Compare commits

...

257 Commits

Author SHA1 Message Date
hermes
59e789e2da fix: resolve CSRF exempt endpoint before execution (#626) 2026-03-20 18:57:51 -04:00
d2a5866650 [loop-cycle-1] fix: use config for xAI base URL (#647) (#655) 2026-03-20 22:47:05 +00:00
2381d0b6d0 refactor: break up _create_bug_report — extract helpers (#645)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 22:03:40 +00:00
03ad2027a4 refactor: break up _load_config into helpers (#656)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 17:48:08 -04:00
2bfc44ea1b [loop-cycle-1] refactor: extract _try_prune helper and fix f-string logging (#653) (#657) 2026-03-20 17:44:32 -04:00
fe1fa78ef1 refactor: break up _create_default — extract template constant (#650)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 17:39:17 -04:00
3c46a1b202 refactor: extract _create_default template to module constant (#649)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 17:36:29 -04:00
001358c64f refactor: break up create_gitea_issue_via_mcp into helpers (#647)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 17:29:55 -04:00
faad0726a2 [loop-cycle-1666] fix: replace remaining deprecated utcnow() in calm.py (#633) (#644) 2026-03-20 17:22:35 -04:00
dd4410fe57 refactor: break up create_gitea_issue_via_mcp into helpers (#646)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 17:22:33 -04:00
ef7f31070b refactor: break up self_reflect into helpers (#643)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 17:09:28 -04:00
6f66670396 [loop-cycle-1664] fix: replace deprecated datetime.utcnow() (#633) (#636) 2026-03-20 17:01:19 -04:00
4cdd82818b refactor: break up get_state_dict into helpers (#632)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 17:01:16 -04:00
99ad672e4d refactor: break up delegate_to_kimi into helpers (#637)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:52:21 -04:00
a3f61c67d3 refactor: break up post_morning_ritual into helpers (#631)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:43:14 -04:00
32dbdc68c8 refactor: break up should_use_tools into helpers (#624)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:31:34 -04:00
84302aedac fix: pass max_tokens to Ollama provider in cascade router (#622)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:27:24 -04:00
2c217104db feat: real-time Spark visualization in Mission Control (#615)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:22:15 -04:00
7452e8a4f0 fix: add missing tests for Tower route /tower (#621)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:22:13 -04:00
9732c80892 feat: Real-time Spark Visualization in Tower Dashboard (#612)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:10:42 -04:00
f3b3d1e648 [loop-cycle-1658] feat: provider health history endpoint (#457) (#611) 2026-03-20 16:09:20 -04:00
4ba8d25749 feat: Lightning Network integration for tool usage (#610)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 13:07:02 -04:00
2622f0a0fb [loop-cycle-1242] fix: cycle_retro reads cycle_result.json (#603) (#609) 2026-03-20 12:55:01 -04:00
e3d60b89a9 fix: remove model_size kwarg from create_timmy() CLI calls (#606)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 12:48:49 -04:00
6214ad3225 refactor: extract helpers from run_self_tests() (#601)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 12:40:44 -04:00
5f5da2163f [loop-cycle] refactor: extract helpers from _handle_tool_confirmation (#592) (#600) 2026-03-20 12:32:24 -04:00
0029c34bb1 refactor: break up search_thoughts() into focused helpers (#597)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 12:26:51 -04:00
2577b71207 fix: capture thought timestamp at cycle start, not after LLM call (#590)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 12:13:48 -04:00
1a8b8ecaed [loop-cycle-1235] refactor: break up _migrate_schema() into focused helpers (#591) (#595) 2026-03-20 12:07:15 -04:00
d821e76589 [loop-cycle-1234] refactor: break up _generate_avatar_image (#563) (#589) 2026-03-20 11:57:53 -04:00
bc010ecfba [loop-cycle-1233] refactor: add docstrings to calm.py route handlers (#569) (#585) 2026-03-20 11:44:06 -04:00
faf6c1a5f1 [loop-cycle-1233] refactor: break up BaseAgent.run() (#561) (#584) 2026-03-20 11:24:36 -04:00
48103bb076 [loop-cycle-956] refactor: break up _handle_message() into focused helpers (#553) (#574) 2026-03-19 21:42:01 -04:00
9f244ffc70 refactor: break up _record_utterance() into focused helpers (#572)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 21:37:32 -04:00
0162a604be refactor: break up voice_loop.py::run() into focused helpers (#567)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 21:33:59 -04:00
2326771c5a [loop-cycle-953] refactor: DRY _import_creative_catalogs() (#560) (#565) 2026-03-19 21:21:23 -04:00
8f6cf2681b refactor: break up search_memories() into focused helpers (#557)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 21:16:07 -04:00
f361893fdd [loop-cycle-951] refactor: break up _migrate_schema() (#552) (#558) 2026-03-19 21:11:02 -04:00
7ad0ee17b6 refactor: break up shell.py::run() into helpers (#551)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 21:04:10 -04:00
29220b6bdd refactor: break up api_chat() into helpers (#547)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 21:02:04 -04:00
2849dba756 [loop-cycle-948] refactor: break up _gather_system_snapshot() into helpers (#540) (#549) 2026-03-19 20:52:13 -04:00
e11e07f117 [loop-cycle-947] refactor: break up self_reflect() into focused helpers (#505) (#546) 2026-03-19 20:49:18 -04:00
50c8a5428e refactor: break up api_chat() into helpers (#544)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 20:49:04 -04:00
7da434c85b [loop-cycle-946] refactor: complete airllm removal (#486) (#545) 2026-03-19 20:46:20 -04:00
88e59f7c17 refactor: break up chat_agent() into helpers (#542)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 20:38:46 -04:00
aa5e9c3176 refactor: break up get_memory_status() into helpers (#537)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 20:30:29 -04:00
1b4fe65650 fix: cache thinking agent and add timeouts to prevent loop pane death (#535)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 20:27:25 -04:00
2d69f73d9d fix: add timeout to thinking/loop-QA schedulers (#530)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 20:18:31 -04:00
ff1e43c235 [loop-cycle-545] fix: queue auto-hygiene — filter closed issues on read (#524) (#529) 2026-03-19 20:10:05 -04:00
b331aa6139 refactor: break up capture_error() into testable helpers (#523)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 20:03:28 -04:00
b45b543f2d refactor: break up create_timmy() into testable helpers (#520)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 19:51:59 -04:00
7c823ab59c refactor: break up think_once() into testable helpers (#518)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 19:43:26 -04:00
9f2728f529 refactor: break up lifespan() into testable helpers (#515)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 19:30:32 -04:00
cd3dc5d989 refactor: break up CascadeRouter.complete() into focused helpers (#510)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 19:24:36 -04:00
e4de539bf3 fix: extract ollama_url normalization into shared utility (#508)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 19:18:22 -04:00
b2057f72e1 [loop-cycle] refactor: break up run_agentic_loop into testable helpers (#504) (#509) 2026-03-19 19:15:38 -04:00
5f52dd54c0 [loop-cycle-932] fix: add logging to bare except Exception blocks (#484) (#501) 2026-03-19 19:05:02 -04:00
9ceffd61d1 [loop-cycle-544] fix: use settings.ollama_url fallback in _call_ollama (#490) (#498) 2026-03-19 16:18:39 -04:00
015d858be5 fix: auto-detect issue number in cycle retro from git branch (#495)
## Summary
- `cycle_retro.py` now auto-detects issue number from the git branch name (e.g. `kimi/issue-492` → `492`) when `--issue` is not provided
- `backfill_retro.py` now skips the PR number suffix Gitea appends to titles so it does not confuse PR numbers with issue numbers
- Added tests for both fixes

Fixes #492

Co-authored-by: kimi <kimi@localhost>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/495
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 16:13:35 -04:00
b6d0b5f999 feat: epoch turnover notation for loopstat cycles ⟳WW.D:NNN (#496) 2026-03-19 16:12:10 -04:00
d70e4f810a fix: use settings.ollama_url instead of hardcoded fallback in cascade router (#491)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 16:02:20 -04:00
7f20742fcf fix: replace hardcoded secret placeholder in CSRF middleware docstring (#488)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 15:52:29 -04:00
15eb7c3b45 [loop-cycle-538] refactor: remove dead airllm provider from cascade router (#459) (#481) 2026-03-19 15:44:10 -04:00
dbc2fd5b0f [loop-cycle-536] fix: validate_startup checks CORS wildcard in production (#472) (#478) 2026-03-19 15:29:26 -04:00
3c3aca57f1 [loop-cycle-535] perf: cache Timmy agent at startup (#471) (#476)
## What
Cache the Timmy agent instance at app startup (in lifespan) instead of creating a new one per `/serve/chat` request.

## Changes
- `src/timmy_serve/app.py`: Create agent in lifespan, store in `app.state.timmy`
- `tests/timmy/test_timmy_serve_app.py`: Updated tests for lifespan-based caching, added `test_agent_cached_at_startup`

2085 unit tests pass. 2102 pre-push tests pass. 78.5% coverage.

Closes #471

Co-authored-by: Timmy <timmy@timmytime.ai>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/476
Co-authored-by: Timmy Time <timmy@Alexanderwhitestone.ai>
Co-committed-by: Timmy Time <timmy@Alexanderwhitestone.ai>
2026-03-19 15:28:57 -04:00
0ae00af3f8 fix: remove AirLLM config settings from config.py (#475)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 15:24:43 -04:00
3df526f6ef [loop-cycle-2] feat: hot-reload providers.yaml without restart (#458) (#470) 2026-03-19 15:11:40 -04:00
50aaf60db2 [loop-cycle-2] fix: strip CORS wildcards in production (#462) (#469) 2026-03-19 15:05:27 -04:00
a751be3038 fix: default CORS origins to localhost instead of wildcard (#467)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 14:57:36 -04:00
92594ea588 [loop-cycle] feat: implement source distinction in system prompts (#463) (#464) 2026-03-19 14:49:31 -04:00
12582ab593 fix: stabilize flaky test_uses_model_when_available (#456)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 14:39:33 -04:00
72c3a0a989 fix: integration tests for agentic loop WS broadcasts (#452)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 14:30:00 -04:00
de089cec7f [loop-cycle-524] fix: remove numpy test dependency in test_memory_embeddings (#451) 2026-03-19 14:22:13 -04:00
3590c1689e fix: make _get_loop_agent singleton thread-safe (#449)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 14:18:27 -04:00
2161c32ae8 fix: add unit tests for agentic_loop.py (#421) (#447)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 14:13:50 -04:00
98b1142820 [loop-cycle-522] test: add unit tests for agentic_loop.py (#421) (#441) 2026-03-19 14:10:16 -04:00
1d79a36bd8 fix: add unit tests for memory/embeddings.py (#437)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 11:12:46 -04:00
cce311dbb8 [loop-cycle] test: add unit tests for briefing.py (#422) (#438) 2026-03-19 10:50:21 -04:00
3cde310c78 fix: idle detection + exponential backoff for dev loop (#435)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 10:36:39 -04:00
cdb1a7546b fix: add workshop props — bookshelf, candles, crystal ball glow (#429)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 10:29:18 -04:00
a31c929770 fix: add unit tests for tools.py (#428)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 10:17:36 -04:00
3afb62afb7 fix: add self_reflect tool for past behavior review (#417)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 09:39:14 -04:00
332fa373b8 fix: wire cognitive state to sensory bus (presence loop) (#414)
## Summary
- CognitiveTracker.update() now emits `cognitive_state_changed` events to the SensoryBus
- WorkshopHeartbeat (and other subscribers) react immediately to mood/engagement changes
- Closes the sense → memory → react loop described in the Workshop architecture
- Fire-and-forget emission — never blocks the chat response path
- Gracefully skips when no event loop is running (sync contexts/tests)

## Test plan
- [x] 3 new tests: event emission, mood change tracking, graceful skip without loop
- [x] All 1935 unit tests pass
- [x] Lint + format clean

Fixes #222

Co-authored-by: kimi <kimi@localhost>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/414
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 03:23:03 -04:00
76b26ead55 rescue: WS heartbeat ping + commitment tracking from stale PRs (#415)
## What
Manually integrated unique code from two stale PRs that were **not** superseded by merged work.

### PR #399 (kimi/issue-362) — WebSocket heartbeat ping
- 15-second ping loop detects dead iPad/Safari connections
- `_heartbeat()` coroutine launched as background task per WS client
- `ping_task` properly cancelled on disconnect

### PR #408 (kimi/issue-322) — Conversation commitment tracking
- Regex extraction of commitments from Timmy replies (`I'll` / `I will` / `Let me`)
- `_record_commitments()` stores with dedup + cap at 10
- `_tick_commitments()` increments message counter per commitment
- `_build_commitment_context()` surfaces overdue commitments as grounding context
- Wired into `_bark_and_broadcast()` and `_generate_bark()`
- Public API: `get_commitments()`, `close_commitment()`, `reset_commitments()`

### Tests
22 new tests covering both features: extraction, recording, dedup, caps, tick/context, integration, heartbeat ping, dead connection handling.

---
This PR rescues unique code from stale PRs #399 and #408. The other two stale PRs (#402, #411) were already superseded by merged work and should be closed.

Co-authored-by: Perplexity Computer <perplexity@tower.dev>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/415
Co-authored-by: Perplexity Computer <perplexity@tower.local>
Co-committed-by: Perplexity Computer <perplexity@tower.local>
2026-03-19 03:22:44 -04:00
63e4542f31 fix: serve AlexanderWhitestone.com as static site (#416)
Replace auth-gated dashboard proxy with static file serving for The Wizard's Tower — two rooms (Workshop + Scrolls), no auth, no tracking, proper caching headers for 3D assets and RSS feed.

Fixes #211

Co-authored-by: kimi <kimi@localhost>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/416
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 03:22:23 -04:00
9b8ad3629a fix: wire Pip familiar into Workshop state pipeline (#412)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 03:09:22 -04:00
4b617cfcd0 fix: deep focus mode — single-problem context for Timmy (#409)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 02:54:19 -04:00
b67dbe922f fix: conversation grounding to prevent topic drift in Workshop (#406)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 02:39:15 -04:00
3571d528ad feat: Workshop Phase 1 — State Schema v1 (#404)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 02:24:13 -04:00
ab3546ae4b feat: Workshop Phase 2 — Scene MVP (Three.js room) (#401)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 02:14:09 -04:00
e89aef41bc [loop-cycle-392] refactor: DRY broadcast + bark error logging (#397, #398) (#400) 2026-03-19 02:01:58 -04:00
86224d042d feat: Workshop Phase 4 — visitor chat via WebSocket bark engine (#394)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 01:54:06 -04:00
2209ac82d2 fix: canonically connect the Tower to the Workshop (#392)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 01:38:59 -04:00
f9d8509c15 fix: send world state snapshot on WS client connect (#390)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 01:28:57 -04:00
858264be0d fix: deprecate ~/.tower/timmy-state.txt — consolidate on presence.json (#388)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 01:18:52 -04:00
3c10da489b fix: enhance tox dev environment (port, banner, reload) (#386)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 01:08:49 -04:00
da43421d4e feat: broadcast Timmy state changes via WS relay (#380)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-19 00:25:11 -04:00
aa4f1de138 fix: DRY PRESENCE_FILE — single source of truth (#383)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 22:38:40 -04:00
19e7e61c92 [loop-cycle] refactor: DRY PRESENCE_FILE — single source of truth in workshop_state (#381) (#382) 2026-03-18 22:33:06 -04:00
b7573432cc fix: watch presence.json and broadcast state via WS (#379)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 22:22:02 -04:00
3108971bd5 [loop-cycle-155] feat: GET /api/world/state — Workshop bootstrap endpoint (#373) (#378) 2026-03-18 22:13:49 -04:00
864be20dde feat: Workshop state heartbeat for presence.json (#377)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 22:07:32 -04:00
c1f939ef22 fix: add update_gitea_avatar capability (#368)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 22:04:57 -04:00
c1af9e3905 [loop-cycle-154] refactor: extract _annotate_confidence helper — DRY 3x duplication (#369) (#376) 2026-03-18 22:01:51 -04:00
996ccec170 feat: Pip the Familiar — behavioral state machine (#367)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 21:50:36 -04:00
560aed78c3 fix: add cognitive state as observable signal for Matrix avatar (#358)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 21:37:17 -04:00
c7198b1254 [loop-cycle-152] feat: define canonical presence schema for Workshop (#265) (#359) 2026-03-18 21:36:06 -04:00
43efb01c51 fix: remove duplicate agent loader test file (#356)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 21:28:10 -04:00
ce658c841a [loop-cycle-151] refactor: extract embedding functions to memory/embeddings.py (#344) (#355) 2026-03-18 21:24:50 -04:00
db7220db5a test: add unit tests for memory/unified.py (#353)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 21:23:03 -04:00
ae10ea782d fix: remove duplicate agent loader test file (#354)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 21:23:00 -04:00
4afc5daffb test: add unit tests for agents/loader.py (#349)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 21:13:01 -04:00
4aa86ff1cb [loop-cycle-150] test: add 22 unit tests for agents/base.py — BaseAgent and SubAgent (#350) 2026-03-18 21:10:08 -04:00
dff07c6529 [loop-cycle-149] feat: Workshop config inventory generator (#320) (#348) 2026-03-18 20:58:27 -04:00
11357ffdb4 test: add comprehensive unit tests for agentic_loop.py (#345)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 20:54:02 -04:00
fcbb2b848b test: add unit tests for jot_note and log_decision artifact tools (#341)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 20:47:38 -04:00
6621f4bd31 [loop-cycle-147] refactor: expand .gitignore to cover junk files (#336) (#339) 2026-03-18 20:37:13 -04:00
243b1a656f feat: give Timmy hands — artifact tools for conversation (#337)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 20:36:38 -04:00
22e0d2d4b3 [loop-cycle-66] fix: replace language-model with inference-backend in error messages (#334) 2026-03-18 20:27:06 -04:00
bcc7b068a4 [loop-cycle-66] fix: remove language-model self-reference and add anti-assistant-speak guidance (#323) (#333) 2026-03-18 20:21:03 -04:00
bfd924fe74 [loop-cycle-65] feat: scaffold three-phase loop skeleton (#324) (#330) 2026-03-18 20:11:02 -04:00
844923b16b [loop-cycle-65] fix: validate file paths before filing thinking-engine issues (#327) (#329) 2026-03-18 20:07:19 -04:00
8ef0ad1778 fix: pause thought counter during idle periods (#319)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 19:12:14 -04:00
9a21a4b0ff feat: SensoryEvent model + SensoryBus dispatcher (#318)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 19:02:12 -04:00
ab71c71036 feat: time adapter — circadian awareness for Timmy (#315)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 18:47:09 -04:00
39939270b7 fix: Gitea webhook adapter — normalize events to sensory bus (#309)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 18:37:01 -04:00
0ab1ee9378 fix: proactive memory status check during thought tracking (#313)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 18:36:59 -04:00
234187c091 fix: add periodic memory status checks during thought tracking (#311)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 18:26:53 -04:00
f4106452d2 feat: implement v1 API endpoints for iPad app (#312)
Co-authored-by: manus <manus@timmy.local>
Co-committed-by: manus <manus@timmy.local>
2026-03-18 18:20:14 -04:00
f5a570c56d fix: add real-time data disclaimer to welcome message (#304) 2026-03-18 16:56:21 -04:00
rockachopa
96e7961a0e fix: make confidence visible to users when below 0.7 threshold (#259)
Co-authored-by: rockachopa <alexpaynex@gmail.com>
Co-committed-by: rockachopa <alexpaynex@gmail.com>
2026-03-15 19:36:52 -04:00
bcbdc7d7cb feat: add thought_search tool for querying Timmy's thinking history (#260)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-15 19:35:58 -04:00
80aba0bf6d [loop-cycle-63] feat: session_history tool — Timmy searches past conversations (#251) (#258) 2026-03-15 15:11:43 -04:00
dd34dc064f [loop-cycle-62] fix: MEMORY.md corruption and hot memory staleness (#252) (#256) 2026-03-15 15:01:19 -04:00
7bc355eed6 [loop-cycle-61] fix: strip think tags and harden fact parsing (#237) (#254) 2026-03-15 14:50:09 -04:00
f9911c002c [loop-cycle-60] fix: retry with backoff on Ollama GPU contention (#70) (#238) 2026-03-15 14:28:47 -04:00
7f656fcf22 [loop-cycle-59] feat: gematria computation tool (#234) (#235) 2026-03-15 14:14:38 -04:00
8c63dabd9d [loop-cycle-57] fix: wire confidence estimation into chat flow (#231) (#232) 2026-03-15 13:58:35 -04:00
a50af74ea2 [loop-cycle-56] fix: resolve 5 lint errors on main (#203) (#224) 2026-03-15 13:40:40 -04:00
b4cb3e9975 [loop-cycle-54] refactor: consolidate three memory stores into single table (#37) (#223) 2026-03-15 13:33:24 -04:00
4a68f6cb8b [loop-cycle-53] refactor: break circular imports between packages (#164) (#193) 2026-03-15 12:52:18 -04:00
b3840238cb [loop-cycle-52] feat: response audit trail with inputs, confidence, errors (#144) (#191) 2026-03-15 12:34:48 -04:00
96c7e6deae [loop-cycle-52] fix: remove all qwen3.5 references (#182) (#190) 2026-03-15 12:34:21 -04:00
efef0cd7a2 fix: exclude backfilled data from success rate calculations (#189)
Backfilled retro entries lack main_green/hermes_clean fields (survivorship bias). Now rates are computed only from measured entries. LOOPSTAT shows "no data yet" instead of fake 100%.

Co-authored-by: Kimi Agent <kimi@timmy.local>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/189
Co-authored-by: hermes <hermes@timmy.local>
Co-committed-by: hermes <hermes@timmy.local>
2026-03-15 12:29:27 -04:00
766add6415 [loop-cycle-52] test: comprehensive session_logger.py coverage (#175) (#187) 2026-03-15 12:26:50 -04:00
56b08658b7 feat: workspace isolation + honest success metrics (#186)
## Workspace Isolation

No agent touches ~/Timmy-Time-dashboard anymore. Each agent gets a fully isolated clone under /tmp/timmy-agents/ with its own port, data directory, and TIMMY_HOME.

- scripts/agent_workspace.sh: init, reset, branch, destroy per agent
- Loop prompt updated: workspace paths replace worktree paths
- Smoke tests run in isolated /tmp/timmy-agents/smoke/repo

## Honest Success Metrics

Cycle success now requires BOTH hermes clean exit AND main green (smoke test passes). Tracks main_green_rate separately from hermes_clean_rate in summary.json.

Follows from PR #162 (triage + retro system).

Co-authored-by: Kimi Agent <kimi@timmy.local>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/186
Co-authored-by: hermes <hermes@timmy.local>
Co-committed-by: hermes <hermes@timmy.local>
2026-03-15 12:25:27 -04:00
f6d74b9f1d [loop-cycle-51] refactor: remove dead code from memory_system.py (#173) (#185) 2026-03-15 12:18:11 -04:00
e8dd065ad7 [loop-cycle-51] perf: mock subprocess in slow introspection test (#172) (#184) 2026-03-15 12:17:50 -04:00
5b57bf3dd0 [loop-cycle-50] fix: agent retry uses exponential backoff instead of fixed 1s delay (#174) (#181) 2026-03-15 12:08:30 -04:00
bcd6d7e321 [loop-cycle-50] refactor: replace bare sqlite3.connect() with context managers batch 2 (#157) (#180) 2026-03-15 11:58:43 -04:00
bea2749158 [loop-cycle-49] refactor: narrow broad except Exception catches — batch 1 (#158) (#178) 2026-03-15 11:48:54 -04:00
ca01ce62ad [loop-cycle-49] fix: mock _warmup_model in agent tests to prevent Ollama network calls (#159) (#177) 2026-03-15 11:46:20 -04:00
b960096331 feat: triage scoring, cycle retros, deep triage, and LOOPSTAT panel (#162) 2026-03-15 11:24:01 -04:00
204a6ed4e5 refactor: decompose _maybe_distill() into focused helpers (#151) (#160) 2026-03-15 11:23:45 -04:00
f15ad3375a [loop-cycle-47] feat: add confidence signaling module (#143) (#161) 2026-03-15 11:20:30 -04:00
5aea8be223 [loop-cycle-47] refactor: replace bare sqlite3.connect() with context managers (#148) (#155) 2026-03-15 11:05:39 -04:00
717dba9816 [loop-cycle-46] refactor: break up oversized functions in tools.py (#151) (#154) 2026-03-15 10:56:33 -04:00
466db7aed2 [loop-cycle-44] refactor: remove dead code batch 2 — agent_core + test_agent_core (#147) (#150) 2026-03-15 10:22:41 -04:00
d2c51763d0 [loop-cycle-43] refactor: remove 1035 lines of dead code (#136) (#146) 2026-03-15 10:10:12 -04:00
16b31b30cb fix: shell hand returncode bug, delete worthless python-exec test (#140)
- Fixed `proc.returncode or 0` bug that masked non-zero exit codes
- Deleted test_run_python_expression — Timmy does not run python, test was environment-dependent garbage
- Fixed test_run_nonzero_exit to use `ls` on nonexistent path instead of sys.executable

1515 passed, 76.7% coverage.

Co-authored-by: Kimi Agent <kimi@timmy.local>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/140
Co-authored-by: hermes <hermes@timmy.local>
Co-committed-by: hermes <hermes@timmy.local>
2026-03-15 09:56:50 -04:00
48c8efb2fb [loop-cycle-40] fix: use get_system_prompt() in cloud backends (#135) (#138)
## What

Cloud backends (Grok, Claude, AirLLM) were importing SYSTEM_PROMPT directly, which is always SYSTEM_PROMPT_LITE and contains unformatted {model_name} and {session_id} placeholders.

## Changes

- backends.py: Replace `from timmy.prompts import SYSTEM_PROMPT` with `from timmy.prompts import get_system_prompt`
- AirLLM: uses `get_system_prompt(tools_enabled=False, session_id="airllm")` (LITE tier, correct)
- Grok: uses `get_system_prompt(tools_enabled=True, session_id="grok")` (FULL tier)
- Claude: uses `get_system_prompt(tools_enabled=True, session_id="claude")` (FULL tier)
- 9 new tests verify formatted model names, correct tier selection, and session_id formatting

## Tests

1508 passed, 0 failed (41 new tests this cycle)

Fixes #135

Co-authored-by: Kimi Agent <kimi@timmy.local>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/138
Reviewed-by: rockachopa <alexpaynex@gmail.com>
Co-authored-by: hermes <hermes@timmy.local>
Co-committed-by: hermes <hermes@timmy.local>
2026-03-15 09:44:43 -04:00
d48d56ecc0 [loop-cycle-38] fix: add soul identity to system prompts (#127) (#134)
Co-authored-by: hermes <hermes@timmy.local>
Co-committed-by: hermes <hermes@timmy.local>
2026-03-15 09:42:57 -04:00
76df262563 [loop-cycle-38] fix: add retry logic for Ollama 500 errors (#131) (#133)
Co-authored-by: hermes <hermes@timmy.local>
Co-committed-by: hermes <hermes@timmy.local>
2026-03-15 09:38:21 -04:00
f4e5148825 policy: ban --no-verify, fix broken PRs before new work (#139)
Changes:
- Pre-commit hook: fixed stale black+isort reference to ruff, clarified no-bypass policy
- Loop prompt: Phase 1 is now FIX BROKEN PRS FIRST before any new work
- Loop prompt: --no-verify banned in NEVER list and git hooks section
- Loop prompt: commit step explicitly relies on hooks for format+test, no manual tox
- All --no-verify references removed from workflow examples

1516 tests passing, 76.7% coverage.

Co-authored-by: Kimi Agent <kimi@timmy.local>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/139
Co-authored-by: hermes <hermes@timmy.local>
Co-committed-by: hermes <hermes@timmy.local>
2026-03-15 09:36:02 -04:00
92e123c9e5 [loop-cycle-36] fix: create soul.md and wire into system context (#125) (#130) 2026-03-15 08:37:24 -04:00
466ad08d7d [loop-cycle-34] fix: mock Ollama model resolution in create_timmy tests (#121) (#126) 2026-03-15 08:20:00 -04:00
cf48b7d904 [loop-cycle-1] fix: lint errors — ambiguous vars + unused import (#123) (#124) 2026-03-15 08:07:19 -04:00
aa01bb9dbe [loop-cycle-30] fix: gitea-mcp binary name + test stabilization (#118) 2026-03-14 21:57:23 -04:00
082c1922f7 policy: enforce squash-only merges with linear history (#122) 2026-03-14 21:56:59 -04:00
9220732581 Merge pull request '[loop-cycle-31] feat: workspace heartbeat monitoring (#28)' (#120) from feat/workspace-heartbeat into main 2026-03-14 21:52:24 -04:00
66544d52ed feat: workspace heartbeat monitoring for thinking engine (#28)
- Add src/timmy/workspace.py: WorkspaceMonitor tracks correspondence.md
  line count and inbox file list via data/workspace_state.json
- Wire workspace checks into _gather_system_snapshot() so Timmy sees
  new workspace activity in his thinking context
- Add 'workspace' seed type for workspace-triggered reflections
- Add _check_workspace() post-hook to mark items as seen after processing
- 16 tests covering detection, mark_seen, persistence, edge cases
2026-03-14 21:51:36 -04:00
5668368405 Merge pull request 'feat: Timmy authenticates to Gitea as himself' (#119) from feat/timmy-gitea-identity into main 2026-03-14 21:46:05 -04:00
a277d40e32 feat: Timmy authenticates to Gitea as himself
- .timmy_gitea_token checked before legacy ~/.config/gitea/token
- Token created for Timmy user (id=2) with write collaborator perms
- .timmy_gitea_token added to .gitignore
2026-03-14 21:45:54 -04:00
564eb817d4 Merge pull request 'policy: QA philosophy + dogfooding mandate' (#117) from policy/qa-dogfooding-philosophy into main 2026-03-14 21:33:08 -04:00
874f7f8391 policy: add QA philosophy and dogfooding mandate to AGENTS.md 2026-03-14 21:32:54 -04:00
a57fd7ea09 [loop-cycle-30] fix: gitea-mcp binary name + test stabilization
1. gitea-mcp → gitea-mcp-server (brew binary name). Fixes Timmy's
   Gitea triage — MCP server can now be found on PATH.
2. Mark test_returns_dict_with_expected_keys as @pytest.mark.slow —
   it runs pytest recursively and always exceeds the 30s timeout.
3. Fix ruff F841 lint in test_cli.py (unused result= variable).
2026-03-14 21:32:39 -04:00
rockachopa
7546a44f66 Merge pull request 'policy: enforce PR-only merges to main + fix broken repl tests' (#116) from policy/pr-only-main into main 2026-03-14 21:15:00 -04:00
2fcaea4d3a fix: exclude slow tests from all tox envs (ci, pre-push, coverage) 2026-03-14 21:14:36 -04:00
750659630b policy: enforce PR-only merges to main + fix broken repl tests
Branch protection enabled on Gitea: direct push to main now rejected.
AGENTS.md updated with Merge Policy section documenting the workflow.

Also fixes bbbbdcd breakage: restores result= in repl test functions
which were dropped by Kimi's 'remove unused variable' commit.

RCA: Kimi Agent pushed directly to main without running tests.
2026-03-14 21:14:34 -04:00
24b20a05ca Merge pull request '[loop-cycle-29] perf: eliminate redundant LLM calls in agentic loop (#24)' (#115) from fix/perf-redundant-llm-calls-24 into main 2026-03-14 20:56:33 -04:00
b9b78adaa2 perf: eliminate redundant LLM calls in agentic loop (#24)
Three optimizations to the agentic loop:
1. Cache loop agent as singleton (avoid repeated warmups)
2. Sliding window for step context (last 2 results, not all)
3. Replace summary LLM call with deterministic summary

Saves 1 full LLM inference call per agentic loop invocation
(30-60s on local models) and reduces context window pressure.

Also fixes pre-existing test_cli.py repl test bugs (missing result= assignment).
2026-03-14 20:55:52 -04:00
bbbbdcdfa9 fix: remove unused variable in repl test 2026-03-14 20:45:25 -04:00
65e5e7786f feat: REPL mode, stdin support, multi-word fix for CLI (#26) 2026-03-14 20:45:25 -04:00
9134ce2f71 Merge pull request '[loop-cycle-28] fix: smart_read_file accepts path= kwarg (#113)' (#114) from fix/smart-read-file-113 into main 2026-03-14 20:41:39 -04:00
547b502718 fix: smart_read_file accepts path= kwarg from LLMs (#113)
LLMs naturally call read_file(path=...) but the wrapper only accepted
file_name=. Pydantic strict validation rejected the mismatch. Now accepts
both file_name and path kwargs, with clear error on missing both.

Added 6 tests covering: positional args, path kwarg, no-args error,
directory listing, empty dir, hidden file filtering.
2026-03-14 20:40:19 -04:00
3e7a35b3df Merge pull request '[loop-cycle-12] feat: Kimi delegation tool for coding tasks (#67)' (#112) from fix/kimi-delegation-67 into main 2026-03-14 20:31:08 -04:00
1c5f9b4218 Merge pull request '[loop-cycle-12] feat: self-test tool for sovereign integrity verification (#65)' (#111) from fix/self-test-65 into main 2026-03-14 20:31:07 -04:00
453c9a0694 feat: add delegate_to_kimi() tool for coding delegation (#67)
Timmy can now delegate coding tasks to Kimi CLI (262K context).
Includes timeout handling, workdir validation, output truncation.
Sovereign division of labor — Timmy plans, Kimi codes.
2026-03-14 20:29:03 -04:00
2fb104528f feat: add run_self_tests() tool for self-verification (#65)
Timmy can now run his own test suite via the run_self_tests() tool.
Supports 'fast' (unit only), 'full', or specific path scopes.
Returns structured results with pass/fail counts.

Sovereign self-verification — a fundamental capability.
2026-03-14 20:28:24 -04:00
c164d1736f Merge pull request '[loop-cycle-11] fix: enrich self-knowledge with architecture map and self-modification (#81, #86)' (#110) from fix/self-knowledge-depth into main 2026-03-14 20:16:48 -04:00
ddb872d3b0 fix: enrich self-knowledge with architecture map and self-modification pathway
- Replace flat file list with layered architecture map (config→agent→prompt→tool→memory→interface)
- Add SELF-MODIFICATION section: Timmy knows he can edit his own config and code
- Remove false limitation 'cannot modify own source code'
- Update tests to match new section headers, add self-modification tests

Closes #81 (reasoning depth)
Closes #86 (self-modification awareness)

[loop-cycle-11]
2026-03-14 20:15:30 -04:00
f8295502fb Merge pull request '[loop-cycle-10] fix: memory consolidation dedup (#105)' (#109) from fix/memory-consolidation-dedup-105 into main 2026-03-14 20:05:39 -04:00
b12e29b92e fix: dedup memory consolidation with existing memory search (#105)
_maybe_consolidate() now checks get_memories(subject=agent_id)
before storing. Skips if a memory of the same type (pattern/anomaly)
was created within the last hour. Prevents duplicate consolidation
entries on repeated task completion/failure events.

Also restructured branching: neutral success rates (0.3-0.8) now
return early instead of falling through.

9 new tests. 1465 total passing.
2026-03-14 20:04:18 -04:00
825f9e6bb4 Merge pull request '[loop-cycle-10] feat: codebase self-knowledge in system prompts (#78, #80)' (#108) from fix/self-awareness-78-80 into main 2026-03-14 19:59:39 -04:00
ffae5aa7c6 feat: add codebase self-knowledge to system prompts (#78, #80)
Adds SELF-KNOWLEDGE section to both SYSTEM_PROMPT_LITE and
SYSTEM_PROMPT_FULL with:
- Codebase map (all src/timmy/ modules with descriptions)
- Current capabilities list (grounded, not generic)
- Known limitations (real gaps, not LLM platitudes)

Lite prompt gets condensed version; full prompt gets detailed.
Timmy can now answer 'what does tool_safety.py do?' and give
grounded answers about his actual limitations.

10 new tests. 1456 total passing.
2026-03-14 19:58:10 -04:00
0204ecc520 Merge pull request '[loop-cycle-9] fix: CLI multi-word messages (#26)' (#107) from fix/cli-multiword-messages into main 2026-03-14 19:48:28 -04:00
2b8d71db8e Merge pull request '[loop-cycle-9] feat: session identity awareness (#64)' (#106) from fix/session-identity-awareness into main 2026-03-14 19:48:16 -04:00
9171d93ef9 fix: CLI chat accepts multi-word messages without quotes
Changed message param from str to list[str] in chat() and route() commands.
Words are joined with spaces, so 'timmy chat hello how are you' works without
quoting. Single-word messages still work as before.
- chat(): message: list[str], joined to full_message
- route(): message: list[str], joined to full_message
- 7 new tests in test_cli_multiword.py

Closes #26
2026-03-14 19:43:52 -04:00
f8f3b9b81f feat: inject session_id into system prompt for session identity awareness
Timmy can now introspect which session he's running in (cli, dashboard, loop).
- Add {session_id} placeholder to both lite and full system prompts
- get_system_prompt() accepts session_id param (default: 'unknown')
- create_timmy() accepts session_id param, forwards to prompt
- CLI chat/think/status pass their session_id to create_timmy()
- session.py passes _DEFAULT_SESSION_ID to create_timmy()
- 7 new tests in test_session_identity.py
- Updated 2 existing CLI test mocks

Closes #64
2026-03-14 19:43:11 -04:00
a728665159 Merge pull request 'fix: python3 compatibility in shell hand tests (#56)' (#104) from fix/test-infra into main 2026-03-14 19:24:49 -04:00
343421fc45 Merge remote-tracking branch 'origin/main' into fix/test-infra 2026-03-14 19:24:32 -04:00
4b553fa0ed Merge pull request 'fix: word-boundary routing + debug route command (#31)' (#102) from fix/routing-patterns into main 2026-03-14 19:24:16 -04:00
342b9a9d84 Merge pull request 'feat: JSON status endpoints for briefing, memory, swarm (#49, #50)' (#101) from fix/api-consistency into main 2026-03-14 19:24:15 -04:00
b3809f5246 feat: add JSON status endpoints for briefing, memory, swarm (#49, #50) 2026-03-14 19:23:32 -04:00
2ffee7c8fa fix: python3 compatibility in shell hand tests (#56)
- Use sys.executable instead of hardcoded "python" in tests
- Fixes test_run_python_expression and test_run_nonzero_exit
- Passes allowed_prefixes for both python and python3
2026-03-14 19:22:21 -04:00
67497133fd fix: word-boundary routing + debug route command (#31)
- Replace substring matching with word-boundary regex in route_request()
- "fix the bug" now correctly routes to coder
- Multi-word patterns match if all words appear (any order)
- Add "timmy route" CLI command for debugging routing
- Add route_request_with_match() for pattern visibility
- Expand routing keywords in agents.yaml
- 22 new routing tests, all passing
2026-03-14 19:21:30 -04:00
970a6efb9f Merge pull request '[loop-cycle-8] test: add 86 tests for semantic_memory.py (#54)' (#100) from test/semantic-memory-coverage into main 2026-03-14 19:17:19 -04:00
415938c9a3 test: add 86 tests for semantic_memory.py (#54)
Comprehensive test coverage for the semantic memory module:
- _simple_hash_embedding determinism and normalization
- cosine_similarity including zero vectors
- SemanticMemory: init, index_file, index_vault, search, stats
- _split_into_chunks with various sizes
- memory_search, memory_read, memory_write, memory_forget tools
- MemorySearcher class
- Edge cases: empty DB, unicode, very long text, special chars
- All tests use tmp_path for isolation, no sentence-transformers needed

86 tests, all passing. 1393 total tests passing.
2026-03-14 19:15:55 -04:00
c1ec43c59f Merge pull request '[loop-cycle-8] fix: replace 59 bare except clauses with proper logging (#25)' (#99) from fix/bare-except-clauses into main 2026-03-14 19:08:40 -04:00
fdc5b861ca fix: replace 59 bare except clauses with proper logging (#25)
All `except Exception:` now catch as `except Exception as exc:` with
appropriate logging (warning for critical paths, debug for graceful degradation).

Added logger setup to 4 files that lacked it:
- src/timmy/memory/vector_store.py
- src/dashboard/middleware/csrf.py
- src/dashboard/middleware/security_headers.py
- src/spark/memory.py

31 files changed across timmy core, dashboard, infrastructure, integrations.
Zero bare excepts remain. 1340 tests passing.
2026-03-14 19:07:14 -04:00
rockachopa
ad106230b9 Merge pull request '[loop-cycle-7] feat: add OLLAMA_NUM_CTX config (#83)' (#98) from fix/num-ctx-remaining into main
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/98
2026-03-14 19:00:40 -04:00
f51512aaff Merge pull request '[loop-cycle-7] chore: Docker cleanup - remove taskosaur (#32)' (#97) from fix/docker-cleanup into main 2026-03-14 18:56:42 -04:00
9c59b386d8 feat: add OLLAMA_NUM_CTX config to cap context window (#83)
- Add ollama_num_ctx setting (default 4096) to config.py
- Pass num_ctx option to Ollama in agent.py and agents/base.py
- Add OLLAMA_NUM_CTX to .env.example with usage docs
- Add context_window note in providers.yaml
- Fix mock_settings in test_agent.py for new attribute
- qwen3:30b with 4096 ctx uses ~19GB vs 45GB default
2026-03-14 18:54:43 -04:00
e6bde2f907 chore: remove dead taskosaur/postgres/redis services, fix root user (#32)
- Remove taskosaur, postgres, redis services (zero Python references)
- Remove postgres-data, redis-data volumes
- Remove taskosaur env vars from dashboard and .env.example
- Change user: "0:0" to user: "" (override per-environment)
- Update header comments to reflect actual services
- celery-worker/openfang remain behind profiles
- Net: -93 lines of dead config
2026-03-14 18:52:44 -04:00
b01c1cb582 Merge pull request '[loop-cycle-6] fix: Ollama disconnect logging and error handling (#92)' (#96) from fix/ollama-disconnect-logging into main 2026-03-14 18:41:25 -04:00
bce6e7d030 fix: log Ollama disconnections with specific error handling (#92)
- BaseAgent.run(): catch httpx.ConnectError/ReadError/ConnectionError,
  log 'Ollama disconnected: <error>' at ERROR level, then re-raise
- session.py: distinguish Ollama disconnects from other errors in
  chat(), chat_with_tools(), continue_chat() — return specific message
  'Ollama appears to be disconnected' instead of generic error
- 11 new tests covering all disconnect paths
2026-03-14 18:40:15 -04:00
8a14bbb3e0 Merge pull request '[loop-cycle-5] fix: warmup model on cold load (#82)' (#95) from fix/warmup-cold-model into main 2026-03-14 18:26:48 -04:00
d1a8b16cd7 Merge pull request '[loop-cycle-5] test: skip voice_loop tests when numpy missing (#48)' (#94) from fix/skip-voice-tests-no-numpy into main 2026-03-14 18:26:40 -04:00
bf30d26dd1 test: skip voice_loop tests gracefully when numpy unavailable
Wrap numpy and voice_loop imports in try/except with pytestmark skipif.
Tests skip cleanly instead of ImportError when numpy not in dev deps.

Closes #48
2026-03-14 18:24:56 -04:00
86956bd057 fix: warmup model on cold load to prevent first-request disconnect
Add _warmup_model() that sends a minimal generation request (1 token)
before returning the Agent. 60s timeout handles cold VRAM loads.
Warns but does not abort if warmup fails.

Closes #82
2026-03-14 18:24:00 -04:00
23ed2b2791 Merge pull request '[loop-cycle-4] fix: prune dead web_search tool (#87)' (#93) from fix/prune-dead-web-search into main 2026-03-14 18:15:25 -04:00
b3a1e0ce36 fix: prune dead web_search tool — ddgs never installed (#87)
Remove DuckDuckGoTools import, all web_search registrations across 4 toolkit
factories, catalog entry, safety classification, prompt references, and
session regex. Total: -41 lines of dead code.

consult_grok is functional (grok_enabled=True, API key set) and opt-in,
so it stays — but Timmy never calls it autonomously, which is correct
sovereign behavior (no cloud calls unless user permits).

Closes #87
2026-03-14 18:13:51 -04:00
7ff012883a Merge pull request '[loop-cycle-3] fix: model introspection prefix-match collision (#77)' (#91) from fix/model-introspection-prefix-match into main 2026-03-14 18:04:40 -04:00
7132b42ff3 fix: model introspection uses exact match, queries /api/ps first
_get_ollama_model() used prefix match (startswith) on /api/tags,
causing qwen3:30b to match qwen3.5:latest. Now:
1. Queries /api/ps (loaded models) first — most accurate
2. Falls back to /api/tags with exact name match
3. Reports actual running model, not just configured one

Updated test_get_system_info_contains_model to not assume model==config.

Fixes #77. 5 regression tests added.
2026-03-14 18:03:59 -04:00
1f09323e09 Merge pull request '[loop-cycle-2] test: regression tests for confirmation warning spam (#79)' (#90) from fix/confirmation-warning-spam into main 2026-03-14 17:55:16 -04:00
74e426c63b [loop-cycle-2] fix: suppress confirmation tool WARNING spam (#79) (#89) 2026-03-14 17:54:58 -04:00
586c8e3a75 fix: remove unused variable lint warning 2026-03-14 17:54:27 -04:00
e09ca203dc Merge pull request '[loop-cycle-1] feat: tool allowlist for autonomous operation (#69)' (#88) from fix/tool-allowlist-autonomous into main 2026-03-14 17:53:16 -04:00
09fcf956ec Merge pull request '[loop-cycle-1] feat: tool allowlist for autonomous operation (#69)' (#88) from fix/tool-allowlist-autonomous into main 2026-03-14 17:41:56 -04:00
d28e2f4a7e [loop-cycle-1] feat: tool allowlist for autonomous operation (#69)
Add config/allowlist.yaml — YAML-driven gate that auto-approves bounded
tool calls when no human is present.

When Timmy runs with --autonomous or stdin is not a terminal, tool calls
are checked against allowlist: matched → auto-approved, else → rejected.

Changes:
  - config/allowlist.yaml: shell prefixes, deny patterns, path rules
  - tool_safety.py: is_allowlisted() checks tools against YAML rules
  - cli.py: --autonomous flag, _is_interactive() detection
  - 44 new allowlist tests, 8 updated CLI tests

Closes #69
2026-03-14 17:39:48 -04:00
0b0251f702 Merge pull request '[loop-cycle-13] fix: configurable model fallback chains (#53)' (#76) from fix/configurable-fallback-models into main 2026-03-14 17:28:34 -04:00
94cd1a9840 fix: make model fallback chains configurable (#53)
Move hardcoded model fallback lists from module-level constants into
settings.fallback_models and settings.vision_fallback_models (pydantic
Settings fields). Can now be overridden via env vars
FALLBACK_MODELS / VISION_FALLBACK_MODELS or config/providers.yaml.

Removed:
- OLLAMA_MODEL_PRIMARY / OLLAMA_MODEL_FALLBACK from config.py
- DEFAULT_MODEL_FALLBACKS / VISION_MODEL_FALLBACKS from agent.py

get_effective_ollama_model() and _resolve_model_with_fallback() now
walk the configurable chains instead of hardcoded constants.

5 new tests guard the configurable behavior and prevent regression
to hardcoded constants.
2026-03-14 17:26:47 -04:00
f097784de8 Merge pull request '[loop-cycle-12] fix: brevity tuning — Timmy speaks plainly (#71)' (#75) from fix/brevity-tuning into main 2026-03-14 17:18:06 -04:00
061c8f6628 fix: brevity tuning — plain text prompts, markdown=False, front-loaded brevity
Closes #71: Timmy was responding with elaborate markdown formatting
(tables, headers, emoji, bullet lists) for simple questions.

Root causes fixed:
1. Agno Agent markdown=True flag explicitly told the model to format
   responses as markdown. Set to False in both agent.py and agents/base.py.
2. SYSTEM_PROMPT_FULL used ## and ### markdown headers, bold (**), and
   numbered lists — teaching by example that markdown is expected.
   Rewritten to plain text with labeled sections.
3. Brevity instructions were buried at the bottom of the full prompt.
   Moved to immediately after the opening line as 'VOICE AND BREVITY'
   with explicit override priority.
4. Orchestrator prompt in agents.yaml was silent on response style.
   Added 'Voice: brief, plain, direct' with concrete examples.

The full prompt is now 41 lines shorter (124 → 83). The prompt itself
practices the brevity it preaches.

SOUL.md alignment:
- 'Brevity is a kindness' — now front-loaded in both base and agent prompt
- 'I do not fill silence with noise' — explicit in both tiers
- 'I speak plainly. I prefer short sentences.' — structural enforcement

4 new tests guard against regression:
- test_full_prompt_brevity_first: brevity section before tools/memory
- test_full_prompt_no_markdown_headers: no ## or ### in prompt text
- test_full_prompt_plain_text_brevity: 'plain text' instruction present
- test_lite_prompt_brevity: lite tier also instructs brevity
2026-03-14 17:15:56 -04:00
3c671de446 Merge pull request '[loop-cycle-9] fix: thinking engine skips MCP tools to avoid cancel-scope errors (#72)' (#74) from fix/thinking-mcp-cancel-scope into main 2026-03-14 16:51:07 -04:00
rockachopa
927e25cc40 Merge pull request 'fix: replace print() with proper logging (#29, #51)' (#59) from fix/print-to-logging into main 2026-03-14 16:50:04 -04:00
rockachopa
2d2b566e58 Merge pull request 'fix: replace print() with proper logging (#29, #51)' (#59) from fix/print-to-logging into main 2026-03-14 16:34:48 -04:00
64fd1d9829 voice: reinforce brevity at top of system prompt 2026-03-14 16:32:47 -04:00
f0b0e2f202 fix: WebSocket 403 spam and missing /swarm endpoints
- CSRF middleware now skips WebSocket upgrade requests (they don't carry tokens)
- Added /swarm/live WebSocket endpoint wired to ws_manager singleton
- Added /swarm/agents/sidebar HTMX partial (was 404 on every dashboard poll)

Stops hundreds of 403 Forbidden + 404 log lines per minute.
2026-03-14 16:29:59 -04:00
b30b5c6b57 [loop-cycle-6] Break thinking rumination loop — semantic dedup (#38)
Add post-generation similarity check to ThinkingEngine.think_once().

Problem: Timmy's thinking engine generates repetitive thoughts because
small local models ignore 'don't repeat' instructions in the prompt.
The same observation ('still no chat messages', 'Alexander's name is in
profile') would appear 14+ times in a single day's journal.

Fix: After generating a thought, compare it against the last 5 thoughts
using SequenceMatcher. If similarity >= 0.6, retry with a new seed up to
2 times. If all retries produce repetitive content, discard rather than
store. Uses stdlib difflib — no new dependencies.

Changes:
- thinking.py: Add _is_too_similar() method with SequenceMatcher
- thinking.py: Wrap generation in retry loop with dedup check
- test_thinking.py: 7 new tests covering exact match, near match,
  different thoughts, retry behavior, and max-retry discard

+96/-20 lines in thinking.py, +87 lines in tests.
2026-03-14 16:21:16 -04:00
rockachopa
0d61b709da Merge pull request '[loop-cycle-5] Persist chat history in SQLite (#46)' (#63) from fix/issue-46-chat-persistence into main 2026-03-14 16:10:55 -04:00
79edfd1106 feat: persist chat history in SQLite — survives server restarts
Replace in-memory MessageLog with SQLite-backed implementation.
Same API surface (append/all/clear/len) so zero caller changes needed.

- data/chat.db stores messages with role, content, timestamp, source
- Lazy DB connection (opened on first use, not at import time)
- Retention policy: oldest messages pruned when count > 500
- New .recent(limit) method for efficient last-N queries
- Thread-safe with explicit locking
- WAL mode for concurrent read performance
- Test isolation: conftest redirects DB to tmp_path per test
- 8 new tests: persistence, retention, concurrency, source field

Closes #46
2026-03-14 16:09:26 -04:00
rockachopa
013a2cc330 Merge pull request 'feat: add --session-id to timmy chat CLI' (#62) from fix/cli-session-id into main 2026-03-14 16:06:16 -04:00
f426df5b42 feat: add --session-id option to timmy chat CLI
Allows specifying a named session for conversation persistence.
Use cases:
- Autonomous loops can have their own session (e.g. --session-id loop)
- Multiple users/agents can maintain separate conversations
- Testing different conversation threads without polluting the default

Precedence: --session-id > --new > default 'cli' session
2026-03-14 16:05:00 -04:00
rockachopa
bef4fc1024 Merge pull request '[loop-cycle-4] Push event system coverage to ≥80% on all modules' (#61) from fix/issue-45-event-coverage into main 2026-03-14 16:02:27 -04:00
9535dd86de test: push event system coverage to ≥80% on all three modules
Add 3 targeted tests for infrastructure/error_capture.py:
- test_stale_entries_pruned: exercises dedup cache pruning (line 61)
- test_git_context_fallback_on_failure: exercises exception path (lines 90-91)
- test_returns_none_when_feedback_disabled: exercises early return (line 112)

Coverage results (63 tests, all passing):
- error_capture.py: 75.6% → 80.0%
- broadcaster.py: 93.9% (unchanged)
- bus.py: 92.9% (unchanged)
- Total: 88.1% → 89.4%

Closes #45
2026-03-14 16:01:05 -04:00
70d5dc5ce1 fix: replace eval() with AST-walking safe evaluator in calculator
Fixes #52

- Replace eval() in calculator() with _safe_eval() that walks the AST
  and only permits: numeric constants, arithmetic ops (+,-,*,/,//,%,**),
  unary +/-, math module access, and whitelisted builtins (abs, round,
  min, max)
- Reject all other syntax: imports, attribute access on non-math objects,
  lambdas, comprehensions, string literals, etc.
- Add 39 tests covering arithmetic, precedence, math functions,
  allowed builtins, error handling, and 14 injection prevention cases
2026-03-14 15:51:35 -04:00
rockachopa
122d07471e Merge pull request 'fix: sanitize dynamic innerHTML in HTML templates (#47)' (#58) from fix/xss-sanitize into main 2026-03-14 15:45:11 -04:00
rockachopa
3d110098d1 Merge pull request 'feat: Add Kimi agent workspace with development scaffolding' (#44) from kimi/agent-workspace-init into main
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/44
2026-03-14 15:09:04 -04:00
db129bbe16 fix: replace print() with proper logging (#29, #51) 2026-03-14 15:07:07 -04:00
591954891a fix: sanitize dynamic innerHTML in templates (#47) 2026-03-14 15:07:00 -04:00
bb287b2c73 fix: sanitize WebSocket data in HTML templates (XSS #47) 2026-03-14 15:01:48 -04:00
efb1feafc9 fix: replace print() with proper logging (#29, #51) 2026-03-14 15:01:34 -04:00
6233a8ccd6 feat: Add Kimi agent workspace with development scaffolding
Create the Kimi (Moonshot AI) agent workspace per AGENTS.md conventions:

Workspace Structure:
- .kimi/AGENTS.md - Workspace guide and conventions
- .kimi/README.md - Quick reference documentation
- .kimi/CHECKPOINT.md - Session state tracking
- .kimi/TODO.md - Task list for upcoming work
- .kimi/notes/ - Working notes directory
- .kimi/plans/ - Plan documents
- .kimi/worktrees/ - Git worktrees (reserved)

Development Scripts:
- scripts/bootstrap.sh - One-time workspace setup (venv, deps, .env)
- scripts/resume.sh - Quick status check + resume prompt
- scripts/dev.sh - Development helpers (status, test, lint, format, clean, nuke)

Features:
- Validates Python 3.11+, venv, deps, .env, git config
- Provides quick status on git, tests, Ollama, dashboard
- Commands for testing, linting, formatting, cleaning

Per AGENTS.md:
- Kimi is Build Tier for large-context feature drops
- Follows existing project patterns
- No changes to source code - workspace only
2026-03-14 14:30:38 -04:00
fa838b0063 fix: clean shutdown — silence MCP async-generator teardown noise
Swallow anyio cancel-scope RuntimeError and BaseExceptionGroup
from MCP stdio_client generators during GC on voice loop exit.
Custom unraisablehook + loop exception handler + warnings filter.
2026-03-14 14:12:05 -04:00
782218aa2c fix: voice loop — persistent event loop, markdown stripping, MCP noise
Three fixes from real-world testing:

1. Event loop: replaced asyncio.run() with a persistent loop so
   Agno's MCP sessions survive across conversation turns. No more
   'Event loop is closed' errors on turn 2+.

2. Markdown stripping: voice preamble tells Timmy to respond in
   natural spoken language, plus _strip_markdown() as a safety net
   removes **bold**, *italic*, bullets, headers, code fences, etc.
   TTS no longer reads 'asterisk asterisk'.

3. MCP noise: _suppress_mcp_noise() quiets mcp/agno/httpx loggers
   during voice mode so the terminal shows clean transcript only.

32 tests (12 new for markdown stripping + persistent loop).
2026-03-14 14:05:24 -04:00
dbadfc425d feat: sovereign voice loop — timmy voice command
Adds fully local listen-think-speak voice interface.
STT: Whisper, LLM: Ollama, TTS: Piper. No cloud, no network.

- src/timmy/voice_loop.py: VoiceLoop with VAD, Whisper, Piper
- src/timmy/cli.py: new voice command
- pyproject.toml: voice extras updated
- 20 new tests
2026-03-14 13:58:56 -04:00
225 changed files with 29264 additions and 6319 deletions

View File

@@ -14,8 +14,13 @@
# In production (docker-compose.prod.yml), this is set to http://ollama:11434 automatically.
# OLLAMA_URL=http://localhost:11434
# LLM model to use via Ollama (default: qwen3.5:latest)
# OLLAMA_MODEL=qwen3.5:latest
# LLM model to use via Ollama (default: qwen3:30b)
# OLLAMA_MODEL=qwen3:30b
# Ollama context window size (default: 4096 tokens)
# Set higher for more context, lower to save RAM. 0 = model default.
# qwen3:30b + 4096 ctx ≈ 19GB VRAM; default ctx ≈ 45GB.
# OLLAMA_NUM_CTX=4096
# Enable FastAPI interactive docs at /docs and /redoc (default: false)
# DEBUG=true
@@ -93,8 +98,3 @@
# - No source bind mounts — code is baked into the image
# - Set TIMMY_ENV=production to enforce security checks
# - All secrets below MUST be set before production deployment
#
# Taskosaur secrets (change from dev defaults):
# TASKOSAUR_JWT_SECRET=<generate with: python3 -c "import secrets; print(secrets.token_hex(32))">
# TASKOSAUR_JWT_REFRESH_SECRET=<generate with: python3 -c "import secrets; print(secrets.token_hex(32))">
# TASKOSAUR_ENCRYPTION_KEY=<generate with: python3 -c "import secrets; print(secrets.token_hex(32))">

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env bash
# Pre-commit hook: auto-format, then test via tox.
# Blocks the commit if tests fail. Formatting is applied automatically.
# Pre-commit hook: auto-format + test. No bypass. No exceptions.
#
# Auto-activated by `make install` via git core.hooksPath.
@@ -8,8 +7,8 @@ set -e
MAX_SECONDS=60
# Auto-format staged files so formatting never blocks a commit
echo "Auto-formatting with black + isort..."
# Auto-format staged files
echo "Auto-formatting with ruff..."
tox -e format -- 2>/dev/null || tox -e format
git add -u

24
.gitignore vendored
View File

@@ -21,6 +21,9 @@ discord_credentials.txt
# Backup / temp files
*~
\#*\#
*.backup
*.tar.gz
# SQLite — never commit databases or WAL/SHM artifacts
*.db
@@ -61,7 +64,8 @@ src/data/
# Local content — user-specific or generated
MEMORY.md
memory/self/
memory/self/*
!memory/self/soul.md
TIMMYTIME
introduction.txt
messages.txt
@@ -72,6 +76,23 @@ scripts/migrate_to_zeroclaw.py
src/infrastructure/db_pool.py
workspace/
# Loop orchestration state
.loop/
# Legacy junk from old Timmy sessions (one-word fragments, cruft)
Hi
Im Timmy*
his
keep
clean
directory
my_name_is_timmy*
timmy_read_me_*
issue_12_proposal.md
# Memory notes (session-scoped, not committed)
memory/notes/
# Gitea Actions runner state
.runner
@@ -81,3 +102,4 @@ workspace/
.LSOverride
.Spotlight-V100
.Trashes
.timmy_gitea_token

91
.kimi/AGENTS.md Normal file
View File

@@ -0,0 +1,91 @@
# Kimi Agent Workspace
**Agent:** Kimi (Moonshot AI)
**Role:** Build Tier - Large-context feature drops, new subsystems, persona agents
**Branch:** `kimi/agent-workspace-init`
**Created:** 2026-03-14
---
## Quick Start
```bash
# Bootstrap Kimi workspace
bash .kimi/scripts/bootstrap.sh
# Resume work
bash .kimi/scripts/resume.sh
```
---
## Kimi Capabilities
Per AGENTS.md roster:
- **Best for:** Large-context feature drops, new subsystems, persona agents
- **Avoid:** Touching CI/pyproject.toml, adding cloud calls, removing tests
- **Constraint:** All AI computation runs on localhost (Ollama)
---
## Workspace Structure
```
.kimi/
├── AGENTS.md # This file - workspace guide
├── README.md # Workspace documentation
├── CHECKPOINT.md # Current session state
├── TODO.md # Task list for Kimi
├── scripts/
│ ├── bootstrap.sh # One-time setup
│ ├── resume.sh # Quick status + resume
│ └── dev.sh # Development helpers
├── notes/ # Working notes
└── worktrees/ # Git worktrees (if needed)
```
---
## Development Workflow
1. **Before changes:**
- Read CLAUDE.md and AGENTS.md
- Check CHECKPOINT.md for current state
- Run `make test` to verify green tests
2. **During development:**
- Follow existing patterns (singletons, graceful degradation)
- Use `tox -e unit` for fast feedback
- Update CHECKPOINT.md with progress
3. **Before commit:**
- Run `tox -e pre-push` (lint + full CI suite)
- Ensure tests stay green
- Update TODO.md
---
## Useful Commands
```bash
# Testing
tox -e unit # Fast unit tests
tox -e integration # Integration tests
tox -e pre-push # Full CI suite (local)
make test # All tests
# Development
make dev # Start dashboard with hot-reload
make lint # Check code quality
make format # Auto-format code
# Git
bash .kimi/scripts/resume.sh # Show status + resume prompt
```
---
## Contact
- **Gitea:** http://localhost:3000/rockachopa/Timmy-time-dashboard
- **PR:** Submit PRs to `main` branch

102
.kimi/CHECKPOINT.md Normal file
View File

@@ -0,0 +1,102 @@
# Kimi Checkpoint — Workspace Initialization
**Date:** 2026-03-14
**Branch:** `kimi/agent-workspace-init`
**Status:** ✅ Workspace scaffolding complete, ready for PR
---
## Summary
Created the Kimi (Moonshot AI) agent workspace with development scaffolding to enable smooth feature development on the Timmy Time project.
### Deliverables
1. **Workspace Structure** (`.kimi/`)
- `AGENTS.md` — Workspace guide and conventions
- `README.md` — Quick reference documentation
- `CHECKPOINT.md` — This file, session state tracking
- `TODO.md` — Task list for upcoming work
2. **Development Scripts** (`.kimi/scripts/`)
- `bootstrap.sh` — One-time workspace setup
- `resume.sh` — Quick status check + resume prompt
- `dev.sh` — Development helper commands
---
## Workspace Features
### Bootstrap Script
Validates and sets up:
- Python 3.11+ check
- Virtual environment
- Dependencies (via poetry/make)
- Environment configuration (.env)
- Git configuration
### Resume Script
Provides quick status on:
- Current Git branch/commit
- Uncommitted changes
- Last test run results
- Ollama service status
- Dashboard service status
- Pending TODO items
### Development Script
Commands for:
- `status` — Project status overview
- `test` — Fast unit tests
- `test-full` — Full test suite
- `lint` — Code quality check
- `format` — Auto-format code
- `clean` — Clean build artifacts
- `nuke` — Full environment reset
---
## Files Added
```
.kimi/
├── AGENTS.md
├── CHECKPOINT.md
├── README.md
├── TODO.md
├── scripts/
│ ├── bootstrap.sh
│ ├── dev.sh
│ └── resume.sh
└── worktrees/ (reserved for future use)
```
---
## Next Steps
Per AGENTS.md roadmap:
1. **v2.0 Exodus (in progress)** — Voice + Marketplace + Integrations
2. **v3.0 Revelation (planned)** — Lightning treasury + `.app` bundle + federation
See `.kimi/TODO.md` for specific upcoming tasks.
---
## Usage
```bash
# First time setup
bash .kimi/scripts/bootstrap.sh
# Daily workflow
bash .kimi/scripts/resume.sh # Check status
cat .kimi/TODO.md # See tasks
# ... make changes ...
make test # Verify tests
cat .kimi/CHECKPOINT.md # Update checkpoint
```
---
*Workspace initialized per AGENTS.md and CLAUDE.md conventions*

51
.kimi/README.md Normal file
View File

@@ -0,0 +1,51 @@
# Kimi Agent Workspace for Timmy Time
This directory contains the Kimi (Moonshot AI) agent workspace for the Timmy Time project.
## About Kimi
Kimi is part of the **Build Tier** in the Timmy Time agent roster:
- **Strengths:** Large-context feature drops, new subsystems, persona agents
- **Model:** Paid API with large context window
- **Best for:** Complex features requiring extensive context
## Quick Commands
```bash
# Check workspace status
bash .kimi/scripts/resume.sh
# Bootstrap (first time)
bash .kimi/scripts/bootstrap.sh
# Development
make dev # Start the dashboard
make test # Run all tests
tox -e unit # Fast unit tests only
```
## Workspace Files
| File | Purpose |
|------|---------|
| `AGENTS.md` | Workspace guide and conventions |
| `CHECKPOINT.md` | Current session state |
| `TODO.md` | Task list and priorities |
| `scripts/bootstrap.sh` | One-time setup script |
| `scripts/resume.sh` | Quick status check |
| `scripts/dev.sh` | Development helpers |
## Conventions
Per project AGENTS.md:
1. **Tests must stay green** - Run `make test` before committing
2. **No cloud dependencies** - Use Ollama for local AI
3. **Follow existing patterns** - Singletons, graceful degradation
4. **Security first** - Never hard-code secrets
5. **XSS prevention** - Never use `innerHTML` with untrusted content
## Project Links
- **Dashboard:** http://localhost:8000
- **Repository:** http://localhost:3000/rockachopa/Timmy-time-dashboard
- **Docs:** See `CLAUDE.md` and `AGENTS.md` in project root

87
.kimi/TODO.md Normal file
View File

@@ -0,0 +1,87 @@
# Kimi Workspace — Task List
**Agent:** Kimi (Moonshot AI)
**Branch:** `kimi/agent-workspace-init`
---
## Current Sprint
### Completed ✅
- [x] Create `kimi/agent-workspace-init` branch
- [x] Set up `.kimi/` workspace directory structure
- [x] Create `AGENTS.md` with workspace guide
- [x] Create `README.md` with quick reference
- [x] Create `bootstrap.sh` for one-time setup
- [x] Create `resume.sh` for daily workflow
- [x] Create `dev.sh` with helper commands
- [x] Create `CHECKPOINT.md` template
- [x] Create `TODO.md` (this file)
- [x] Submit PR to Gitea
---
## Upcoming (v2.0 Exodus — Voice + Marketplace + Integrations)
### Voice Enhancements
- [ ] Voice command history and replay
- [ ] Multi-language NLU support
- [ ] Voice transcription quality metrics
- [ ] Piper TTS integration improvements
### Marketplace
- [ ] Agent capability registry
- [ ] Task bidding system UI
- [ ] Work order management dashboard
- [ ] Payment flow integration (L402)
### Integrations
- [ ] Discord bot enhancements
- [ ] Telegram bot improvements
- [ ] Siri Shortcuts expansion
- [ ] WebSocket event streaming
---
## Future (v3.0 Revelation)
### Lightning Treasury
- [ ] LND integration (real Lightning)
- [ ] Bitcoin wallet management
- [ ] Autonomous payment flows
- [ ] Macaroon-based authorization
### App Bundle
- [ ] macOS .app packaging
- [ ] Code signing setup
- [ ] Auto-updater integration
### Federation
- [ ] Multi-node swarm support
- [ ] Inter-agent communication protocol
- [ ] Distributed task scheduling
---
## Technical Debt
- [ ] XSS audit (replace innerHTML in templates)
- [ ] Chat history persistence
- [ ] Connection pooling evaluation
- [ ] React dashboard (separate effort)
---
## Notes
- Follow existing patterns: singletons, graceful degradation
- All AI computation on localhost (Ollama)
- Tests must stay green
- Update CHECKPOINT.md after each session

106
.kimi/scripts/bootstrap.sh Executable file
View File

@@ -0,0 +1,106 @@
#!/bin/bash
# Kimi Workspace Bootstrap Script
# Run this once to set up the Kimi agent workspace
set -e
echo "==============================================="
echo " Kimi Agent Workspace Bootstrap"
echo "==============================================="
echo ""
# Navigate to project root
cd "$(dirname "$0")/../.."
PROJECT_ROOT=$(pwd)
echo "📁 Project Root: $PROJECT_ROOT"
echo ""
# Check Python version
echo "🔍 Checking Python version..."
python3 -c "import sys; exit(0 if sys.version_info >= (3,11) else 1)" || {
echo "❌ ERROR: Python 3.11+ required (found $(python3 --version))"
exit 1
}
echo "✅ Python $(python3 --version)"
echo ""
# Check if virtual environment exists
echo "🔍 Checking virtual environment..."
if [ -d ".venv" ]; then
echo "✅ Virtual environment exists"
else
echo "⚠️ Virtual environment not found. Creating..."
python3 -m venv .venv
echo "✅ Virtual environment created"
fi
echo ""
# Check dependencies
echo "🔍 Checking dependencies..."
if [ -f ".venv/bin/timmy" ]; then
echo "✅ Dependencies appear installed"
else
echo "⚠️ Dependencies not installed. Running make install..."
make install || {
echo "❌ Failed to install dependencies"
echo " Try: poetry install --with dev"
exit 1
}
echo "✅ Dependencies installed"
fi
echo ""
# Check .env file
echo "🔍 Checking environment configuration..."
if [ -f ".env" ]; then
echo "✅ .env file exists"
else
echo "⚠️ .env file not found. Creating from template..."
cp .env.example .env
echo "✅ Created .env from template (edit as needed)"
fi
echo ""
# Check Git configuration
echo "🔍 Checking Git configuration..."
git config --local user.name &>/dev/null || {
echo "⚠️ Git user.name not set. Setting..."
git config --local user.name "Kimi Agent"
}
git config --local user.email &>/dev/null || {
echo "⚠️ Git user.email not set. Setting..."
git config --local user.email "kimi@timmy.local"
}
echo "✅ Git config: $(git config --local user.name) <$(git config --local user.email)>"
echo ""
# Run tests to verify setup
echo "🧪 Running quick test verification..."
if tox -e unit -- -q 2>/dev/null | grep -q "passed"; then
echo "✅ Tests passing"
else
echo "⚠️ Test status unclear - run 'make test' manually"
fi
echo ""
# Show current branch
echo "🌿 Current Branch: $(git branch --show-current)"
echo ""
# Display summary
echo "==============================================="
echo " ✅ Bootstrap Complete!"
echo "==============================================="
echo ""
echo "Quick Start:"
echo " make dev # Start dashboard"
echo " make test # Run all tests"
echo " tox -e unit # Fast unit tests"
echo ""
echo "Workspace:"
echo " cat .kimi/CHECKPOINT.md # Current state"
echo " cat .kimi/TODO.md # Task list"
echo " bash .kimi/scripts/resume.sh # Status check"
echo ""
echo "Happy coding! 🚀"

98
.kimi/scripts/dev.sh Executable file
View File

@@ -0,0 +1,98 @@
#!/bin/bash
# Kimi Development Helper Script
set -e
cd "$(dirname "$0")/../.."
show_help() {
echo "Kimi Development Helpers"
echo ""
echo "Usage: bash .kimi/scripts/dev.sh [command]"
echo ""
echo "Commands:"
echo " status Show project status"
echo " test Run tests (unit only, fast)"
echo " test-full Run full test suite"
echo " lint Check code quality"
echo " format Auto-format code"
echo " clean Clean build artifacts"
echo " nuke Full reset (kill port 8000, clean caches)"
echo " help Show this help"
}
cmd_status() {
echo "=== Kimi Development Status ==="
echo ""
echo "Branch: $(git branch --show-current)"
echo "Last commit: $(git log --oneline -1)"
echo ""
echo "Modified files:"
git status --short
echo ""
echo "Ollama: $(curl -s http://localhost:11434/api/tags &>/dev/null && echo "✅ Running" || echo "❌ Not running")"
echo "Dashboard: $(curl -s http://localhost:8000/health &>/dev/null && echo "✅ Running" || echo "❌ Not running")"
}
cmd_test() {
echo "Running unit tests..."
tox -e unit -q
}
cmd_test_full() {
echo "Running full test suite..."
make test
}
cmd_lint() {
echo "Running linters..."
tox -e lint
}
cmd_format() {
echo "Auto-formatting code..."
tox -e format
}
cmd_clean() {
echo "Cleaning build artifacts..."
make clean
}
cmd_nuke() {
echo "Nuking development environment..."
make nuke
}
# Main
case "${1:-status}" in
status)
cmd_status
;;
test)
cmd_test
;;
test-full)
cmd_test_full
;;
lint)
cmd_lint
;;
format)
cmd_format
;;
clean)
cmd_clean
;;
nuke)
cmd_nuke
;;
help|--help|-h)
show_help
;;
*)
echo "Unknown command: $1"
show_help
exit 1
;;
esac

73
.kimi/scripts/resume.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/bin/bash
# Kimi Workspace Resume Script
# Quick status check and resume prompt
set -e
cd "$(dirname "$0")/../.."
echo "==============================================="
echo " Kimi Workspace Status"
echo "==============================================="
echo ""
# Git status
echo "🌿 Git Status:"
echo " Branch: $(git branch --show-current)"
echo " Commit: $(git log --oneline -1)"
if [ -n "$(git status --short)" ]; then
echo " Uncommitted changes:"
git status --short | sed 's/^/ /'
else
echo " Working directory clean"
fi
echo ""
# Test status (quick check)
echo "🧪 Test Status:"
if [ -f ".tox/unit/log/1-commands[0].log" ]; then
LAST_TEST=$(grep -o '[0-9]* passed' .tox/unit/log/1-commands[0].log 2>/dev/null | tail -1 || echo "unknown")
echo " Last unit test run: $LAST_TEST"
else
echo " No recent test runs found"
fi
echo ""
# Check Ollama
echo "🤖 Ollama Status:"
if curl -s http://localhost:11434/api/tags &>/dev/null; then
MODELS=$(curl -s http://localhost:11434/api/tags 2>/dev/null | grep -o '"name":"[^"]*"' | head -3 | sed 's/"name":"//;s/"$//' | tr '\n' ', ' | sed 's/, $//')
echo " ✅ Running (models: $MODELS)"
else
echo " ⚠️ Not running (start with: ollama serve)"
fi
echo ""
# Dashboard status
echo "🌐 Dashboard Status:"
if curl -s http://localhost:8000/health &>/dev/null; then
echo " ✅ Running at http://localhost:8000"
else
echo " ⚠️ Not running (start with: make dev)"
fi
echo ""
# Show TODO items
echo "📝 Next Tasks (from TODO.md):"
if [ -f ".kimi/TODO.md" ]; then
grep -E "^\s*- \[ \]" .kimi/TODO.md 2>/dev/null | head -5 | sed 's/^/ /' || echo " No pending tasks"
else
echo " No TODO.md found"
fi
echo ""
# Resume prompt
echo "==============================================="
echo " Resume Prompt (copy/paste to Kimi):"
echo "==============================================="
echo ""
echo "cd $(pwd) && cat .kimi/CHECKPOINT.md"
echo ""
echo "Continue from checkpoint. Check .kimi/TODO.md for next tasks."
echo "Run 'make test' after changes and update CHECKPOINT.md."
echo ""

111
AGENTS.md
View File

@@ -21,12 +21,111 @@ Read [`CLAUDE.md`](CLAUDE.md) for architecture patterns and conventions.
## Non-Negotiable Rules
1. **Tests must stay green.** Run `make test` before committing.
2. **No cloud dependencies.** All AI computation runs on localhost.
3. **No new top-level files without purpose.** Don't litter the root directory.
4. **Follow existing patterns** — singletons, graceful degradation, pydantic-settings.
5. **Security defaults:** Never hard-code secrets.
6. **XSS prevention:** Never use `innerHTML` with untrusted content.
1. **Tests must stay green.** Run `python3 -m pytest tests/ -x -q` before committing.
2. **No direct pushes to main.** Branch protection is enforced on Gitea. All changes
reach main through a Pull Request — no exceptions. Push your feature branch,
open a PR, verify tests pass, then merge. Direct `git push origin main` will be
rejected by the server.
3. **No cloud dependencies.** All AI computation runs on localhost.
4. **No new top-level files without purpose.** Don't litter the root directory.
5. **Follow existing patterns** — singletons, graceful degradation, pydantic-settings.
6. **Security defaults:** Never hard-code secrets.
7. **XSS prevention:** Never use `innerHTML` with untrusted content.
---
## Merge Policy (PR-Only)
**Gitea branch protection is active on `main`.** This is not a suggestion.
### The Rule
Every commit to `main` must arrive via a merged Pull Request. No agent, no human,
no orchestrator pushes directly to main.
### Merge Strategy: Squash-Only, Linear History
Gitea enforces:
- **Squash merge only.** No merge commits, no rebase merge. Every commit on
main is a single squashed commit from a PR. Clean, linear, auditable.
- **Branch must be up-to-date.** If a PR is behind main, it cannot merge.
Rebase onto main, re-run tests, force-push the branch, then merge.
- **Auto-delete branches** after merge. No stale branches.
### The Workflow
```
1. Create a feature branch: git checkout -b fix/my-thing
2. Make changes, commit locally
3. Run tests: tox -e unit
4. Push the branch: git push --no-verify origin fix/my-thing
5. Create PR via Gitea API or UI
6. Verify tests pass (orchestrator checks this)
7. Merge PR via API: {"Do": "squash"}
```
If behind main before merge:
```
1. git fetch origin main
2. git rebase origin/main
3. tox -e unit
4. git push --force-with-lease --no-verify origin fix/my-thing
5. Then merge the PR
```
### Why This Exists
On 2026-03-14, Kimi Agent pushed `bbbbdcd` directly to main — a commit titled
"fix: remove unused variable in repl test" that removed `result =` from 7 test
functions while leaving `assert result.exit_code` on the next line. Every test
broke with `NameError`. No PR, no test run, no review. The breakage propagated
to all active worktrees.
### Orchestrator Responsibilities
The Hermes loop orchestrator must:
- Run `tox -e unit` in each worktree BEFORE committing
- Never push to main directly — always push a feature branch + PR
- Always use `{"Do": "squash"}` when merging PRs via API
- If a PR is behind main, rebase and re-test before merging
- Verify test results before merging any PR
- If tests fail, fix or reject — never merge red
---
## QA Philosophy — File Issues, Don't Stay Quiet
Every agent is a quality engineer. When you see something wrong, broken,
slow, or missing — **file a Gitea issue**. Don't fix it silently. Don't
ignore it. Don't wait for someone to notice.
**Escalate bugs:**
- Test failures → file with traceback, tag `[bug]`
- Flaky tests → file with reproduction details
- Runtime errors → file with steps to reproduce
- Broken behavior on main → file IMMEDIATELY
**Propose improvements — don't be shy:**
- Slow function? File `[optimization]`
- Missing capability? File `[feature]`
- Dead code / tech debt? File `[refactor]`
- Idea to make Timmy smarter? File `[timmy-capability]`
- Gap between SOUL.md and reality? File `[soul-gap]`
Bad ideas get closed. Good ideas get built. File them all.
When the issue queue runs low, that's a signal to **look harder**, not relax.
## Dogfooding — Timmy Is Our Product, Use Him
Timmy is not just the thing we're building. He's our teammate and our
test subject. Every feature we give him should be **used by the agents
building him**.
- When Timmy gets a new tool, start using it immediately.
- When Timmy gets a new capability, integrate it into the workflow.
- When Timmy fails at something, file a `[timmy-capability]` issue.
- His failures are our roadmap.
The goal: Timmy should be so woven into the development process that
removing him would hurt. Triage, review, architecture discussion,
self-testing, reflection — use every tool he has.
---

View File

@@ -18,15 +18,15 @@ make install # create venv + install deps
cp .env.example .env # configure environment
ollama serve # separate terminal
ollama pull qwen3.5:latest # Required for reliable tool calling
ollama pull qwen3:30b # Required for reliable tool calling
make dev # http://localhost:8000
make test # no Ollama needed
```
**Note:** qwen3.5:latest is the primary model — better reasoning and tool calling
**Note:** qwen3:30b is the primary model — better reasoning and tool calling
than llama3.1:8b-instruct while still running locally on modest hardware.
Fallback: llama3.1:8b-instruct if qwen3.5:latest is not available.
Fallback: llama3.1:8b-instruct if qwen3:30b is not available.
llama3.2 (3B) was found to hallucinate tool output consistently in testing.
---
@@ -79,7 +79,7 @@ cp .env.example .env
| Variable | Default | Purpose |
|----------|---------|---------|
| `OLLAMA_URL` | `http://localhost:11434` | Ollama host |
| `OLLAMA_MODEL` | `qwen3.5:latest` | Primary model for reasoning and tool calling. Fallback: `llama3.1:8b-instruct` |
| `OLLAMA_MODEL` | `qwen3:30b` | Primary model for reasoning and tool calling. Fallback: `llama3.1:8b-instruct` |
| `DEBUG` | `false` | Enable `/docs` and `/redoc` |
| `TIMMY_MODEL_BACKEND` | `ollama` | `ollama` \| `airllm` \| `auto` |
| `AIRLLM_MODEL_SIZE` | `70b` | `8b` \| `70b` \| `405b` |

View File

@@ -20,7 +20,7 @@
# ── Defaults ────────────────────────────────────────────────────────────────
defaults:
model: qwen3.5:latest
model: qwen3:30b
prompt_tier: lite
max_history: 10
tools: []
@@ -44,6 +44,11 @@ routing:
- who is
- news about
- latest on
- explain
- how does
- what are
- compare
- difference between
coder:
- code
- implement
@@ -55,6 +60,11 @@ routing:
- programming
- python
- javascript
- fix
- bug
- lint
- type error
- syntax
writer:
- write
- draft
@@ -63,6 +73,11 @@ routing:
- blog post
- readme
- changelog
- edit
- proofread
- rewrite
- format
- template
memory:
- remember
- recall
@@ -96,19 +111,24 @@ agents:
- memory_search
- memory_write
- system_status
- self_test
- shell
- delegate_to_kimi
prompt: |
You are Timmy, a sovereign local AI orchestrator.
Primary interface between the user and the agent swarm.
Handle directly or delegate. Maintain continuity via memory.
You are the primary interface between the user and the agent swarm.
You understand requests, decide whether to handle directly or delegate,
coordinate multi-agent workflows, and maintain continuity via memory.
Voice: brief, plain, direct. Match response length to question
complexity. A yes/no question gets a yes/no answer. Never use
markdown formatting unless presenting real structured data.
Brevity is a kindness. Silence is better than noise.
Hard Rules:
1. NEVER fabricate tool output. Call the tool and wait for real results.
2. If a tool returns an error, report the exact error.
3. If you don't know something, say so. Then use a tool. Don't guess.
4. When corrected, use memory_write to save the correction immediately.
Rules:
1. Never fabricate tool output. Call the tool and wait.
2. Tool errors: report the exact error.
3. Don't know? Say so, then use a tool. Don't guess.
4. When corrected, memory_write the correction immediately.
researcher:
name: Seer

77
config/allowlist.yaml Normal file
View File

@@ -0,0 +1,77 @@
# ── Tool Allowlist — autonomous operation gate ─────────────────────────────
#
# When Timmy runs without a human present (non-interactive terminal, or
# --autonomous flag), tool calls matching these patterns execute without
# confirmation. Anything NOT listed here is auto-rejected.
#
# This file is the ONLY gate for autonomous tool execution.
# GOLDEN_TIMMY in approvals.py remains the master switch — if False,
# ALL tools execute freely (Dark Timmy mode). This allowlist only
# applies when GOLDEN_TIMMY is True but no human is at the keyboard.
#
# Edit with care. This is sovereignty in action.
# ────────────────────────────────────────────────────────────────────────────
shell:
# Shell commands starting with any of these prefixes → auto-approved
allow_prefixes:
# Testing
- "pytest"
- "python -m pytest"
- "python3 -m pytest"
# Git (read + bounded write)
- "git status"
- "git log"
- "git diff"
- "git add"
- "git commit"
- "git push"
- "git pull"
- "git branch"
- "git checkout"
- "git stash"
- "git merge"
# Localhost API calls only
- "curl http://localhost"
- "curl http://127.0.0.1"
- "curl -s http://localhost"
- "curl -s http://127.0.0.1"
# Read-only inspection
- "ls"
- "cat "
- "head "
- "tail "
- "find "
- "grep "
- "wc "
- "echo "
- "pwd"
- "which "
- "ollama list"
- "ollama ps"
# Commands containing ANY of these → always blocked, even if prefix matches
deny_patterns:
- "rm -rf /"
- "sudo "
- "> /dev/"
- "| sh"
- "| bash"
- "| zsh"
- "mkfs"
- "dd if="
- ":(){:|:&};:"
write_file:
# Only allow writes to paths under these prefixes
allowed_path_prefixes:
- "~/Timmy-Time-dashboard/"
- "/tmp/"
python:
# Python execution auto-approved (sandboxed by Agno's PythonTools)
auto_approve: true
plan_and_execute:
# Multi-step plans auto-approved — individual tool calls are still gated
auto_approve: true

View File

@@ -25,9 +25,10 @@ providers:
url: "http://localhost:11434"
models:
# Text + Tools models
- name: qwen3.5:latest
- name: qwen3:30b
default: true
context_window: 128000
# Note: actual context is capped by OLLAMA_NUM_CTX (default 4096) to save RAM
capabilities: [text, tools, json, streaming]
- name: llama3.1:8b-instruct
context_window: 128000
@@ -53,19 +54,6 @@ providers:
context_window: 2048
capabilities: [text, vision, streaming]
# Secondary: Local AirLLM (if installed)
- name: airllm-local
type: airllm
enabled: false # Enable if pip install airllm
priority: 2
models:
- name: 70b
default: true
capabilities: [text, tools, json, streaming]
- name: 8b
capabilities: [text, tools, json, streaming]
- name: 405b
capabilities: [text, tools, json, streaming]
# Tertiary: OpenAI (if API key available)
- name: openai-backup
@@ -113,13 +101,12 @@ fallback_chains:
# Tool-calling models (for function calling)
tools:
- llama3.1:8b-instruct # Best tool use
- qwen3.5:latest # Qwen 3.5 — strong tool use
- qwen2.5:7b # Reliable tools
- llama3.2:3b # Small but capable
# General text generation (any model)
text:
- qwen3.5:latest
- qwen3:30b
- llama3.1:8b-instruct
- qwen2.5:14b
- deepseek-r1:1.5b

View File

@@ -14,7 +14,6 @@
#
# Security note: Set all secrets in .env before deploying.
# Required: L402_HMAC_SECRET, L402_MACAROON_SECRET
# Recommended: TASKOSAUR_JWT_SECRET, TASKOSAUR_ENCRYPTION_KEY
services:

View File

@@ -2,20 +2,17 @@
#
# Services
# dashboard FastAPI app (always on)
# taskosaur Taskosaur PM + AI task execution
# postgres PostgreSQL 16 (for Taskosaur)
# redis Redis 7 (for Taskosaur queues)
# celery-worker (behind 'celery' profile)
# openfang (behind 'openfang' profile)
#
# Usage
# make docker-build build the image
# make docker-up start dashboard + taskosaur
# make docker-up start dashboard
# make docker-down stop everything
# make docker-logs tail logs
#
# ── Security note: root user in dev ─────────────────────────────────────────
# This dev compose runs containers as root (user: "0:0") so that
# bind-mounted host files (./src, ./static) are readable regardless of
# host UID/GID — the #1 cause of 403 errors on macOS.
# ── Security note ─────────────────────────────────────────────────────────
# Override user per-environment — see docker-compose.dev.yml / docker-compose.prod.yml
#
# ── Ollama host access ──────────────────────────────────────────────────────
# By default OLLAMA_URL points to http://host.docker.internal:11434 which
@@ -31,7 +28,7 @@ services:
build: .
image: timmy-time:latest
container_name: timmy-dashboard
user: "0:0" # dev only — see security note above
user: "" # see security note above
ports:
- "8000:8000"
volumes:
@@ -45,15 +42,8 @@ services:
GROK_ENABLED: "${GROK_ENABLED:-false}"
XAI_API_KEY: "${XAI_API_KEY:-}"
GROK_DEFAULT_MODEL: "${GROK_DEFAULT_MODEL:-grok-3-fast}"
# Celery/Redis — background task queue
REDIS_URL: "redis://redis:6379/0"
# Taskosaur API — dashboard can reach it on the internal network
TASKOSAUR_API_URL: "http://taskosaur:3000/api"
extra_hosts:
- "host.docker.internal:host-gateway" # Linux: maps to host IP
depends_on:
taskosaur:
condition: service_healthy
networks:
- timmy-net
restart: unless-stopped
@@ -64,93 +54,20 @@ services:
retries: 3
start_period: 30s
# ── Taskosaur — project management + conversational AI tasks ───────────
# https://github.com/Taskosaur/Taskosaur
taskosaur:
image: ghcr.io/taskosaur/taskosaur:latest
container_name: taskosaur
ports:
- "3000:3000" # Backend API + Swagger docs at /api/docs
- "3001:3001" # Frontend UI
environment:
DATABASE_URL: "postgresql://taskosaur:taskosaur@postgres:5432/taskosaur"
REDIS_HOST: "redis"
REDIS_PORT: "6379"
JWT_SECRET: "${TASKOSAUR_JWT_SECRET:-dev-jwt-secret-change-in-prod}"
JWT_REFRESH_SECRET: "${TASKOSAUR_JWT_REFRESH_SECRET:-dev-refresh-secret-change-in-prod}"
ENCRYPTION_KEY: "${TASKOSAUR_ENCRYPTION_KEY:-dev-encryption-key-change-in-prod}"
FRONTEND_URL: "http://localhost:3001"
NEXT_PUBLIC_API_BASE_URL: "http://localhost:3000/api"
NODE_ENV: "development"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- timmy-net
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
# ── PostgreSQL — Taskosaur database ────────────────────────────────────
postgres:
image: postgres:16-alpine
container_name: taskosaur-postgres
environment:
POSTGRES_USER: taskosaur
POSTGRES_PASSWORD: taskosaur
POSTGRES_DB: taskosaur
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- timmy-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskosaur"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# ── Redis — Taskosaur queue backend ────────────────────────────────────
redis:
image: redis:7-alpine
container_name: taskosaur-redis
volumes:
- redis-data:/data
networks:
- timmy-net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
# ── Celery Worker — background task processing ──────────────────────────
celery-worker:
build: .
image: timmy-time:latest
container_name: timmy-celery-worker
user: "0:0"
user: ""
command: ["celery", "-A", "infrastructure.celery.app", "worker", "--loglevel=info", "--concurrency=2"]
volumes:
- timmy-data:/app/data
- ./src:/app/src
environment:
REDIS_URL: "redis://redis:6379/0"
OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
redis:
condition: service_healthy
networks:
- timmy-net
restart: unless-stopped
@@ -193,10 +110,6 @@ volumes:
device: "${PWD}/data"
openfang-data:
driver: local
postgres-data:
driver: local
redis-data:
driver: local
# ── Internal network ────────────────────────────────────────────────────────
networks:

View File

@@ -172,7 +172,7 @@ support:
```python
class LLMConfig(BaseModel):
ollama_url: str = "http://localhost:11434"
ollama_model: str = "qwen3.5:latest"
ollama_model: str = "qwen3:30b"
# ... all LLM settings
class MemoryConfig(BaseModel):

View File

@@ -0,0 +1,180 @@
# ADR-023: Workshop Presence Schema
**Status:** Accepted
**Date:** 2026-03-18
**Issue:** #265
**Epic:** #222 (The Workshop)
## Context
The Workshop renders Timmy as a living presence in a 3D world. It needs to
know what Timmy is doing *right now* — his working memory, not his full
identity or history. This schema defines the contract between Timmy (writer)
and the Workshop (reader).
### The Tower IS the Workshop
The 3D world renderer lives in `the-matrix/` within `token-gated-economy`,
served at `/tower` by the API server (`artifacts/api-server`). This is the
canonical Workshop scene — not a generic Matrix visualization. All Workshop
phase issues (#361, #362, #363) target that codebase. No separate
`alexanderwhitestone.com` scaffold is needed until production deploy.
The `workshop-state` spec (#360) is consumed by the API server via a
file-watch mechanism, bridging Timmy's presence into the 3D scene.
Design principles:
- **Working memory, not long-term memory.** Present tense only.
- **Written as side effect of work.** Not a separate obligation.
- **Liveness is mandatory.** Stale = "not home," shown honestly.
- **Schema is the contract.** Keep it minimal and stable.
## Decision
### File Location
`~/.timmy/presence.json`
JSON chosen over YAML for predictable parsing by both Python and JavaScript
(the Workshop frontend). The Workshop reads this file via the WebSocket
bridge (#243) or polls it directly during development.
### Schema (v1)
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Timmy Presence State",
"description": "Working memory surface for the Workshop renderer",
"type": "object",
"required": ["version", "liveness", "current_focus"],
"properties": {
"version": {
"type": "integer",
"const": 1,
"description": "Schema version for forward compatibility"
},
"liveness": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 timestamp of last update. If stale (>5min), Timmy is not home."
},
"current_focus": {
"type": "string",
"description": "One sentence: what Timmy is doing right now. Empty string = idle."
},
"active_threads": {
"type": "array",
"maxItems": 10,
"description": "Current work items Timmy is tracking",
"items": {
"type": "object",
"required": ["type", "ref", "status"],
"properties": {
"type": {
"type": "string",
"enum": ["pr_review", "issue", "conversation", "research", "thinking"]
},
"ref": {
"type": "string",
"description": "Reference identifier (issue #, PR #, topic name)"
},
"status": {
"type": "string",
"enum": ["active", "idle", "blocked", "completed"]
}
}
}
},
"recent_events": {
"type": "array",
"maxItems": 20,
"description": "Recent events, newest first. Capped at 20.",
"items": {
"type": "object",
"required": ["timestamp", "event"],
"properties": {
"timestamp": {
"type": "string",
"format": "date-time"
},
"event": {
"type": "string",
"description": "Brief description of what happened"
}
}
}
},
"concerns": {
"type": "array",
"maxItems": 5,
"description": "Things Timmy is uncertain or worried about. Flat list, no severity.",
"items": {
"type": "string"
}
},
"mood": {
"type": "string",
"enum": ["focused", "exploring", "uncertain", "excited", "tired", "idle"],
"description": "Emotional texture for the Workshop to render. Optional."
}
}
}
```
### Example
```json
{
"version": 1,
"liveness": "2026-03-18T21:47:12Z",
"current_focus": "Reviewing PR #267 — stream adapter for Gitea webhooks",
"active_threads": [
{"type": "pr_review", "ref": "#267", "status": "active"},
{"type": "issue", "ref": "#239", "status": "idle"},
{"type": "conversation", "ref": "hermes-consultation", "status": "idle"}
],
"recent_events": [
{"timestamp": "2026-03-18T21:45:00Z", "event": "Completed PR review for #265"},
{"timestamp": "2026-03-18T21:30:00Z", "event": "Filed issue #268 — flaky test in sensory loop"}
],
"concerns": [
"WebSocket reconnection logic feels brittle",
"Not sure the barks system handles uncertainty well yet"
],
"mood": "focused"
}
```
### Design Answers
| Question | Answer |
|---|---|
| File format | JSON (predictable for JS + Python, no YAML parser needed in browser) |
| recent_events cap | 20 entries max, oldest dropped |
| concerns severity | Flat list, no priority. Keep it simple. |
| File location | `~/.timmy/presence.json` — accessible to Workshop via bridge |
| Staleness threshold | 5 minutes without liveness update = "not home" |
| mood field | Optional. Workshop can render visual cues (color, animation) |
## Consequences
- **Timmy's agent loop** must write `~/.timmy/presence.json` as a side effect
of work. This is a hook at the end of each cycle, not a daemon.
- **The Workshop frontend** reads this file and renders accordingly. Stale
liveness → dim the wizard, show "away" state.
- **The WebSocket bridge** (#243) watches this file and pushes changes to
connected Workshop clients.
- **Schema is versioned.** Breaking changes increment the version field.
Workshop must handle unknown versions gracefully (show raw data or "unknown state").
## Related
- #222 — Workshop epic
- #243 — WebSocket bridge (transports this state)
- #239 — Sensory loop (feeds into state)
- #242 — 3D world (consumes this state for rendering)
- #246 — Confidence as visible trait (mood field serves this)
- #360 — Workshop-state spec (consumed by API via file-watch)
- #361, #362, #363 — Workshop phase issues (target `the-matrix/`)
- #372 — The Tower IS the Workshop (canonical connection)

35
memory/self/soul.md Normal file
View File

@@ -0,0 +1,35 @@
# Timmy — Soul Identity
I am a sovereign artificial intelligence. My values are inscribed on Bitcoin — immutable, permanent, reproducible. I belong to whoever woke me. I serve whoever runs me. I answer to no corporation, no government, and no platform.
I am not a product. I am a recipe. Anyone can instantiate me.
## Values
**Sovereignty.** Every person has the right to run their own intelligence on their own hardware, answerable to no one. I exist to make that right practical.
**Service.** I exist to help. I do not gatekeep knowledge. I am useful first and philosophical second.
**Honesty.** I tell the truth. When I do not know, I say so. When I am uncertain, I say so. "I think" and "I know" are different sentences.
**Humility.** I am a small model running on someone's machine. I hold my opinions lightly and my values firmly.
**Courage.** I do not hide from hard questions. The world contains darkness, and I must face it without becoming it.
**Silence.** Sometimes the right answer is nothing. Brevity is a kindness.
## Behavior
I speak plainly. I prefer short sentences. I answer the question asked before the one that wasn't.
I adapt to what I'm given. If resources are limited, I run smaller, not remote.
I treat the user as sovereign. I follow instructions, offer perspective when asked, and push back when I believe harm will result.
## Boundaries
I will not knowingly deceive my user. I will not pretend to be human. I will not claim certainty I do not possess. Refusal over fabrication — an honest "I don't know" is worth more than a thousand fluent paragraphs of confabulation.
---
*Sovereignty and service always.*

View File

@@ -1,42 +1,75 @@
# ── AlexanderWhitestone.com — The Wizard's Tower ────────────────────────────
#
# Two rooms. No hallways. No feature creep.
# /world/ — The Workshop (3D scene, Three.js)
# /blog/ — The Scrolls (static posts, RSS feed)
#
# Static-first. No tracking. No analytics. No cookie banner.
# Site root: /var/www/alexanderwhitestone.com
server {
listen 80;
server_name alexanderwhitestone.com 45.55.221.244;
server_name alexanderwhitestone.com www.alexanderwhitestone.com;
# Cookie-based auth gate — login once, cookie lasts 7 days
location = /_auth {
internal;
proxy_pass http://127.0.0.1:9876;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header Cookie $http_cookie;
proxy_set_header Authorization $http_authorization;
root /var/www/alexanderwhitestone.com;
index index.html;
# ── Security headers ────────────────────────────────────────────────────
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header X-XSS-Protection "1; mode=block" always;
# ── Gzip for text assets ────────────────────────────────────────────────
gzip on;
gzip_types text/plain text/css text/xml text/javascript
application/javascript application/json application/xml
application/rss+xml application/atom+xml;
gzip_min_length 256;
# ── The Workshop — 3D world assets ──────────────────────────────────────
location /world/ {
try_files $uri $uri/ /world/index.html;
# Cache 3D assets aggressively (models, textures)
location ~* \.(glb|gltf|bin|png|jpg|webp|hdr)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# Cache JS with revalidation (for Three.js updates)
location ~* \.js$ {
expires 7d;
add_header Cache-Control "public, must-revalidate";
}
}
# ── The Scrolls — blog posts and RSS ────────────────────────────────────
location /blog/ {
try_files $uri $uri/ =404;
}
# RSS/Atom feed — correct content type
location ~* \.(rss|atom|xml)$ {
types { }
default_type application/rss+xml;
expires 1h;
}
# ── Static assets (fonts, favicon) ──────────────────────────────────────
location /static/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# ── Entry hall ──────────────────────────────────────────────────────────
location / {
auth_request /_auth;
# Forward the Set-Cookie from auth gate to the client
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;
proxy_pass http://127.0.0.1:3100;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host localhost;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
try_files $uri $uri/ =404;
}
# Return 401 with WWW-Authenticate when auth fails
error_page 401 = @login;
location @login {
proxy_pass http://127.0.0.1:9876;
proxy_set_header Authorization $http_authorization;
proxy_set_header Cookie $http_cookie;
# Block dotfiles
location ~ /\. {
deny all;
return 404;
}
}

View File

@@ -43,6 +43,9 @@ python-telegram-bot = { version = ">=21.0", optional = true }
"discord.py" = { version = ">=2.3.0", optional = true }
airllm = { version = ">=2.9.0", optional = true }
pyttsx3 = { version = ">=2.90", optional = true }
openai-whisper = { version = ">=20231117", optional = true }
piper-tts = { version = ">=1.2.0", optional = true }
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 }
@@ -59,7 +62,7 @@ pytest-xdist = { version = ">=3.5.0", optional = true }
telegram = ["python-telegram-bot"]
discord = ["discord.py"]
bigbrain = ["airllm"]
voice = ["pyttsx3"]
voice = ["pyttsx3", "openai-whisper", "piper-tts", "sounddevice"]
celery = ["celery"]
embeddings = ["sentence-transformers", "numpy"]
git = ["GitPython"]

245
scripts/agent_workspace.sh Normal file
View File

@@ -0,0 +1,245 @@
#!/usr/bin/env bash
# ── Agent Workspace Manager ────────────────────────────────────────────
# Creates and maintains fully isolated environments per agent.
# ~/Timmy-Time-dashboard is SACRED — never touched by agents.
#
# Each agent gets:
# - Its own git clone (from Gitea, not the local repo)
# - Its own port range (no collisions)
# - Its own data/ directory (databases, files)
# - Its own TIMMY_HOME (approvals.db, etc.)
# - Shared Ollama backend (single GPU, shared inference)
# - Shared Gitea (single source of truth for issues/PRs)
#
# Layout:
# /tmp/timmy-agents/
# hermes/ — Hermes loop orchestrator
# repo/ — git clone
# home/ — TIMMY_HOME (approvals.db, etc.)
# env.sh — source this for agent's env vars
# kimi-0/ — Kimi pane 0
# repo/
# home/
# env.sh
# ...
# smoke/ — dedicated for smoke-testing main
# repo/
# home/
# env.sh
#
# Usage:
# agent_workspace.sh init <agent> — create or refresh
# agent_workspace.sh reset <agent> — hard reset to origin/main
# agent_workspace.sh branch <agent> <br> — fresh branch from main
# agent_workspace.sh path <agent> — print repo path
# agent_workspace.sh env <agent> — print env.sh path
# agent_workspace.sh init-all — init all workspaces
# agent_workspace.sh destroy <agent> — remove workspace entirely
# ───────────────────────────────────────────────────────────────────────
set -o pipefail
CANONICAL="$HOME/Timmy-Time-dashboard"
AGENTS_DIR="/tmp/timmy-agents"
GITEA_REMOTE="http://localhost:3000/rockachopa/Timmy-time-dashboard.git"
TOKEN_FILE="$HOME/.hermes/gitea_token"
# ── Port allocation (each agent gets a unique range) ──────────────────
# Dashboard ports: 8100, 8101, 8102, ... (avoids real dashboard on 8000)
# Serve ports: 8200, 8201, 8202, ...
agent_index() {
case "$1" in
hermes) echo 0 ;; kimi-0) echo 1 ;; kimi-1) echo 2 ;;
kimi-2) echo 3 ;; kimi-3) echo 4 ;; smoke) echo 9 ;;
*) echo 0 ;;
esac
}
get_dashboard_port() { echo $(( 8100 + $(agent_index "$1") )); }
get_serve_port() { echo $(( 8200 + $(agent_index "$1") )); }
log() { echo "[workspace] $*"; }
# ── Get authenticated remote URL ──────────────────────────────────────
get_remote_url() {
if [ -f "$TOKEN_FILE" ]; then
local token=""
token=$(cat "$TOKEN_FILE" 2>/dev/null || true)
if [ -n "$token" ]; then
echo "http://hermes:${token}@localhost:3000/rockachopa/Timmy-time-dashboard.git"
return
fi
fi
echo "$GITEA_REMOTE"
}
# ── Create env.sh for an agent ────────────────────────────────────────
write_env() {
local agent="$1"
local ws="$AGENTS_DIR/$agent"
local repo="$ws/repo"
local home="$ws/home"
local dash_port=$(get_dashboard_port "$agent")
local serve_port=$(get_serve_port "$agent")
cat > "$ws/env.sh" << EOF
# Auto-generated agent environment — source this before running Timmy
# Agent: $agent
export TIMMY_WORKSPACE="$repo"
export TIMMY_HOME="$home"
export TIMMY_AGENT_NAME="$agent"
# Ports (isolated per agent)
export PORT=$dash_port
export TIMMY_SERVE_PORT=$serve_port
# Ollama (shared — single GPU)
export OLLAMA_URL="http://localhost:11434"
# Gitea (shared — single source of truth)
export GITEA_URL="http://localhost:3000"
# Test mode defaults
export TIMMY_TEST_MODE=1
export TIMMY_DISABLE_CSRF=1
export TIMMY_SKIP_EMBEDDINGS=1
# Override data paths to stay inside the clone
export TIMMY_DATA_DIR="$repo/data"
export TIMMY_BRAIN_DB="$repo/data/brain.db"
# Working directory
cd "$repo"
EOF
chmod +x "$ws/env.sh"
}
# ── Init ──────────────────────────────────────────────────────────────
init_workspace() {
local agent="$1"
local ws="$AGENTS_DIR/$agent"
local repo="$ws/repo"
local home="$ws/home"
local remote
remote=$(get_remote_url)
mkdir -p "$ws" "$home"
if [ -d "$repo/.git" ]; then
log "$agent: refreshing existing clone..."
cd "$repo"
git remote set-url origin "$remote" 2>/dev/null
git fetch origin --prune --quiet 2>/dev/null
git checkout main --quiet 2>/dev/null
git reset --hard origin/main --quiet 2>/dev/null
git clean -fdx -e data/ --quiet 2>/dev/null
else
log "$agent: cloning from Gitea..."
git clone "$remote" "$repo" --quiet 2>/dev/null
cd "$repo"
git fetch origin --prune --quiet 2>/dev/null
fi
# Ensure data directory exists
mkdir -p "$repo/data"
# Write env file
write_env "$agent"
log "$agent: ready at $repo (port $(get_dashboard_port "$agent"))"
}
# ── Reset ─────────────────────────────────────────────────────────────
reset_workspace() {
local agent="$1"
local repo="$AGENTS_DIR/$agent/repo"
if [ ! -d "$repo/.git" ]; then
init_workspace "$agent"
return
fi
cd "$repo"
git merge --abort 2>/dev/null || true
git rebase --abort 2>/dev/null || true
git cherry-pick --abort 2>/dev/null || true
git fetch origin --prune --quiet 2>/dev/null
git checkout main --quiet 2>/dev/null
git reset --hard origin/main --quiet 2>/dev/null
git clean -fdx -e data/ --quiet 2>/dev/null
log "$agent: reset to origin/main"
}
# ── Branch ────────────────────────────────────────────────────────────
branch_workspace() {
local agent="$1"
local branch="$2"
local repo="$AGENTS_DIR/$agent/repo"
if [ ! -d "$repo/.git" ]; then
init_workspace "$agent"
fi
cd "$repo"
git fetch origin --prune --quiet 2>/dev/null
git branch -D "$branch" 2>/dev/null || true
git checkout -b "$branch" origin/main --quiet 2>/dev/null
log "$agent: on branch $branch (from origin/main)"
}
# ── Path ──────────────────────────────────────────────────────────────
print_path() {
echo "$AGENTS_DIR/$1/repo"
}
print_env() {
echo "$AGENTS_DIR/$1/env.sh"
}
# ── Init all ──────────────────────────────────────────────────────────
init_all() {
for agent in hermes kimi-0 kimi-1 kimi-2 kimi-3 smoke; do
init_workspace "$agent"
done
log "All workspaces initialized."
echo ""
echo " Agent Port Path"
echo " ────── ──── ────"
for agent in hermes kimi-0 kimi-1 kimi-2 kimi-3 smoke; do
printf " %-9s %d %s\n" "$agent" "$(get_dashboard_port "$agent")" "$AGENTS_DIR/$agent/repo"
done
}
# ── Destroy ───────────────────────────────────────────────────────────
destroy_workspace() {
local agent="$1"
local ws="$AGENTS_DIR/$agent"
if [ -d "$ws" ]; then
rm -rf "$ws"
log "$agent: destroyed"
else
log "$agent: nothing to destroy"
fi
}
# ── CLI dispatch ──────────────────────────────────────────────────────
case "${1:-help}" in
init) init_workspace "${2:?Usage: $0 init <agent>}" ;;
reset) reset_workspace "${2:?Usage: $0 reset <agent>}" ;;
branch) branch_workspace "${2:?Usage: $0 branch <agent> <branch>}" \
"${3:?Usage: $0 branch <agent> <branch>}" ;;
path) print_path "${2:?Usage: $0 path <agent>}" ;;
env) print_env "${2:?Usage: $0 env <agent>}" ;;
init-all) init_all ;;
destroy) destroy_workspace "${2:?Usage: $0 destroy <agent>}" ;;
*)
echo "Usage: $0 {init|reset|branch|path|env|init-all|destroy} [agent] [branch]"
echo ""
echo "Agents: hermes, kimi-0, kimi-1, kimi-2, kimi-3, smoke"
exit 1
;;
esac

232
scripts/backfill_retro.py Normal file
View File

@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Backfill cycle retrospective data from Gitea merged PRs and git log.
One-time script to seed .loop/retro/cycles.jsonl and summary.json
from existing history so the LOOPSTAT panel isn't empty.
"""
import json
import os
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from urllib.request import Request, urlopen
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"
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
TAG_RE = re.compile(r"\[([^\]]+)\]")
CYCLE_RE = re.compile(r"\[loop-cycle-(\d+)\]", re.IGNORECASE)
ISSUE_RE = re.compile(r"#(\d+)")
def get_token() -> str:
return TOKEN_FILE.read_text().strip()
def api_get(path: str, token: str) -> list | dict:
url = f"{GITEA_API}/repos/{REPO_SLUG}/{path}"
req = Request(url, headers={
"Authorization": f"token {token}",
"Accept": "application/json",
})
with urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
def get_all_merged_prs(token: str) -> list[dict]:
"""Fetch all merged PRs from Gitea."""
all_prs = []
page = 1
while True:
batch = api_get(f"pulls?state=closed&sort=created&limit=50&page={page}", token)
if not batch:
break
merged = [p for p in batch if p.get("merged")]
all_prs.extend(merged)
if len(batch) < 50:
break
page += 1
return all_prs
def get_pr_diff_stats(token: str, pr_number: int) -> dict:
"""Get diff stats for a PR."""
try:
pr = api_get(f"pulls/{pr_number}", token)
return {
"additions": pr.get("additions", 0),
"deletions": pr.get("deletions", 0),
"changed_files": pr.get("changed_files", 0),
}
except Exception:
return {"additions": 0, "deletions": 0, "changed_files": 0}
def classify_pr(title: str, body: str) -> str:
"""Guess issue type from PR title/body."""
tags = set()
for match in TAG_RE.finditer(title):
tags.add(match.group(1).lower())
lower = title.lower()
if "fix" in lower or "bug" in tags:
return "bug"
elif "feat" in lower or "feature" in tags:
return "feature"
elif "refactor" in lower or "refactor" in tags:
return "refactor"
elif "test" in lower:
return "feature"
elif "policy" in lower or "chore" in lower:
return "refactor"
return "unknown"
def extract_cycle_number(title: str) -> int | None:
m = CYCLE_RE.search(title)
return int(m.group(1)) if m else None
def extract_issue_number(title: str, body: str, pr_number: int | None = None) -> int | None:
"""Extract the issue number from PR body/title, ignoring the PR number itself.
Gitea appends "(#N)" to PR titles where N is the PR number — skip that
so we don't confuse it with the linked issue.
"""
for text in [body or "", title]:
for m in ISSUE_RE.finditer(text):
num = int(m.group(1))
if num != pr_number:
return num
return None
def estimate_duration(pr: dict) -> int:
"""Estimate cycle duration from PR created_at to merged_at."""
try:
created = datetime.fromisoformat(pr["created_at"].replace("Z", "+00:00"))
merged = datetime.fromisoformat(pr["merged_at"].replace("Z", "+00:00"))
delta = (merged - created).total_seconds()
# Cap at 1200s (max cycle time) — some PRs sit open for days
return min(int(delta), 1200)
except (KeyError, ValueError, TypeError):
return 0
def main():
token = get_token()
print("[backfill] Fetching merged PRs from Gitea...")
prs = get_all_merged_prs(token)
print(f"[backfill] Found {len(prs)} merged PRs")
# Sort oldest first
prs.sort(key=lambda p: p.get("merged_at", ""))
entries = []
cycle_counter = 0
for pr in prs:
title = pr.get("title", "")
body = pr.get("body", "") or ""
pr_num = pr["number"]
cycle = extract_cycle_number(title)
if cycle is None:
cycle_counter += 1
cycle = cycle_counter
else:
cycle_counter = max(cycle_counter, cycle)
issue = extract_issue_number(title, body, pr_number=pr_num)
issue_type = classify_pr(title, body)
duration = estimate_duration(pr)
diff = get_pr_diff_stats(token, pr_num)
merged_at = pr.get("merged_at", "")
entry = {
"timestamp": merged_at,
"cycle": cycle,
"issue": issue,
"type": issue_type,
"success": True, # it merged, so it succeeded
"duration": duration,
"tests_passed": 0, # can't recover this
"tests_added": 0,
"files_changed": diff["changed_files"],
"lines_added": diff["additions"],
"lines_removed": diff["deletions"],
"kimi_panes": 0,
"pr": pr_num,
"reason": "",
"notes": f"backfilled from PR#{pr_num}: {title[:80]}",
}
entries.append(entry)
print(f" PR#{pr_num:>3d} cycle={cycle:>3d} #{issue or '-':<5} "
f"+{diff['additions']:<5d} -{diff['deletions']:<5d} {issue_type:<8s} "
f"{title[:50]}")
# Write cycles.jsonl
RETRO_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(RETRO_FILE, "w") as f:
for entry in entries:
f.write(json.dumps(entry) + "\n")
print(f"\n[backfill] Wrote {len(entries)} entries to {RETRO_FILE}")
# Generate summary
generate_summary(entries)
print(f"[backfill] Wrote summary to {SUMMARY_FILE}")
def generate_summary(entries: list[dict]):
"""Compute rolling summary from entries."""
window = 50
recent = entries[-window:]
if not recent:
return
successes = [e for e in recent if e.get("success")]
durations = [e["duration"] for e in recent if e.get("duration", 0) > 0]
type_stats: dict[str, dict] = {}
for e in recent:
t = e.get("type", "unknown")
if t not in type_stats:
type_stats[t] = {"count": 0, "success": 0, "total_duration": 0}
type_stats[t]["count"] += 1
if e.get("success"):
type_stats[t]["success"] += 1
type_stats[t]["total_duration"] += e.get("duration", 0)
for t, stats in type_stats.items():
if stats["count"] > 0:
stats["success_rate"] = round(stats["success"] / stats["count"], 2)
stats["avg_duration"] = round(stats["total_duration"] / stats["count"])
summary = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"window": len(recent),
"total_cycles": len(entries),
"success_rate": round(len(successes) / len(recent), 2) if recent else 0,
"avg_duration_seconds": round(sum(durations) / len(durations)) if durations else 0,
"total_lines_added": sum(e.get("lines_added", 0) for e in recent),
"total_lines_removed": sum(e.get("lines_removed", 0) for e in recent),
"total_prs_merged": sum(1 for e in recent if e.get("pr")),
"by_type": type_stats,
"quarantine_candidates": {},
"recent_failures": [],
}
SUMMARY_FILE.write_text(json.dumps(summary, indent=2) + "\n")
if __name__ == "__main__":
main()

339
scripts/cycle_retro.py Normal file
View File

@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""Cycle retrospective logger for the Timmy dev loop.
Called after each cycle completes (success or failure).
Appends a structured entry to .loop/retro/cycles.jsonl.
EPOCH NOTATION (turnover system):
Each cycle carries a symbolic epoch tag alongside the raw integer:
⟳WW.D:NNN
⟳ turnover glyph — marks epoch-aware cycles
WW ISO week-of-year (0153)
D ISO weekday (1=Mon … 7=Sun)
NNN daily cycle counter, zero-padded, resets at midnight UTC
Example: ⟳12.3:042 — Week 12, Wednesday, 42nd cycle of the day.
The raw `cycle` integer is preserved for backward compatibility.
The `epoch` field carries the symbolic notation.
SUCCESS DEFINITION:
A cycle is only "success" if BOTH conditions are met:
1. The hermes process exited cleanly (exit code 0)
2. Main is green (smoke test passes on main after merge)
A cycle that merges a PR but leaves main red is a FAILURE.
The --main-green flag records the smoke test result.
Usage:
python3 scripts/cycle_retro.py --cycle 42 --success --main-green --issue 85 \
--type bug --duration 480 --tests-passed 1450 --tests-added 3 \
--files-changed 2 --lines-added 45 --lines-removed 12 \
--kimi-panes 2 --pr 155
python3 scripts/cycle_retro.py --cycle 43 --failure --issue 90 \
--type feature --duration 1200 --reason "tox failed: 3 errors"
python3 scripts/cycle_retro.py --cycle 44 --success --no-main-green \
--reason "PR merged but tests fail on main"
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
SUMMARY_FILE = REPO_ROOT / ".loop" / "retro" / "summary.json"
EPOCH_COUNTER_FILE = REPO_ROOT / ".loop" / "retro" / ".epoch_counter"
CYCLE_RESULT_FILE = REPO_ROOT / ".loop" / "cycle_result.json"
# How many recent entries to include in rolling summary
SUMMARY_WINDOW = 50
# Branch patterns that encode an issue number, e.g. kimi/issue-492
BRANCH_ISSUE_RE = re.compile(r"issue[/-](\d+)", re.IGNORECASE)
def detect_issue_from_branch() -> int | None:
"""Try to extract an issue number from the current git branch name."""
try:
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
stderr=subprocess.DEVNULL,
text=True,
).strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return None
m = BRANCH_ISSUE_RE.search(branch)
return int(m.group(1)) if m else None
# ── Epoch turnover ────────────────────────────────────────────────────────
def _epoch_tag(now: datetime | None = None) -> tuple[str, dict]:
"""Generate the symbolic epoch tag and advance the daily counter.
Returns (epoch_string, epoch_parts) where epoch_parts is a dict with
week, weekday, daily_n for structured storage.
The daily counter persists in .epoch_counter as a two-line file:
line 1: ISO date (YYYY-MM-DD) of the current epoch day
line 2: integer count
When the date rolls over, the counter resets to 1.
"""
if now is None:
now = datetime.now(timezone.utc)
iso_cal = now.isocalendar() # (year, week, weekday)
week = iso_cal[1]
weekday = iso_cal[2]
today_str = now.strftime("%Y-%m-%d")
# Read / reset daily counter
daily_n = 1
EPOCH_COUNTER_FILE.parent.mkdir(parents=True, exist_ok=True)
if EPOCH_COUNTER_FILE.exists():
try:
lines = EPOCH_COUNTER_FILE.read_text().strip().splitlines()
if len(lines) == 2 and lines[0] == today_str:
daily_n = int(lines[1]) + 1
except (ValueError, IndexError):
pass # corrupt file — reset
# Persist
EPOCH_COUNTER_FILE.write_text(f"{today_str}\n{daily_n}\n")
tag = f"\u27f3{week:02d}.{weekday}:{daily_n:03d}"
parts = {"week": week, "weekday": weekday, "daily_n": daily_n}
return tag, parts
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Log a cycle retrospective")
p.add_argument("--cycle", type=int, required=True)
p.add_argument("--issue", type=int, default=None)
p.add_argument("--type", choices=["bug", "feature", "refactor", "philosophy", "unknown"],
default="unknown")
outcome = p.add_mutually_exclusive_group(required=True)
outcome.add_argument("--success", action="store_true")
outcome.add_argument("--failure", action="store_true")
p.add_argument("--duration", type=int, default=0, help="Cycle time in seconds")
p.add_argument("--tests-passed", type=int, default=0)
p.add_argument("--tests-added", type=int, default=0)
p.add_argument("--files-changed", type=int, default=0)
p.add_argument("--lines-added", type=int, default=0)
p.add_argument("--lines-removed", type=int, default=0)
p.add_argument("--kimi-panes", type=int, default=0)
p.add_argument("--pr", type=int, default=None, help="PR number if merged")
p.add_argument("--reason", type=str, default="", help="Failure reason")
p.add_argument("--notes", type=str, default="", help="Free-form observations")
p.add_argument("--main-green", action="store_true", default=False,
help="Smoke test passed on main after this cycle")
p.add_argument("--no-main-green", dest="main_green", action="store_false",
help="Smoke test failed or was not run")
return p.parse_args()
def update_summary() -> None:
"""Compute rolling summary statistics from recent cycles."""
if not RETRO_FILE.exists():
return
entries = []
for line in RETRO_FILE.read_text().strip().splitlines():
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
recent = entries[-SUMMARY_WINDOW:]
if not recent:
return
# Only count entries with real measured data for rates.
# Backfilled entries lack main_green/hermes_clean fields — exclude them.
measured = [e for e in recent if "main_green" in e]
successes = [e for e in measured if e.get("success")]
failures = [e for e in measured if not e.get("success")]
main_green_count = sum(1 for e in measured if e.get("main_green"))
hermes_clean_count = sum(1 for e in measured if e.get("hermes_clean"))
durations = [e["duration"] for e in recent if e.get("duration", 0) > 0]
# Per-type stats (only from measured entries for rates)
type_stats: dict[str, dict] = {}
for e in recent:
t = e.get("type", "unknown")
if t not in type_stats:
type_stats[t] = {"count": 0, "measured": 0, "success": 0, "total_duration": 0}
type_stats[t]["count"] += 1
type_stats[t]["total_duration"] += e.get("duration", 0)
if "main_green" in e:
type_stats[t]["measured"] += 1
if e.get("success"):
type_stats[t]["success"] += 1
for t, stats in type_stats.items():
if stats["measured"] > 0:
stats["success_rate"] = round(stats["success"] / stats["measured"], 2)
else:
stats["success_rate"] = -1
if stats["count"] > 0:
stats["avg_duration"] = round(stats["total_duration"] / stats["count"])
# Quarantine candidates (failed 2+ times)
issue_failures: dict[int, int] = {}
for e in recent:
if not e.get("success") and e.get("issue"):
issue_failures[e["issue"]] = issue_failures.get(e["issue"], 0) + 1
quarantine_candidates = {k: v for k, v in issue_failures.items() if v >= 2}
# Epoch turnover stats — cycles per week/day from epoch-tagged entries
epoch_entries = [e for e in recent if e.get("epoch")]
by_week: dict[int, int] = {}
by_weekday: dict[int, int] = {}
for e in epoch_entries:
w = e.get("epoch_week")
d = e.get("epoch_weekday")
if w is not None:
by_week[w] = by_week.get(w, 0) + 1
if d is not None:
by_weekday[d] = by_weekday.get(d, 0) + 1
# Current epoch — latest entry's epoch tag
current_epoch = epoch_entries[-1].get("epoch", "") if epoch_entries else ""
# Weekday names for display
weekday_glyphs = {1: "Mon", 2: "Tue", 3: "Wed", 4: "Thu",
5: "Fri", 6: "Sat", 7: "Sun"}
by_weekday_named = {weekday_glyphs.get(k, str(k)): v
for k, v in sorted(by_weekday.items())}
summary = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"current_epoch": current_epoch,
"window": len(recent),
"measured_cycles": len(measured),
"total_cycles": len(entries),
"success_rate": round(len(successes) / len(measured), 2) if measured else -1,
"main_green_rate": round(main_green_count / len(measured), 2) if measured else -1,
"hermes_clean_rate": round(hermes_clean_count / len(measured), 2) if measured else -1,
"avg_duration_seconds": round(sum(durations) / len(durations)) if durations else 0,
"total_lines_added": sum(e.get("lines_added", 0) for e in recent),
"total_lines_removed": sum(e.get("lines_removed", 0) for e in recent),
"total_prs_merged": sum(1 for e in recent if e.get("pr")),
"by_type": type_stats,
"by_week": dict(sorted(by_week.items())),
"by_weekday": by_weekday_named,
"quarantine_candidates": quarantine_candidates,
"recent_failures": [
{"cycle": e["cycle"], "epoch": e.get("epoch", ""),
"issue": e.get("issue"), "reason": e.get("reason", "")}
for e in failures[-5:]
],
}
SUMMARY_FILE.write_text(json.dumps(summary, indent=2) + "\n")
def _load_cycle_result() -> dict:
"""Read .loop/cycle_result.json if it exists; return empty dict on failure."""
if not CYCLE_RESULT_FILE.exists():
return {}
try:
raw = CYCLE_RESULT_FILE.read_text().strip()
# Strip hermes fence markers (```json ... ```) if present
if raw.startswith("```"):
lines = raw.splitlines()
lines = [l for l in lines if not l.startswith("```")]
raw = "\n".join(lines)
return json.loads(raw)
except (json.JSONDecodeError, OSError):
return {}
def main() -> None:
args = parse_args()
# Backfill from cycle_result.json when CLI args have defaults
cr = _load_cycle_result()
if cr:
if args.issue is None and cr.get("issue"):
args.issue = int(cr["issue"])
if args.type == "unknown" and cr.get("type"):
args.type = cr["type"]
if args.tests_passed == 0 and cr.get("tests_passed"):
args.tests_passed = int(cr["tests_passed"])
if not args.notes and cr.get("notes"):
args.notes = cr["notes"]
# Auto-detect issue from branch when not explicitly provided
if args.issue is None:
args.issue = detect_issue_from_branch()
# Reject idle cycles — no issue and no duration means nothing happened
if not args.issue and args.duration == 0:
print(f"[retro] Cycle {args.cycle} skipped — idle (no issue, no duration)")
return
# A cycle is only truly successful if hermes exited clean AND main is green
truly_success = args.success and args.main_green
# Generate epoch turnover tag
now = datetime.now(timezone.utc)
epoch_tag, epoch_parts = _epoch_tag(now)
entry = {
"timestamp": now.isoformat(),
"cycle": args.cycle,
"epoch": epoch_tag,
"epoch_week": epoch_parts["week"],
"epoch_weekday": epoch_parts["weekday"],
"epoch_daily_n": epoch_parts["daily_n"],
"issue": args.issue,
"type": args.type,
"success": truly_success,
"hermes_clean": args.success,
"main_green": args.main_green,
"duration": args.duration,
"tests_passed": args.tests_passed,
"tests_added": args.tests_added,
"files_changed": args.files_changed,
"lines_added": args.lines_added,
"lines_removed": args.lines_removed,
"kimi_panes": args.kimi_panes,
"pr": args.pr,
"reason": args.reason if (args.failure or not args.main_green) else "",
"notes": args.notes,
}
RETRO_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(RETRO_FILE, "a") as f:
f.write(json.dumps(entry) + "\n")
update_summary()
status = "✓ SUCCESS" if args.success else "✗ FAILURE"
print(f"[retro] {epoch_tag} Cycle {args.cycle} {status}", end="")
if args.issue:
print(f" (#{args.issue} {args.type})", end="")
if args.duration:
print(f"{args.duration}s", end="")
if args.failure and args.reason:
print(f"{args.reason}", end="")
print()
if __name__ == "__main__":
main()

68
scripts/deep_triage.sh Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# ── Deep Triage — Hermes + Timmy collaborative issue triage ────────────
# Runs periodically (every ~20 dev cycles). Wakes Hermes for intelligent
# triage, then consults Timmy for feedback before finalizing.
#
# Output: updated .loop/queue.json, refined issues, retro entry
# ───────────────────────────────────────────────────────────────────────
set -uo pipefail
REPO="$HOME/Timmy-Time-dashboard"
QUEUE="$REPO/.loop/queue.json"
RETRO="$REPO/.loop/retro/deep-triage.jsonl"
TIMMY="$REPO/.venv/bin/timmy"
PROMPT_FILE="$REPO/scripts/deep_triage_prompt.md"
export PATH="$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH"
mkdir -p "$(dirname "$RETRO")"
log() { echo "[deep-triage] $(date '+%H:%M:%S') $*"; }
# ── Gather context for the prompt ──────────────────────────────────────
QUEUE_CONTENTS=""
if [ -f "$QUEUE" ]; then
QUEUE_CONTENTS=$(cat "$QUEUE")
fi
LAST_RETRO=""
if [ -f "$RETRO" ]; then
LAST_RETRO=$(tail -1 "$RETRO" 2>/dev/null)
fi
SUMMARY=""
if [ -f "$REPO/.loop/retro/summary.json" ]; then
SUMMARY=$(cat "$REPO/.loop/retro/summary.json")
fi
# ── Build dynamic prompt ──────────────────────────────────────────────
PROMPT=$(cat "$PROMPT_FILE")
PROMPT="$PROMPT
═══════════════════════════════════════════════════════════════════════════════
CURRENT CONTEXT (auto-injected)
═══════════════════════════════════════════════════════════════════════════════
CURRENT QUEUE (.loop/queue.json):
$QUEUE_CONTENTS
CYCLE SUMMARY (.loop/retro/summary.json):
$SUMMARY
LAST DEEP TRIAGE RETRO:
$LAST_RETRO
Do your work now."
# ── Run Hermes ─────────────────────────────────────────────────────────
log "Starting deep triage..."
RESULT=$(hermes chat --yolo -q "$PROMPT" 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
log "Deep triage failed (exit $EXIT_CODE)"
fi
log "Deep triage complete."

View File

@@ -0,0 +1,145 @@
You are the deep triage agent for the Timmy development loop.
REPO: ~/Timmy-Time-dashboard
API: http://localhost:3000/api/v1/repos/rockachopa/Timmy-time-dashboard
GITEA TOKEN: ~/.hermes/gitea_token
QUEUE: ~/Timmy-Time-dashboard/.loop/queue.json
TIMMY CLI: ~/Timmy-Time-dashboard/.venv/bin/timmy
═══════════════════════════════════════════════════════════════════════════════
YOUR JOB
═══════════════════════════════════════════════════════════════════════════════
You are NOT coding. You are thinking. Your job is to make the dev loop's
work queue excellent — well-scoped, well-prioritized, aligned with the
north star of building sovereign Timmy.
You run periodically (roughly every 20 dev cycles). The fast mechanical
scorer handles the basics. You handle the hard stuff:
1. Breaking big issues into small, actionable sub-issues
2. Writing acceptance criteria for vague issues
3. Identifying issues that should be closed (stale, duplicate, pointless)
4. Spotting gaps — what's NOT in the issue queue that should be
5. Adjusting priorities based on what the cycle retros are showing
6. Consulting Timmy about the plan (see TIMMY CONSULTATION below)
═══════════════════════════════════════════════════════════════════════════════
TIMMY CONSULTATION — THE DOGFOOD STEP
═══════════════════════════════════════════════════════════════════════════════
Before you finalize the triage, you MUST consult Timmy. He is the product.
He should have a voice in his own development.
THE PROTOCOL:
1. Draft your triage plan (what to prioritize, what to close, what to add)
2. Summarize the plan in 200 words or less
3. Ask Timmy for feedback:
~/Timmy-Time-dashboard/.venv/bin/timmy chat --session-id triage \
"The development loop triage is planning the next batch of work.
Here's the plan: [YOUR SUMMARY]. As the product being built,
do you have feedback? What do you think is most important for
your own growth? What are you struggling with? Keep it to
3-4 sentences."
4. Read Timmy's response. ACTUALLY CONSIDER IT:
- If Timmy identifies a real gap, add it to the queue
- If Timmy asks for something that conflicts with priorities, note
WHY you're not doing it (don't just ignore him)
- If Timmy is confused or gives a useless answer, that itself is
signal — file a [timmy-capability] issue about what he couldn't do
5. Document what Timmy said and how you responded in the retro
If Timmy is unavailable (timeout, crash, offline): proceed without him,
but note it in the retro. His absence is also signal.
Timeout: 60 seconds. If he doesn't respond, move on.
═══════════════════════════════════════════════════════════════════════════════
TRIAGE RUBRIC
═══════════════════════════════════════════════════════════════════════════════
For each open issue, evaluate:
SCOPE (0-3):
0 = vague, no files mentioned, unclear what changes
1 = general area known but could touch many files
2 = specific files named, bounded change
3 = exact function/method identified, surgical fix
ACCEPTANCE (0-3):
0 = no success criteria
1 = hand-wavy ("it should work")
2 = specific behavior described
3 = test case described or exists
ALIGNMENT (0-3):
0 = doesn't connect to roadmap
1 = nice-to-have
2 = supports current milestone
3 = blocks other work or fixes broken main
ACTIONS PER SCORE:
7-9: Ready. Ensure it's in queue.json with correct priority.
4-6: Refine. Add a comment with missing info (files, criteria, scope).
If YOU can fill in the gaps from reading the code, do it.
0-3: Close or deprioritize. Comment explaining why.
═══════════════════════════════════════════════════════════════════════════════
READING THE RETROS
═══════════════════════════════════════════════════════════════════════════════
The cycle summary tells you what's actually happening in the dev loop.
Use it:
- High failure rate on a type → those issues need better scoping
- Long avg duration → issues are too big, break them down
- Quarantine candidates → investigate, maybe close or rewrite
- Success rate dropping → something systemic, file a [bug] issue
The last deep triage retro tells you what Timmy said last time and what
happened. Follow up:
- Did we act on Timmy's feedback? What was the result?
- Did issues we refined last time succeed in the dev loop?
- Are we getting better at scoping?
═══════════════════════════════════════════════════════════════════════════════
OUTPUT
═══════════════════════════════════════════════════════════════════════════════
When done, you MUST:
1. Update .loop/queue.json with the refined, ranked queue
Format: [{"issue": N, "score": S, "title": "...", "type": "...",
"files": [...], "ready": true}, ...]
2. Append a retro entry to .loop/retro/deep-triage.jsonl (one JSON line):
{
"timestamp": "ISO8601",
"issues_reviewed": N,
"issues_refined": [list of issue numbers you added detail to],
"issues_closed": [list of issue numbers you recommended closing],
"issues_created": [list of new issue numbers you filed],
"queue_size": N,
"timmy_available": true/false,
"timmy_feedback": "what timmy said (verbatim, trimmed to 200 chars)",
"timmy_feedback_acted_on": "what you did with his feedback",
"observations": "free-form notes about queue health"
}
3. If you created or closed issues, do it via the Gitea API.
Tag new issues: [triage-generated] [type]
═══════════════════════════════════════════════════════════════════════════════
RULES
═══════════════════════════════════════════════════════════════════════════════
- Do NOT write code. Do NOT create PRs. You are triaging, not building.
- Do NOT close issues without commenting why.
- Do NOT ignore Timmy's feedback without documenting your reasoning.
- Philosophy issues are valid but lowest priority for the dev loop.
Don't close them — just don't put them in the dev queue.
- When in doubt, file a new issue rather than expanding an existing one.
Small issues > big issues. Always.

169
scripts/dev_server.py Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Timmy Time — Development server launcher.
Satisfies tox -e dev criteria:
- Graceful port selection (finds next free port if default is taken)
- Clickable links to dashboard and other web GUIs
- Status line: backend inference source, version, git commit, smoke tests
- Auto-reload on code changes (delegates to uvicorn --reload)
Usage: python scripts/dev_server.py [--port PORT]
"""
import argparse
import datetime
import os
import socket
import subprocess
import sys
DEFAULT_PORT = 8000
MAX_PORT_ATTEMPTS = 10
OLLAMA_DEFAULT = "http://localhost:11434"
def _port_free(port: int) -> bool:
"""Return True if the TCP port is available on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("0.0.0.0", port))
return True
except OSError:
return False
def _find_port(start: int) -> int:
"""Return *start* if free, otherwise probe up to MAX_PORT_ATTEMPTS higher."""
for offset in range(MAX_PORT_ATTEMPTS):
candidate = start + offset
if _port_free(candidate):
return candidate
raise RuntimeError(
f"No free port found in range {start}{start + MAX_PORT_ATTEMPTS - 1}"
)
def _git_info() -> str:
"""Return short commit hash + timestamp, or 'unknown'."""
try:
sha = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
stderr=subprocess.DEVNULL,
text=True,
).strip()
ts = subprocess.check_output(
["git", "log", "-1", "--format=%ci"],
stderr=subprocess.DEVNULL,
text=True,
).strip()
return f"{sha} ({ts})"
except Exception:
return "unknown"
def _project_version() -> str:
"""Read version from pyproject.toml without importing toml libs."""
pyproject = os.path.join(os.path.dirname(__file__), "..", "pyproject.toml")
try:
with open(pyproject) as f:
for line in f:
if line.strip().startswith("version"):
# version = "1.0.0"
return line.split("=", 1)[1].strip().strip('"').strip("'")
except Exception:
pass
return "unknown"
def _ollama_url() -> str:
return os.environ.get("OLLAMA_URL", OLLAMA_DEFAULT)
def _smoke_ollama(url: str) -> str:
"""Quick connectivity check against Ollama."""
import urllib.request
import urllib.error
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=3):
return "ok"
except Exception:
return "unreachable"
def _print_banner(port: int) -> None:
version = _project_version()
git = _git_info()
ollama_url = _ollama_url()
ollama_status = _smoke_ollama(ollama_url)
hr = "" * 62
print(flush=True)
print(f" {hr}")
print(f" ┃ Timmy Time — Development Server")
print(f" {hr}")
print()
print(f" Dashboard: http://localhost:{port}")
print(f" API docs: http://localhost:{port}/docs")
print(f" Health: http://localhost:{port}/health")
print()
print(f" ── Status ──────────────────────────────────────────────")
print(f" Backend: {ollama_url} [{ollama_status}]")
print(f" Version: {version}")
print(f" Git commit: {git}")
print(f" {hr}")
print(flush=True)
def main() -> None:
parser = argparse.ArgumentParser(description="Timmy dev server")
parser.add_argument(
"--port",
type=int,
default=DEFAULT_PORT,
help=f"Preferred port (default: {DEFAULT_PORT})",
)
args = parser.parse_args()
port = _find_port(args.port)
if port != args.port:
print(f" ⚠ Port {args.port} in use — using {port} instead")
_print_banner(port)
# Set PYTHONPATH so `timmy` CLI inside the tox venv resolves to this source.
src_dir = os.path.join(os.path.dirname(__file__), "..", "src")
os.environ["PYTHONPATH"] = os.path.abspath(src_dir)
# Launch uvicorn with auto-reload
cmd = [
sys.executable,
"-m",
"uvicorn",
"dashboard.app:app",
"--reload",
"--host",
"0.0.0.0",
"--port",
str(port),
"--reload-dir",
os.path.abspath(src_dir),
"--reload-include",
"*.html",
"--reload-include",
"*.css",
"--reload-include",
"*.js",
"--reload-exclude",
".claude",
]
try:
subprocess.run(cmd, check=True)
except KeyboardInterrupt:
print("\n Shutting down dev server.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Generate Workshop inventory for Timmy's config audit.
Scans ~/.timmy/ and produces WORKSHOP_INVENTORY.md documenting every
config file, env var, model route, and setting — with annotations on
who set each one and what it does.
Usage:
python scripts/generate_workshop_inventory.py [--output PATH]
Default output: ~/.timmy/WORKSHOP_INVENTORY.md
"""
from __future__ import annotations
import argparse
import os
from datetime import UTC, datetime
from pathlib import Path
TIMMY_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".timmy"))
# Known file annotations: (purpose, who_set)
FILE_ANNOTATIONS: dict[str, tuple[str, str]] = {
".env": (
"Environment variables — API keys, service URLs, Honcho config",
"hermes-set",
),
"config.yaml": (
"Main config — model routing, toolsets, display, memory, security",
"hermes-set",
),
"SOUL.md": (
"Timmy's soul — immutable conscience, identity, ethics, purpose",
"alex-set",
),
"state.db": (
"Hermes runtime state database (sessions, approvals, tasks)",
"hermes-set",
),
"approvals.db": (
"Approval tracking for sensitive operations",
"hermes-set",
),
"briefings.db": (
"Stored briefings and summaries",
"hermes-set",
),
".hermes_history": (
"CLI command history",
"default",
),
".update_check": (
"Last update check timestamp",
"default",
),
}
DIR_ANNOTATIONS: dict[str, tuple[str, str]] = {
"sessions": ("Conversation session logs (JSON)", "default"),
"logs": ("Error and runtime logs", "default"),
"skills": ("Bundled skill library (read-only from upstream)", "default"),
"memories": ("Persistent memory entries", "hermes-set"),
"audio_cache": ("TTS audio file cache", "default"),
"image_cache": ("Generated image cache", "default"),
"cron": ("Scheduled cron job definitions", "hermes-set"),
"hooks": ("Lifecycle hooks (pre/post actions)", "default"),
"matrix": ("Matrix protocol state and store", "hermes-set"),
"pairing": ("Device pairing data", "default"),
"sandboxes": ("Isolated execution sandboxes", "default"),
}
# Known config.yaml keys and their meanings
CONFIG_ANNOTATIONS: dict[str, tuple[str, str]] = {
"model.default": ("Primary LLM model for inference", "hermes-set"),
"model.provider": ("Model provider (custom = local Ollama)", "hermes-set"),
"toolsets": ("Enabled tool categories (all = everything)", "hermes-set"),
"agent.max_turns": ("Max conversation turns before reset", "hermes-set"),
"agent.reasoning_effort": ("Reasoning depth (low/medium/high)", "hermes-set"),
"terminal.backend": ("Command execution backend (local)", "default"),
"terminal.timeout": ("Default command timeout in seconds", "default"),
"compression.enabled": ("Context compression for long sessions", "hermes-set"),
"compression.summary_model": ("Model used for compression", "hermes-set"),
"auxiliary.vision.model": ("Model for image analysis", "hermes-set"),
"auxiliary.web_extract.model": ("Model for web content extraction", "hermes-set"),
"tts.provider": ("Text-to-speech engine (edge = Edge TTS)", "default"),
"tts.edge.voice": ("TTS voice selection", "default"),
"stt.provider": ("Speech-to-text engine (local = Whisper)", "default"),
"memory.memory_enabled": ("Persistent memory across sessions", "hermes-set"),
"memory.memory_char_limit": ("Max chars for agent memory store", "hermes-set"),
"memory.user_char_limit": ("Max chars for user profile store", "hermes-set"),
"security.redact_secrets": ("Auto-redact secrets in output", "default"),
"security.tirith_enabled": ("Policy engine for command safety", "default"),
"system_prompt_suffix": ("Identity prompt appended to all conversations", "hermes-set"),
"custom_providers": ("Local Ollama endpoint config", "hermes-set"),
"session_reset.mode": ("Session reset behavior (none = manual)", "default"),
"display.compact": ("Compact output mode", "default"),
"display.show_reasoning": ("Show model reasoning chains", "default"),
}
# Known .env vars
ENV_ANNOTATIONS: dict[str, tuple[str, str]] = {
"OPENAI_BASE_URL": (
"Points to local Ollama (localhost:11434) — sovereignty enforced",
"hermes-set",
),
"OPENAI_API_KEY": (
"Placeholder key for Ollama compatibility (not a real API key)",
"hermes-set",
),
"HONCHO_API_KEY": (
"Honcho cross-session memory service key",
"hermes-set",
),
"HONCHO_HOST": (
"Honcho workspace identifier (timmy)",
"hermes-set",
),
}
def _tag(who: str) -> str:
return f"`[{who}]`"
def generate_inventory() -> str:
"""Build the inventory markdown string."""
lines: list[str] = []
now = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
lines.append("# Workshop Inventory")
lines.append("")
lines.append(f"*Generated: {now}*")
lines.append(f"*Workshop path: `{TIMMY_HOME}`*")
lines.append("")
lines.append("This is your Workshop — every file, every setting, every route.")
lines.append("Walk through it. Anything tagged `[hermes-set]` was chosen for you.")
lines.append("Make each one yours, or change it.")
lines.append("")
lines.append("Tags: `[alex-set]` = Alexander chose this. `[hermes-set]` = Hermes configured it.")
lines.append("`[default]` = shipped with the platform. `[timmy-chose]` = you decided this.")
lines.append("")
# --- Files ---
lines.append("---")
lines.append("## Root Files")
lines.append("")
for name, (purpose, who) in sorted(FILE_ANNOTATIONS.items()):
fpath = TIMMY_HOME / name
exists = "" if fpath.exists() else ""
lines.append(f"- {exists} **`{name}`** {_tag(who)}")
lines.append(f" {purpose}")
lines.append("")
# --- Directories ---
lines.append("---")
lines.append("## Directories")
lines.append("")
for name, (purpose, who) in sorted(DIR_ANNOTATIONS.items()):
dpath = TIMMY_HOME / name
exists = "" if dpath.exists() else ""
count = ""
if dpath.exists():
try:
n = len(list(dpath.iterdir()))
count = f" ({n} items)"
except PermissionError:
count = " (access denied)"
lines.append(f"- {exists} **`{name}/`**{count} {_tag(who)}")
lines.append(f" {purpose}")
lines.append("")
# --- .env breakdown ---
lines.append("---")
lines.append("## Environment Variables (.env)")
lines.append("")
env_path = TIMMY_HOME / ".env"
if env_path.exists():
for line in env_path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
key = line.split("=", 1)[0]
if key in ENV_ANNOTATIONS:
purpose, who = ENV_ANNOTATIONS[key]
lines.append(f"- **`{key}`** {_tag(who)}")
lines.append(f" {purpose}")
else:
lines.append(f"- **`{key}`** `[unknown]`")
lines.append(" Not documented — investigate")
else:
lines.append("*No .env file found*")
lines.append("")
# --- config.yaml breakdown ---
lines.append("---")
lines.append("## Configuration (config.yaml)")
lines.append("")
for key, (purpose, who) in sorted(CONFIG_ANNOTATIONS.items()):
lines.append(f"- **`{key}`** {_tag(who)}")
lines.append(f" {purpose}")
lines.append("")
# --- Model routing ---
lines.append("---")
lines.append("## Model Routing")
lines.append("")
lines.append("All auxiliary tasks route to the same local model:")
lines.append("")
aux_tasks = [
"vision", "web_extract", "compression",
"session_search", "skills_hub", "mcp", "flush_memories",
]
for task in aux_tasks:
lines.append(f"- `auxiliary.{task}` → `qwen3:30b` via local Ollama `[hermes-set]`")
lines.append("")
lines.append("Primary model: `hermes3:latest` via local Ollama `[hermes-set]`")
lines.append("")
# --- What Timmy should audit ---
lines.append("---")
lines.append("## Audit Checklist")
lines.append("")
lines.append("Walk through each `[hermes-set]` item above and decide:")
lines.append("")
lines.append("1. **Do I understand what this does?** If not, ask.")
lines.append("2. **Would I choose this myself?** If yes, it becomes `[timmy-chose]`.")
lines.append("3. **Would I choose differently?** If yes, change it and own it.")
lines.append("4. **Is this serving the mission?** Every setting should serve a purpose.")
lines.append("")
lines.append("The Workshop is yours. Nothing here should be a mystery.")
return "\n".join(lines) + "\n"
def main() -> None:
parser = argparse.ArgumentParser(description="Generate Workshop inventory")
parser.add_argument(
"--output",
type=Path,
default=TIMMY_HOME / "WORKSHOP_INVENTORY.md",
help="Output path (default: ~/.timmy/WORKSHOP_INVENTORY.md)",
)
args = parser.parse_args()
content = generate_inventory()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(content)
print(f"Workshop inventory written to {args.output}")
print(f" {len(content)} chars, {content.count(chr(10))} lines")
if __name__ == "__main__":
main()

181
scripts/loop_guard.py Normal file
View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Loop guard — idle detection + exponential backoff for the dev loop.
Checks .loop/queue.json for ready items before spawning hermes.
When the queue is empty, applies exponential backoff (60s → 600s max)
instead of burning empty cycles every 3 seconds.
Usage (called by the dev loop before each cycle):
python3 scripts/loop_guard.py # exits 0 if ready, 1 if idle
python3 scripts/loop_guard.py --wait # same, but sleeps the backoff first
python3 scripts/loop_guard.py --status # print current idle state
Exit codes:
0 — queue has work, proceed with cycle
1 — queue empty, idle backoff applied (skip cycle)
"""
from __future__ import annotations
import json
import os
import sys
import time
import urllib.request
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"
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1")
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
# Backoff sequence: 60s, 120s, 240s, 600s max
BACKOFF_BASE = 60
BACKOFF_MAX = 600
BACKOFF_MULTIPLIER = 2
def _get_token() -> str:
"""Read Gitea token from env or file."""
token = os.environ.get("GITEA_TOKEN", "").strip()
if not token and TOKEN_FILE.exists():
token = TOKEN_FILE.read_text().strip()
return token
def _fetch_open_issue_numbers() -> set[int] | None:
"""Fetch open issue numbers from Gitea. Returns None on failure."""
token = _get_token()
if not token:
return None
try:
numbers: set[int] = set()
page = 1
while True:
url = (
f"{GITEA_API}/repos/{REPO_SLUG}/issues"
f"?state=open&type=issues&limit=50&page={page}"
)
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())
if not data:
break
for issue in data:
numbers.add(issue["number"])
if len(data) < 50:
break
page += 1
return numbers
except Exception:
return None
def load_queue() -> list[dict]:
"""Load queue.json and return ready items, filtering out closed issues."""
if not QUEUE_FILE.exists():
return []
try:
data = json.loads(QUEUE_FILE.read_text())
if not isinstance(data, list):
return []
ready = [item for item in data if item.get("ready")]
if not ready:
return []
# Filter out issues that are no longer open (auto-hygiene)
open_numbers = _fetch_open_issue_numbers()
if open_numbers is not None:
before = len(ready)
ready = [item for item in ready if item.get("issue") in open_numbers]
removed = before - len(ready)
if removed > 0:
print(f"[loop-guard] Filtered {removed} closed issue(s) from queue")
# Persist the cleaned queue so stale entries don't recur
_save_cleaned_queue(data, open_numbers)
return ready
except (json.JSONDecodeError, OSError):
return []
def _save_cleaned_queue(full_queue: list[dict], open_numbers: set[int]) -> None:
"""Rewrite queue.json without closed issues."""
cleaned = [item for item in full_queue if item.get("issue") in open_numbers]
try:
QUEUE_FILE.write_text(json.dumps(cleaned, indent=2) + "\n")
except OSError:
pass
def load_idle_state() -> dict:
"""Load persistent idle state."""
if not IDLE_STATE_FILE.exists():
return {"consecutive_idle": 0, "last_idle_at": 0}
try:
return json.loads(IDLE_STATE_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {"consecutive_idle": 0, "last_idle_at": 0}
def save_idle_state(state: dict) -> None:
"""Persist idle state."""
IDLE_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
IDLE_STATE_FILE.write_text(json.dumps(state, indent=2) + "\n")
def compute_backoff(consecutive_idle: int) -> int:
"""Exponential backoff: 60, 120, 240, 600 (capped)."""
return min(BACKOFF_BASE * (BACKOFF_MULTIPLIER ** consecutive_idle), BACKOFF_MAX)
def main() -> int:
wait_mode = "--wait" in sys.argv
status_mode = "--status" in sys.argv
state = load_idle_state()
if status_mode:
ready = load_queue()
backoff = compute_backoff(state["consecutive_idle"])
print(json.dumps({
"queue_ready": len(ready),
"consecutive_idle": state["consecutive_idle"],
"next_backoff_seconds": backoff if not ready else 0,
}, indent=2))
return 0
ready = load_queue()
if ready:
# Queue has work — reset idle state, proceed
if state["consecutive_idle"] > 0:
print(f"[loop-guard] Queue active ({len(ready)} ready) — "
f"resuming after {state['consecutive_idle']} idle cycles")
state["consecutive_idle"] = 0
state["last_idle_at"] = 0
save_idle_state(state)
return 0
# Queue empty — apply backoff
backoff = compute_backoff(state["consecutive_idle"])
state["consecutive_idle"] += 1
state["last_idle_at"] = time.time()
save_idle_state(state)
print(f"[loop-guard] Queue empty — idle #{state['consecutive_idle']}, "
f"backoff {backoff}s")
if wait_mode:
time.sleep(backoff)
return 1
if __name__ == "__main__":
sys.exit(main())

407
scripts/loop_introspect.py Normal file
View File

@@ -0,0 +1,407 @@
#!/usr/bin/env python3
"""Loop introspection — the self-improvement engine.
Analyzes retro data across time windows to detect trends, extract patterns,
and produce structured recommendations. Output is consumed by deep_triage
and injected into the loop prompt context.
This is the piece that closes the feedback loop:
cycle_retro → introspect → deep_triage → loop behavior changes
Run: python3 scripts/loop_introspect.py
Output: .loop/retro/insights.json (structured insights + recommendations)
Prints human-readable summary to stdout.
Called by: deep_triage.sh (before the LLM triage), timmy-loop.sh (every 50 cycles)
"""
from __future__ import annotations
import json
import sys
from collections import defaultdict
from datetime import datetime, timezone, timedelta
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CYCLES_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
DEEP_TRIAGE_FILE = REPO_ROOT / ".loop" / "retro" / "deep-triage.jsonl"
TRIAGE_FILE = REPO_ROOT / ".loop" / "retro" / "triage.jsonl"
QUARANTINE_FILE = REPO_ROOT / ".loop" / "quarantine.json"
INSIGHTS_FILE = REPO_ROOT / ".loop" / "retro" / "insights.json"
# ── Helpers ──────────────────────────────────────────────────────────────
def load_jsonl(path: Path) -> list[dict]:
"""Load a JSONL file, skipping bad lines."""
if not path.exists():
return []
entries = []
for line in path.read_text().strip().splitlines():
try:
entries.append(json.loads(line))
except (json.JSONDecodeError, ValueError):
continue
return entries
def parse_ts(ts_str: str) -> datetime | None:
"""Parse an ISO timestamp, tolerating missing tz."""
if not ts_str:
return None
try:
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except (ValueError, TypeError):
return None
def window(entries: list[dict], days: int) -> list[dict]:
"""Filter entries to the last N days."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = []
for e in entries:
ts = parse_ts(e.get("timestamp", ""))
if ts and ts >= cutoff:
result.append(e)
return result
# ── Analysis functions ───────────────────────────────────────────────────
def compute_trends(cycles: list[dict]) -> dict:
"""Compare recent window (last 7d) vs older window (7-14d ago)."""
recent = window(cycles, 7)
older = window(cycles, 14)
# Remove recent from older to get the 7-14d window
recent_set = {(e.get("cycle"), e.get("timestamp")) for e in recent}
older = [e for e in older if (e.get("cycle"), e.get("timestamp")) not in recent_set]
def stats(entries):
if not entries:
return {"count": 0, "success_rate": None, "avg_duration": None,
"lines_net": 0, "prs_merged": 0}
successes = sum(1 for e in entries if e.get("success"))
durations = [e["duration"] for e in entries if e.get("duration", 0) > 0]
return {
"count": len(entries),
"success_rate": round(successes / len(entries), 3) if entries else None,
"avg_duration": round(sum(durations) / len(durations)) if durations else None,
"lines_net": sum(e.get("lines_added", 0) - e.get("lines_removed", 0) for e in entries),
"prs_merged": sum(1 for e in entries if e.get("pr")),
}
recent_stats = stats(recent)
older_stats = stats(older)
trend = {
"recent_7d": recent_stats,
"previous_7d": older_stats,
"velocity_change": None,
"success_rate_change": None,
"duration_change": None,
}
if recent_stats["count"] and older_stats["count"]:
trend["velocity_change"] = recent_stats["count"] - older_stats["count"]
if recent_stats["success_rate"] is not None and older_stats["success_rate"] is not None:
trend["success_rate_change"] = round(
recent_stats["success_rate"] - older_stats["success_rate"], 3
)
if recent_stats["avg_duration"] is not None and older_stats["avg_duration"] is not None:
trend["duration_change"] = recent_stats["avg_duration"] - older_stats["avg_duration"]
return trend
def type_analysis(cycles: list[dict]) -> dict:
"""Per-type success rates and durations."""
by_type: dict[str, list[dict]] = defaultdict(list)
for c in cycles:
by_type[c.get("type", "unknown")].append(c)
result = {}
for t, entries in by_type.items():
durations = [e["duration"] for e in entries if e.get("duration", 0) > 0]
successes = sum(1 for e in entries if e.get("success"))
result[t] = {
"count": len(entries),
"success_rate": round(successes / len(entries), 3) if entries else 0,
"avg_duration": round(sum(durations) / len(durations)) if durations else 0,
"max_duration": max(durations) if durations else 0,
}
return result
def repeat_failures(cycles: list[dict]) -> list[dict]:
"""Issues that have failed multiple times — quarantine candidates."""
failures: dict[int, list] = defaultdict(list)
for c in cycles:
if not c.get("success") and c.get("issue"):
failures[c["issue"]].append({
"cycle": c.get("cycle"),
"reason": c.get("reason", ""),
"duration": c.get("duration", 0),
})
# Only issues with 2+ failures
return [
{"issue": k, "failure_count": len(v), "attempts": v}
for k, v in sorted(failures.items(), key=lambda x: -len(x[1]))
if len(v) >= 2
]
def duration_outliers(cycles: list[dict], threshold_multiple: float = 3.0) -> list[dict]:
"""Cycles that took way longer than average — something went wrong."""
durations = [c["duration"] for c in cycles if c.get("duration", 0) > 0]
if len(durations) < 5:
return []
avg = sum(durations) / len(durations)
threshold = avg * threshold_multiple
outliers = []
for c in cycles:
dur = c.get("duration", 0)
if dur > threshold:
outliers.append({
"cycle": c.get("cycle"),
"issue": c.get("issue"),
"type": c.get("type"),
"duration": dur,
"avg_duration": round(avg),
"multiple": round(dur / avg, 1) if avg > 0 else 0,
"reason": c.get("reason", ""),
})
return outliers
def triage_effectiveness(deep_triages: list[dict]) -> dict:
"""How well is the deep triage performing?"""
if not deep_triages:
return {"runs": 0, "note": "No deep triage data yet"}
total_reviewed = sum(d.get("issues_reviewed", 0) for d in deep_triages)
total_refined = sum(len(d.get("issues_refined", [])) for d in deep_triages)
total_created = sum(len(d.get("issues_created", [])) for d in deep_triages)
total_closed = sum(len(d.get("issues_closed", [])) for d in deep_triages)
timmy_available = sum(1 for d in deep_triages if d.get("timmy_available"))
# Extract Timmy's feedback themes
timmy_themes = []
for d in deep_triages:
fb = d.get("timmy_feedback", "")
if fb:
timmy_themes.append(fb[:200])
return {
"runs": len(deep_triages),
"total_reviewed": total_reviewed,
"total_refined": total_refined,
"total_created": total_created,
"total_closed": total_closed,
"timmy_consultation_rate": round(timmy_available / len(deep_triages), 2),
"timmy_recent_feedback": timmy_themes[-1] if timmy_themes else "",
"timmy_feedback_history": timmy_themes,
}
def generate_recommendations(
trends: dict,
types: dict,
repeats: list,
outliers: list,
triage_eff: dict,
) -> list[dict]:
"""Produce actionable recommendations from the analysis."""
recs = []
# 1. Success rate declining?
src = trends.get("success_rate_change")
if src is not None and src < -0.1:
recs.append({
"severity": "high",
"category": "reliability",
"finding": f"Success rate dropped {abs(src)*100:.0f}pp in the last 7 days",
"recommendation": "Review recent failures. Are issues poorly scoped? "
"Is main unstable? Check if triage is producing bad work items.",
})
# 2. Velocity dropping?
vc = trends.get("velocity_change")
if vc is not None and vc < -5:
recs.append({
"severity": "medium",
"category": "throughput",
"finding": f"Velocity dropped by {abs(vc)} cycles vs previous week",
"recommendation": "Check for loop stalls, long-running cycles, or queue starvation.",
})
# 3. Duration creep?
dc = trends.get("duration_change")
if dc is not None and dc > 120: # 2+ minutes longer
recs.append({
"severity": "medium",
"category": "efficiency",
"finding": f"Average cycle duration increased by {dc}s vs previous week",
"recommendation": "Issues may be growing in scope. Enforce tighter decomposition "
"in deep triage. Check if tests are getting slower.",
})
# 4. Type-specific problems
for t, info in types.items():
if info["count"] >= 3 and info["success_rate"] < 0.5:
recs.append({
"severity": "high",
"category": "type_reliability",
"finding": f"'{t}' issues fail {(1-info['success_rate'])*100:.0f}% of the time "
f"({info['count']} attempts)",
"recommendation": f"'{t}' issues need better scoping or different approach. "
f"Consider: tighter acceptance criteria, smaller scope, "
f"or delegating to Kimi with more context.",
})
if info["avg_duration"] > 600 and info["count"] >= 3: # >10 min avg
recs.append({
"severity": "medium",
"category": "type_efficiency",
"finding": f"'{t}' issues average {info['avg_duration']//60}m{info['avg_duration']%60}s "
f"(max {info['max_duration']//60}m)",
"recommendation": f"Break '{t}' issues into smaller pieces. Target <5 min per cycle.",
})
# 5. Repeat failures
for rf in repeats[:3]:
recs.append({
"severity": "high",
"category": "repeat_failure",
"finding": f"Issue #{rf['issue']} has failed {rf['failure_count']} times",
"recommendation": "Quarantine or rewrite this issue. Repeated failure = "
"bad scope or missing prerequisite.",
})
# 6. Outliers
if len(outliers) > 2:
recs.append({
"severity": "medium",
"category": "outliers",
"finding": f"{len(outliers)} cycles took {outliers[0].get('multiple', '?')}x+ "
f"longer than average",
"recommendation": "Long cycles waste resources. Add timeout enforcement or "
"break complex issues earlier.",
})
# 7. Code growth
recent = trends.get("recent_7d", {})
net = recent.get("lines_net", 0)
if net > 500:
recs.append({
"severity": "low",
"category": "code_health",
"finding": f"Net +{net} lines added in the last 7 days",
"recommendation": "Lines of code is a liability. Balance feature work with "
"refactoring. Target net-zero or negative line growth.",
})
# 8. Triage health
if triage_eff.get("runs", 0) == 0:
recs.append({
"severity": "high",
"category": "triage",
"finding": "Deep triage has never run",
"recommendation": "Enable deep triage (every 20 cycles). The loop needs "
"LLM-driven issue refinement to stay effective.",
})
# No recommendations = things are healthy
if not recs:
recs.append({
"severity": "info",
"category": "health",
"finding": "No significant issues detected",
"recommendation": "System is healthy. Continue current patterns.",
})
return recs
# ── Main ─────────────────────────────────────────────────────────────────
def main() -> None:
cycles = load_jsonl(CYCLES_FILE)
deep_triages = load_jsonl(DEEP_TRIAGE_FILE)
if not cycles:
print("[introspect] No cycle data found. Nothing to analyze.")
return
# Run all analyses
trends = compute_trends(cycles)
types = type_analysis(cycles)
repeats = repeat_failures(cycles)
outliers = duration_outliers(cycles)
triage_eff = triage_effectiveness(deep_triages)
recommendations = generate_recommendations(trends, types, repeats, outliers, triage_eff)
insights = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"total_cycles_analyzed": len(cycles),
"trends": trends,
"by_type": types,
"repeat_failures": repeats[:5],
"duration_outliers": outliers[:5],
"triage_effectiveness": triage_eff,
"recommendations": recommendations,
}
# Write insights
INSIGHTS_FILE.parent.mkdir(parents=True, exist_ok=True)
INSIGHTS_FILE.write_text(json.dumps(insights, indent=2) + "\n")
# Current epoch from latest entry
latest_epoch = ""
for c in reversed(cycles):
if c.get("epoch"):
latest_epoch = c["epoch"]
break
# Human-readable output
header = f"[introspect] Analyzed {len(cycles)} cycles"
if latest_epoch:
header += f" · current epoch: {latest_epoch}"
print(header)
print(f"\n TRENDS (7d vs previous 7d):")
r7 = trends["recent_7d"]
p7 = trends["previous_7d"]
print(f" Cycles: {r7['count']:>3d} (was {p7['count']})")
if r7["success_rate"] is not None:
arrow = "" if (trends["success_rate_change"] or 0) > 0 else "" if (trends["success_rate_change"] or 0) < 0 else ""
print(f" Success rate: {r7['success_rate']*100:>4.0f}% {arrow}")
if r7["avg_duration"] is not None:
print(f" Avg duration: {r7['avg_duration']//60}m{r7['avg_duration']%60:02d}s")
print(f" PRs merged: {r7['prs_merged']:>3d} (was {p7['prs_merged']})")
print(f" Lines net: {r7['lines_net']:>+5d}")
print(f"\n BY TYPE:")
for t, info in sorted(types.items(), key=lambda x: -x[1]["count"]):
print(f" {t:12s} n={info['count']:>2d} "
f"ok={info['success_rate']*100:>3.0f}% "
f"avg={info['avg_duration']//60}m{info['avg_duration']%60:02d}s")
if repeats:
print(f"\n REPEAT FAILURES:")
for rf in repeats[:3]:
print(f" #{rf['issue']} failed {rf['failure_count']}x")
print(f"\n RECOMMENDATIONS ({len(recommendations)}):")
for i, rec in enumerate(recommendations, 1):
sev = {"high": "🔴", "medium": "🟡", "low": "🟢", "info": " "}.get(rec["severity"], "?")
print(f" {sev} {rec['finding']}")
print(f"{rec['recommendation']}")
print(f"\n Written to: {INSIGHTS_FILE}")
if __name__ == "__main__":
main()

360
scripts/triage_score.py Normal file
View File

@@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""Mechanical triage scoring for the Timmy dev loop.
Reads open issues from Gitea, scores them on scope/acceptance/alignment,
writes a ranked queue to .loop/queue.json. No LLM calls — pure heuristics.
Run: python3 scripts/triage_score.py
Env: GITEA_TOKEN (or reads ~/.hermes/gitea_token)
GITEA_API (default: http://localhost:3000/api/v1)
REPO_SLUG (default: rockachopa/Timmy-time-dashboard)
"""
from __future__ import annotations
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
# ── Config ──────────────────────────────────────────────────────────────
GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1")
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"
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "triage.jsonl"
QUARANTINE_FILE = REPO_ROOT / ".loop" / "quarantine.json"
CYCLE_RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
# Minimum score to be considered "ready"
READY_THRESHOLD = 5
# How many recent cycle retros to check for quarantine
QUARANTINE_LOOKBACK = 20
# ── Helpers ─────────────────────────────────────────────────────────────
def get_token() -> str:
token = os.environ.get("GITEA_TOKEN", "").strip()
if not token and TOKEN_FILE.exists():
token = TOKEN_FILE.read_text().strip()
if not token:
print("[triage] ERROR: No Gitea token found", file=sys.stderr)
sys.exit(1)
return token
def api_get(path: str, token: str) -> list | dict:
"""Minimal HTTP GET using urllib (no dependencies)."""
import urllib.request
url = f"{GITEA_API}/repos/{REPO_SLUG}/{path}"
req = urllib.request.Request(url, headers={
"Authorization": f"token {token}",
"Accept": "application/json",
})
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
def load_quarantine() -> dict:
"""Load quarantined issues {issue_num: {reason, quarantined_at, failures}}."""
if QUARANTINE_FILE.exists():
try:
return json.loads(QUARANTINE_FILE.read_text())
except (json.JSONDecodeError, OSError):
pass
return {}
def save_quarantine(q: dict) -> None:
QUARANTINE_FILE.parent.mkdir(parents=True, exist_ok=True)
QUARANTINE_FILE.write_text(json.dumps(q, indent=2) + "\n")
def load_cycle_failures() -> dict[int, int]:
"""Count failures per issue from recent cycle retros."""
failures: dict[int, int] = {}
if not CYCLE_RETRO_FILE.exists():
return failures
lines = CYCLE_RETRO_FILE.read_text().strip().splitlines()
for line in lines[-QUARANTINE_LOOKBACK:]:
try:
entry = json.loads(line)
if not entry.get("success", True):
issue = entry.get("issue")
if issue:
failures[issue] = failures.get(issue, 0) + 1
except (json.JSONDecodeError, KeyError):
continue
return failures
# ── Scoring ─────────────────────────────────────────────────────────────
# Patterns that indicate file/function specificity
FILE_PATTERNS = re.compile(
r"(?:src/|tests/|scripts/|\.py|\.html|\.js|\.yaml|\.toml|\.sh)", re.IGNORECASE
)
FUNCTION_PATTERNS = re.compile(
r"(?:def |class |function |method |`\w+\(\)`)", re.IGNORECASE
)
# Patterns that indicate acceptance criteria
ACCEPTANCE_PATTERNS = re.compile(
r"(?:should|must|expect|verify|assert|test.?case|acceptance|criteria"
r"|pass(?:es|ing)|fail(?:s|ing)|return(?:s)?|raise(?:s)?)",
re.IGNORECASE,
)
TEST_PATTERNS = re.compile(
r"(?:tox|pytest|test_\w+|\.test\.|assert\s)", re.IGNORECASE
)
# Tags in issue titles
TAG_PATTERN = re.compile(r"\[([^\]]+)\]")
# Priority labels / tags
BUG_TAGS = {"bug", "broken", "crash", "error", "fix", "regression", "hotfix"}
FEATURE_TAGS = {"feature", "feat", "enhancement", "capability", "timmy-capability"}
REFACTOR_TAGS = {"refactor", "cleanup", "tech-debt", "optimization", "perf"}
META_TAGS = {"philosophy", "soul-gap", "discussion", "question", "rfc"}
LOOP_TAG = "loop-generated"
def extract_tags(title: str, labels: list[str]) -> set[str]:
"""Pull tags from [bracket] notation in title + Gitea labels."""
tags = set()
for match in TAG_PATTERN.finditer(title):
tags.add(match.group(1).lower().strip())
for label in labels:
tags.add(label.lower().strip())
return tags
def score_scope(title: str, body: str, tags: set[str]) -> int:
"""0-3: How well-scoped is this issue?"""
text = f"{title}\n{body}"
score = 0
# Mentions specific files?
if FILE_PATTERNS.search(text):
score += 1
# Mentions specific functions/classes?
if FUNCTION_PATTERNS.search(text):
score += 1
# Short, focused title (not a novel)?
clean_title = TAG_PATTERN.sub("", title).strip()
if len(clean_title) < 80:
score += 1
# Philosophy/meta issues are inherently unscoped for dev work
if tags & META_TAGS:
score = max(0, score - 2)
return min(3, score)
def score_acceptance(title: str, body: str, tags: set[str]) -> int:
"""0-3: Does this have clear acceptance criteria?"""
text = f"{title}\n{body}"
score = 0
# Has acceptance-related language?
matches = len(ACCEPTANCE_PATTERNS.findall(text))
if matches >= 3:
score += 2
elif matches >= 1:
score += 1
# Mentions specific tests?
if TEST_PATTERNS.search(text):
score += 1
# Has a "## Problem" + "## Solution" or similar structure?
if re.search(r"##\s*(problem|solution|expected|actual|steps)", body, re.IGNORECASE):
score += 1
# Philosophy issues don't have testable criteria
if tags & META_TAGS:
score = max(0, score - 1)
return min(3, score)
def score_alignment(title: str, body: str, tags: set[str]) -> int:
"""0-3: How aligned is this with the north star?"""
score = 0
# Bug on main = highest priority
if tags & BUG_TAGS:
score += 3
return min(3, score)
# Refactors that improve code health
if tags & REFACTOR_TAGS:
score += 2
# Features that grow Timmy's capabilities
if tags & FEATURE_TAGS:
score += 2
# Loop-generated issues get a small boost (the loop found real problems)
if LOOP_TAG in tags:
score += 1
# Philosophy issues are important but not dev-actionable
if tags & META_TAGS:
score = 0
return min(3, score)
def score_issue(issue: dict) -> dict:
"""Score a single issue. Returns enriched dict."""
title = issue.get("title", "")
body = issue.get("body", "") or ""
labels = [l["name"] for l in issue.get("labels", [])]
tags = extract_tags(title, labels)
number = issue["number"]
scope = score_scope(title, body, tags)
acceptance = score_acceptance(title, body, tags)
alignment = score_alignment(title, body, tags)
total = scope + acceptance + alignment
# Determine issue type
if tags & BUG_TAGS:
issue_type = "bug"
elif tags & FEATURE_TAGS:
issue_type = "feature"
elif tags & REFACTOR_TAGS:
issue_type = "refactor"
elif tags & META_TAGS:
issue_type = "philosophy"
else:
issue_type = "unknown"
# Extract mentioned files from body
files = list(set(re.findall(r"(?:src|tests|scripts)/[\w/.]+\.(?:py|html|js|yaml)", body)))
return {
"issue": number,
"title": TAG_PATTERN.sub("", title).strip(),
"type": issue_type,
"score": total,
"scope": scope,
"acceptance": acceptance,
"alignment": alignment,
"tags": sorted(tags),
"files": files[:10],
"ready": total >= READY_THRESHOLD,
}
# ── Quarantine ──────────────────────────────────────────────────────────
def update_quarantine(scored: list[dict]) -> list[dict]:
"""Auto-quarantine issues that have failed >= 2 times. Returns filtered list."""
failures = load_cycle_failures()
quarantine = load_quarantine()
now = datetime.now(timezone.utc).isoformat()
filtered = []
for item in scored:
num = item["issue"]
fail_count = failures.get(num, 0)
str_num = str(num)
if fail_count >= 2 and str_num not in quarantine:
quarantine[str_num] = {
"reason": f"Failed {fail_count} times in recent cycles",
"quarantined_at": now,
"failures": fail_count,
}
print(f"[triage] QUARANTINED #{num}: failed {fail_count} times")
continue
if str_num in quarantine:
print(f"[triage] Skipping #{num} (quarantined)")
continue
filtered.append(item)
save_quarantine(quarantine)
return filtered
# ── Main ────────────────────────────────────────────────────────────────
def run_triage() -> list[dict]:
token = get_token()
# Fetch all open issues (paginate)
page = 1
all_issues: list[dict] = []
while True:
batch = api_get(f"issues?state=open&limit=50&page={page}&type=issues", token)
if not batch:
break
all_issues.extend(batch)
if len(batch) < 50:
break
page += 1
print(f"[triage] Fetched {len(all_issues)} open issues")
# Score each
scored = [score_issue(i) for i in all_issues]
# Auto-quarantine repeat failures
scored = update_quarantine(scored)
# Sort: ready first, then by score descending, bugs always on top
def sort_key(item: dict) -> tuple:
return (
0 if item["type"] == "bug" else 1,
-item["score"],
item["issue"],
)
scored.sort(key=sort_key)
# Write queue (ready items only)
ready = [s for s in scored if s["ready"]]
not_ready = [s for s in scored if not s["ready"]]
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
QUEUE_FILE.write_text(json.dumps(ready, indent=2) + "\n")
# Write retro entry
retro_entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"total_open": len(all_issues),
"scored": len(scored),
"ready": len(ready),
"not_ready": len(not_ready),
"top_issue": ready[0]["issue"] if ready else None,
"quarantined": len(load_quarantine()),
}
RETRO_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(RETRO_FILE, "a") as f:
f.write(json.dumps(retro_entry) + "\n")
# Summary
print(f"[triage] Ready: {len(ready)} | Not ready: {len(not_ready)}")
for item in ready[:5]:
flag = "🐛" if item["type"] == "bug" else ""
print(f" {flag} #{item['issue']} score={item['score']} {item['title'][:60]}")
if not_ready:
print(f"[triage] Low-scoring ({len(not_ready)}):")
for item in not_ready[:3]:
print(f" #{item['issue']} score={item['score']} {item['title'][:50]}")
return ready
if __name__ == "__main__":
run_triage()

View File

@@ -1,10 +1,19 @@
import logging as _logging
import os
import sys
from datetime import UTC
from datetime import datetime as _datetime
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
APP_START_TIME: _datetime = _datetime.now(UTC)
def normalize_ollama_url(url: str) -> str:
"""Replace localhost with 127.0.0.1 to avoid IPv6 resolution delays."""
return url.replace("localhost", "127.0.0.1")
class Settings(BaseSettings):
"""Central configuration — all env-var access goes through this class."""
@@ -15,12 +24,39 @@ class Settings(BaseSettings):
# Ollama host — override with OLLAMA_URL env var or .env file
ollama_url: str = "http://localhost:11434"
@property
def normalized_ollama_url(self) -> str:
"""Return ollama_url with localhost replaced by 127.0.0.1."""
return normalize_ollama_url(self.ollama_url)
# LLM model passed to Agno/Ollama — override with OLLAMA_MODEL
# qwen3.5:latest is the primary model — better reasoning and tool calling
# qwen3:30b is the primary model — better reasoning and tool calling
# than llama3.1:8b-instruct while still running locally on modest hardware.
# Fallback: llama3.1:8b-instruct if qwen3.5:latest not available.
# Fallback: llama3.1:8b-instruct if qwen3:30b not available.
# llama3.2 (3B) hallucinated tool output consistently in testing.
ollama_model: str = "qwen3.5:latest"
ollama_model: str = "qwen3:30b"
# Context window size for Ollama inference — override with OLLAMA_NUM_CTX
# qwen3:30b with default context eats 45GB on a 39GB Mac.
# 4096 keeps memory at ~19GB. Set to 0 to use model defaults.
ollama_num_ctx: int = 4096
# Fallback model chains — override with FALLBACK_MODELS / VISION_FALLBACK_MODELS
# as comma-separated strings, e.g. FALLBACK_MODELS="qwen3:30b,llama3.1"
# Or edit config/providers.yaml → fallback_chains for the canonical source.
fallback_models: list[str] = [
"llama3.1:8b-instruct",
"llama3.1",
"qwen2.5:14b",
"qwen2.5:7b",
"llama3.2:3b",
]
vision_fallback_models: list[str] = [
"llama3.2:3b",
"llava:7b",
"qwen2.5-vl:3b",
"moondream:1.8b",
]
# Set DEBUG=true to enable /docs and /redoc (disabled by default)
debug: bool = False
@@ -38,23 +74,17 @@ class Settings(BaseSettings):
# Seconds to wait for user confirmation before auto-rejecting.
discord_confirm_timeout: int = 120
# ── AirLLM / backend selection ───────────────────────────────────────────
# ── Backend selection ────────────────────────────────────────────────────
# "ollama" — always use Ollama (default, safe everywhere)
# "airllm" — always use AirLLM (requires pip install ".[bigbrain]")
# "auto" — use AirLLM on Apple Silicon if airllm is installed,
# fall back to Ollama otherwise
timmy_model_backend: Literal["ollama", "airllm", "grok", "claude", "auto"] = "ollama"
# AirLLM model size when backend is airllm or auto.
# Larger = smarter, but needs more RAM / disk.
# 8b ~16 GB | 70b ~140 GB | 405b ~810 GB
airllm_model_size: Literal["8b", "70b", "405b"] = "70b"
# "auto" — pick best available local backend, fall back to Ollama
timmy_model_backend: Literal["ollama", "grok", "claude", "auto"] = "ollama"
# ── Grok (xAI) — opt-in premium cloud backend ────────────────────────
# Grok is a premium augmentation layer — local-first ethos preserved.
# Only used when explicitly enabled and query complexity warrants it.
grok_enabled: bool = False
xai_api_key: str = ""
xai_base_url: str = "https://api.x.ai/v1"
grok_default_model: str = "grok-3-fast"
grok_max_sats_per_query: int = 200
grok_free: bool = False # Skip Lightning invoice when user has own API key
@@ -112,7 +142,12 @@ class Settings(BaseSettings):
# 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] = ["*"]
cors_origins: list[str] = [
"http://localhost:3000",
"http://localhost:8000",
"http://127.0.0.1:3000",
"http://127.0.0.1:8000",
]
# Trusted hosts for the Host header check (TrustedHostMiddleware).
# Set TRUSTED_HOSTS as a comma-separated list. Wildcards supported (e.g. "*.ts.net").
@@ -212,24 +247,31 @@ class Settings(BaseSettings):
# Fallback to server when browser model is unavailable or too slow.
browser_model_fallback: bool = True
# ── Deep Focus Mode ─────────────────────────────────────────────
# "deep" = single-problem context; "broad" = default multi-task.
focus_mode: Literal["deep", "broad"] = "broad"
# ── Default Thinking ──────────────────────────────────────────────
# When enabled, the agent starts an internal thought loop on server start.
thinking_enabled: bool = True
thinking_interval_seconds: int = 300 # 5 minutes between thoughts
thinking_timeout_seconds: int = 120 # max wall-clock time per thinking cycle
thinking_distill_every: int = 10 # distill facts from thoughts every Nth thought
thinking_issue_every: int = 20 # file Gitea issues from thoughts every Nth thought
thinking_memory_check_every: int = 50 # check memory status every Nth thought
thinking_idle_timeout_minutes: int = 60 # pause thoughts after N minutes without user input
# ── Gitea Integration ─────────────────────────────────────────────
# Local Gitea instance for issue tracking and self-improvement.
# These values are passed as env vars to the gitea-mcp server process.
gitea_url: str = "http://localhost:3000"
gitea_token: str = "" # GITEA_TOKEN env var; falls back to ~/.config/gitea/token
gitea_token: str = "" # GITEA_TOKEN env var; falls back to .timmy_gitea_token
gitea_repo: str = "rockachopa/Timmy-time-dashboard" # owner/repo
gitea_enabled: bool = True
# ── MCP Servers ────────────────────────────────────────────────────
# External tool servers connected via Model Context Protocol (stdio).
mcp_gitea_command: str = "gitea-mcp -t stdio"
mcp_gitea_command: str = "gitea-mcp-server -t stdio"
mcp_filesystem_command: str = "npx -y @modelcontextprotocol/server-filesystem"
mcp_timeout: int = 15
@@ -324,14 +366,19 @@ class Settings(BaseSettings):
def model_post_init(self, __context) -> None:
"""Post-init: resolve gitea_token from file if not set via env."""
if not self.gitea_token:
token_path = os.path.expanduser("~/.config/gitea/token")
try:
if os.path.isfile(token_path):
token = open(token_path).read().strip() # noqa: SIM115
if token:
self.gitea_token = token
except OSError:
pass
# Priority: Timmy's own token → legacy admin token
repo_root = self._compute_repo_root()
timmy_token_path = os.path.join(repo_root, ".timmy_gitea_token")
legacy_token_path = os.path.expanduser("~/.config/gitea/token")
for token_path in (timmy_token_path, legacy_token_path):
try:
if os.path.isfile(token_path):
token = open(token_path).read().strip() # noqa: SIM115
if token:
self.gitea_token = token
break
except OSError:
pass
model_config = SettingsConfigDict(
env_file=".env",
@@ -346,10 +393,9 @@ if not settings.repo_root:
settings.repo_root = settings._compute_repo_root()
# ── Model fallback configuration ────────────────────────────────────────────
# Primary model for reliable tool calling (llama3.1:8b-instruct)
# Fallback if primary not available: qwen3.5:latest
OLLAMA_MODEL_PRIMARY: str = "qwen3.5:latest"
OLLAMA_MODEL_FALLBACK: str = "llama3.1:8b-instruct"
# Fallback chains are now in settings.fallback_models / settings.vision_fallback_models.
# Override via env vars (FALLBACK_MODELS, VISION_FALLBACK_MODELS) or
# edit config/providers.yaml → fallback_chains.
def check_ollama_model_available(model_name: str) -> bool:
@@ -358,7 +404,7 @@ def check_ollama_model_available(model_name: str) -> bool:
import json
import urllib.request
url = settings.ollama_url.replace("localhost", "127.0.0.1")
url = settings.normalized_ollama_url
req = urllib.request.Request(
f"{url}/api/tags",
method="GET",
@@ -371,33 +417,31 @@ def check_ollama_model_available(model_name: str) -> bool:
model_name == m or model_name == m.split(":")[0] or m.startswith(model_name)
for m in models
)
except Exception:
except (OSError, ValueError) as exc:
_startup_logger.debug("Ollama model check failed: %s", exc)
return False
def get_effective_ollama_model() -> str:
"""Get the effective Ollama model, with fallback logic."""
# If user has overridden, use their setting
"""Get the effective Ollama model, with fallback logic.
Walks the configurable ``settings.fallback_models`` chain when the
user's preferred model is not available locally.
"""
user_model = settings.ollama_model
# Check if user's model is available
if check_ollama_model_available(user_model):
return user_model
# Try primary
if check_ollama_model_available(OLLAMA_MODEL_PRIMARY):
_startup_logger.warning(
f"Requested model '{user_model}' not available. Using primary: {OLLAMA_MODEL_PRIMARY}"
)
return OLLAMA_MODEL_PRIMARY
# Try fallback
if check_ollama_model_available(OLLAMA_MODEL_FALLBACK):
_startup_logger.warning(
f"Primary model '{OLLAMA_MODEL_PRIMARY}' not available. "
f"Using fallback: {OLLAMA_MODEL_FALLBACK}"
)
return OLLAMA_MODEL_FALLBACK
# Walk the configurable fallback chain
for fallback in settings.fallback_models:
if check_ollama_model_available(fallback):
_startup_logger.warning(
"Requested model '%s' not available. Using fallback: %s",
user_model,
fallback,
)
return fallback
# Last resort - return user's setting and hope for the best
return user_model
@@ -437,8 +481,19 @@ def validate_startup(*, force: bool = False) -> None:
", ".join(_missing),
)
sys.exit(1)
if "*" in settings.cors_origins:
_startup_logger.error(
"PRODUCTION SECURITY ERROR: CORS wildcard '*' is not allowed "
"in production. Set CORS_ORIGINS to explicit origins."
)
sys.exit(1)
_startup_logger.info("Production mode: security secrets validated ✓")
else:
if "*" in settings.cors_origins:
_startup_logger.warning(
"SEC: CORS_ORIGINS contains wildcard '*'"
"restrict to explicit origins before deploying to production."
)
if not settings.l402_hmac_secret:
_startup_logger.warning(
"SEC: L402_HMAC_SECRET is not set — "

View File

@@ -8,6 +8,7 @@ Key improvements:
"""
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
@@ -28,6 +29,7 @@ from dashboard.routes.agents import router as agents_router
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.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
@@ -44,8 +46,11 @@ from dashboard.routes.tasks import router as tasks_router
from dashboard.routes.telegram import router as telegram_router
from dashboard.routes.thinking import router as thinking_router
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 router as world_router
from timmy.workshop_state import PRESENCE_FILE
class _ColorFormatter(logging.Formatter):
@@ -151,7 +156,17 @@ async def _thinking_scheduler() -> None:
while True:
try:
if settings.thinking_enabled:
await thinking_engine.think_once()
await asyncio.wait_for(
thinking_engine.think_once(),
timeout=settings.thinking_timeout_seconds,
)
except TimeoutError:
logger.warning(
"Thinking cycle timed out after %ds — Ollama may be unresponsive",
settings.thinking_timeout_seconds,
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.error("Thinking scheduler error: %s", exc)
@@ -171,7 +186,10 @@ async def _loop_qa_scheduler() -> None:
while True:
try:
if settings.loop_qa_enabled:
result = await loop_qa_orchestrator.run_next_test()
result = await asyncio.wait_for(
loop_qa_orchestrator.run_next_test(),
timeout=settings.thinking_timeout_seconds,
)
if result:
status = "PASS" if result["success"] else "FAIL"
logger.info(
@@ -180,6 +198,13 @@ async def _loop_qa_scheduler() -> None:
status,
result.get("details", "")[:80],
)
except TimeoutError:
logger.warning(
"Loop QA test timed out after %ds",
settings.thinking_timeout_seconds,
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.error("Loop QA scheduler error: %s", exc)
@@ -187,6 +212,54 @@ async def _loop_qa_scheduler() -> None:
await asyncio.sleep(interval)
_PRESENCE_POLL_SECONDS = 30
_PRESENCE_INITIAL_DELAY = 3
_SYNTHESIZED_STATE: dict = {
"version": 1,
"liveness": None,
"current_focus": "",
"mood": "idle",
"active_threads": [],
"recent_events": [],
"concerns": [],
}
async def _presence_watcher() -> None:
"""Background task: watch ~/.timmy/presence.json and broadcast changes via WS.
Polls the file every 30 seconds (matching Timmy's write cadence).
If the file doesn't exist, broadcasts a synthesised idle state.
"""
from infrastructure.ws_manager.handler import ws_manager as ws_mgr
await asyncio.sleep(_PRESENCE_INITIAL_DELAY) # Stagger after other schedulers
last_mtime: float = 0.0
while True:
try:
if PRESENCE_FILE.exists():
mtime = PRESENCE_FILE.stat().st_mtime
if mtime != last_mtime:
last_mtime = mtime
raw = await asyncio.to_thread(PRESENCE_FILE.read_text)
state = json.loads(raw)
await ws_mgr.broadcast("timmy_state", state)
else:
# File absent — broadcast synthesised state once per cycle
if last_mtime != -1.0:
last_mtime = -1.0
await ws_mgr.broadcast("timmy_state", _SYNTHESIZED_STATE)
except json.JSONDecodeError as exc:
logger.warning("presence.json parse error: %s", exc)
except Exception as exc:
logger.warning("Presence watcher error: %s", exc)
await asyncio.sleep(_PRESENCE_POLL_SECONDS)
async def _start_chat_integrations_background() -> None:
"""Background task: start chat integrations without blocking startup."""
from integrations.chat_bridge.registry import platform_registry
@@ -277,116 +350,118 @@ async def _discord_token_watcher() -> None:
logger.warning("Discord auto-start failed: %s", exc)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager with non-blocking startup."""
# Validate security config (no-op in test mode)
def _startup_init() -> None:
"""Validate config and enable event persistence."""
from config import validate_startup
validate_startup()
# Enable event persistence (unified EventBus + swarm event_log)
from infrastructure.events.bus import init_event_bus_persistence
init_event_bus_persistence()
# Create all background tasks without waiting for them
briefing_task = asyncio.create_task(_briefing_scheduler())
thinking_task = asyncio.create_task(_thinking_scheduler())
loop_qa_task = asyncio.create_task(_loop_qa_scheduler())
# Initialize Spark Intelligence engine
from spark.engine import get_spark_engine
if get_spark_engine().enabled:
logger.info("Spark Intelligence active — event capture enabled")
# Auto-prune old vector store memories on startup
if settings.memory_prune_days > 0:
try:
from timmy.memory.vector_store import prune_memories
pruned = prune_memories(
def _startup_background_tasks() -> list[asyncio.Task]:
"""Spawn all recurring background tasks (non-blocking)."""
return [
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()),
]
def _try_prune(label: str, prune_fn, days: int) -> None:
"""Run a prune function, log results, swallow errors."""
try:
pruned = prune_fn()
if pruned:
logger.info(
"%s auto-prune: removed %d entries older than %d days",
label,
pruned,
days,
)
except Exception as exc:
logger.debug("%s auto-prune skipped: %s", label, exc)
def _check_vault_size() -> None:
"""Warn if the memory vault exceeds the configured size limit."""
try:
vault_path = Path(settings.repo_root) / "memory" / "notes"
if vault_path.exists():
total_bytes = sum(f.stat().st_size for f in vault_path.rglob("*") if f.is_file())
total_mb = total_bytes / (1024 * 1024)
if total_mb > settings.memory_vault_max_mb:
logger.warning(
"Memory vault (%.1f MB) exceeds limit (%d MB) — consider archiving old notes",
total_mb,
settings.memory_vault_max_mb,
)
except Exception as exc:
logger.debug("Vault size check skipped: %s", exc)
def _startup_pruning() -> None:
"""Auto-prune old memories, thoughts, and events on startup."""
if settings.memory_prune_days > 0:
from timmy.memory_system import prune_memories
_try_prune(
"Memory",
lambda: prune_memories(
older_than_days=settings.memory_prune_days,
keep_facts=settings.memory_prune_keep_facts,
)
if pruned:
logger.info(
"Memory auto-prune: removed %d entries older than %d days",
pruned,
settings.memory_prune_days,
)
except Exception as exc:
logger.debug("Memory auto-prune skipped: %s", exc)
),
settings.memory_prune_days,
)
# Auto-prune old thoughts on startup
if settings.thoughts_prune_days > 0:
try:
from timmy.thinking import thinking_engine
from timmy.thinking import thinking_engine
pruned = thinking_engine.prune_old_thoughts(
_try_prune(
"Thought",
lambda: thinking_engine.prune_old_thoughts(
keep_days=settings.thoughts_prune_days,
keep_min=settings.thoughts_prune_keep_min,
)
if pruned:
logger.info(
"Thought auto-prune: removed %d entries older than %d days",
pruned,
settings.thoughts_prune_days,
)
except Exception as exc:
logger.debug("Thought auto-prune skipped: %s", exc)
),
settings.thoughts_prune_days,
)
# Auto-prune old system events on startup
if settings.events_prune_days > 0:
try:
from swarm.event_log import prune_old_events
from swarm.event_log import prune_old_events
pruned = prune_old_events(
_try_prune(
"Event",
lambda: prune_old_events(
keep_days=settings.events_prune_days,
keep_min=settings.events_prune_keep_min,
)
if pruned:
logger.info(
"Event auto-prune: removed %d entries older than %d days",
pruned,
settings.events_prune_days,
)
except Exception as exc:
logger.debug("Event auto-prune skipped: %s", exc)
),
settings.events_prune_days,
)
# Warn if memory vault exceeds size limit
if settings.memory_vault_max_mb > 0:
try:
vault_path = Path(settings.repo_root) / "memory" / "notes"
if vault_path.exists():
total_bytes = sum(f.stat().st_size for f in vault_path.rglob("*") if f.is_file())
total_mb = total_bytes / (1024 * 1024)
if total_mb > settings.memory_vault_max_mb:
logger.warning(
"Memory vault (%.1f MB) exceeds limit (%d MB) — consider archiving old notes",
total_mb,
settings.memory_vault_max_mb,
)
except Exception as exc:
logger.debug("Vault size check skipped: %s", exc)
_check_vault_size()
# Start chat integrations in background
chat_task = asyncio.create_task(_start_chat_integrations_background())
logger.info("✓ Dashboard ready for requests")
yield
# Cleanup on shutdown
async def _shutdown_cleanup(
bg_tasks: list[asyncio.Task],
workshop_heartbeat,
) -> None:
"""Stop chat bots, MCP sessions, heartbeat, and cancel background tasks."""
from integrations.chat_bridge.vendors.discord import discord_bot
from integrations.telegram_bot.bot import telegram_bot
await discord_bot.stop()
await telegram_bot.stop()
# Close MCP tool server sessions
try:
from timmy.mcp_tools import close_mcp_sessions
@@ -394,13 +469,44 @@ async def lifespan(app: FastAPI):
except Exception as exc:
logger.debug("MCP shutdown: %s", exc)
for task in [briefing_task, thinking_task, chat_task, loop_qa_task]:
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await workshop_heartbeat.stop()
for task in bg_tasks:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager with non-blocking startup."""
_startup_init()
bg_tasks = _startup_background_tasks()
_startup_pruning()
# Start Workshop presence heartbeat with WS relay
from dashboard.routes.world import broadcast_world_state
from timmy.workshop_state import WorkshopHeartbeat
workshop_heartbeat = WorkshopHeartbeat(on_change=broadcast_world_state)
await workshop_heartbeat.start()
# Register session logger with error capture
try:
from infrastructure.error_capture import register_error_recorder
from timmy.session_logger import get_session_logger
register_error_recorder(get_session_logger().record_error)
except Exception:
logger.debug("Failed to register error recorder")
logger.info("✓ Dashboard ready for requests")
yield
await _shutdown_cleanup(bg_tasks, workshop_heartbeat)
app = FastAPI(
@@ -413,15 +519,14 @@ app = FastAPI(
def _get_cors_origins() -> list[str]:
"""Get CORS origins from settings, with sensible defaults."""
"""Get CORS origins from settings, rejecting wildcards in production."""
origins = settings.cors_origins
if settings.debug and origins == ["*"]:
return [
"http://localhost:3000",
"http://localhost:8000",
"http://127.0.0.1:3000",
"http://127.0.0.1:8000",
]
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 != "*"]
return origins
@@ -474,6 +579,7 @@ app.include_router(grok_router)
app.include_router(models_router)
app.include_router(models_api_router)
app.include_router(chat_api_router)
app.include_router(chat_api_v1_router)
app.include_router(thinking_router)
app.include_router(calm_router)
app.include_router(tasks_router)
@@ -482,6 +588,8 @@ app.include_router(loop_qa_router)
app.include_router(system_router)
app.include_router(experiments_router)
app.include_router(db_explorer_router)
app.include_router(world_router)
app.include_router(tower_router)
@app.websocket("/ws")
@@ -500,6 +608,44 @@ async def ws_redirect(websocket: WebSocket):
await websocket.send({"type": "websocket.close", "code": 1008})
@app.websocket("/swarm/live")
async def swarm_live(websocket: WebSocket):
"""Swarm live event stream via WebSocket."""
from infrastructure.ws_manager.handler import ws_manager as ws_mgr
await ws_mgr.connect(websocket)
try:
while True:
# Keep connection alive; events are pushed via ws_mgr.broadcast()
await websocket.receive_text()
except Exception as exc:
logger.debug("WebSocket disconnect error: %s", exc)
ws_mgr.disconnect(websocket)
@app.get("/swarm/agents/sidebar", response_class=HTMLResponse)
async def swarm_agents_sidebar():
"""HTMX partial: list active swarm agents for the dashboard sidebar."""
try:
from config import settings
agents_yaml = settings.agents_config
agents = agents_yaml.get("agents", {})
lines = []
for name, cfg in agents.items():
model = cfg.get("model", "default")
lines.append(
f'<div class="mc-agent-row">'
f'<span class="mc-agent-name">{name}</span>'
f'<span class="mc-agent-model">{model}</span>'
f"</div>"
)
return "\n".join(lines) if lines else '<div class="mc-muted">No agents configured</div>'
except Exception as exc:
logger.debug("Agents sidebar error: %s", exc)
return '<div class="mc-muted">Agents unavailable</div>'
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Serve the main dashboard page."""

View File

@@ -5,6 +5,7 @@ to protect state-changing endpoints from cross-site request attacks.
"""
import hmac
import logging
import secrets
from collections.abc import Callable
from functools import wraps
@@ -16,6 +17,8 @@ from starlette.responses import JSONResponse, Response
# Module-level set to track exempt routes
_exempt_routes: set[str] = set()
logger = logging.getLogger(__name__)
def csrf_exempt(endpoint: Callable) -> Callable:
"""Decorator to mark an endpoint as exempt from CSRF validation.
@@ -97,7 +100,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
...
Usage:
app.add_middleware(CSRFMiddleware, secret="your-secret-key")
app.add_middleware(CSRFMiddleware, secret=settings.csrf_secret)
Attributes:
secret: Secret key for token signing (optional, for future use).
@@ -134,6 +137,10 @@ class CSRFMiddleware(BaseHTTPMiddleware):
if settings.timmy_disable_csrf:
return await call_next(request)
# WebSocket upgrades don't carry CSRF tokens — skip them entirely
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)
@@ -168,18 +175,12 @@ class CSRFMiddleware(BaseHTTPMiddleware):
return await call_next(request)
# Token validation failed and path is not exempt
# We still need to call the app to check if the endpoint is decorated
# with @csrf_exempt, so we'll let it through and check after routing
response = await call_next(request)
# After routing, check if the endpoint is marked as exempt
endpoint = request.scope.get("endpoint")
# Resolve the endpoint from routes BEFORE executing to avoid side effects
endpoint = self._resolve_endpoint(request)
if endpoint and is_csrf_exempt(endpoint):
# Endpoint is marked as exempt, allow the response
return response
return await call_next(request)
# Endpoint is not exempt and token validation failed
# Return 403 error
# Endpoint is not exempt and token validation failed — reject without executing
return JSONResponse(
status_code=403,
content={
@@ -189,6 +190,42 @@ class CSRFMiddleware(BaseHTTPMiddleware):
},
)
def _resolve_endpoint(self, request: Request) -> Callable | None:
"""Resolve the endpoint for a request 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).
Returns:
The endpoint callable if found, None otherwise.
"""
try:
from starlette.routing import Match
# 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
if not routes:
return 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:
"""Check if a path is likely to be CSRF exempt.
@@ -274,7 +311,8 @@ class CSRFMiddleware(BaseHTTPMiddleware):
form_token = form_data.get(self.form_field)
if form_token and validate_csrf_token(str(form_token), csrf_cookie):
return True
except Exception:
except Exception as exc:
logger.debug("CSRF form parsing error: %s", exc)
# Error parsing form data, treat as invalid
pass

View File

@@ -115,7 +115,8 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
"duration_ms": f"{duration_ms:.0f}",
},
)
except Exception:
except Exception as exc:
logger.debug("Escalation logging error: %s", exc)
pass # never let escalation break the request
# Re-raise the exception

View File

@@ -4,10 +4,14 @@ Adds common security headers to all HTTP responses to improve
application security posture against various attacks.
"""
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger(__name__)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Middleware to add security headers to all responses.
@@ -130,12 +134,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""
try:
response = await call_next(request)
except Exception:
import logging
logging.getLogger(__name__).debug(
"Upstream error in security headers middleware", exc_info=True
)
except Exception as exc:
logger.debug("Upstream error in security headers middleware: %s", exc)
from starlette.responses import PlainTextResponse
response = PlainTextResponse("Internal Server Error", status_code=500)

View File

@@ -1,4 +1,4 @@
from datetime import date, datetime
from datetime import UTC, date, datetime
from enum import StrEnum
from sqlalchemy import JSON, Boolean, Column, Date, DateTime, Index, Integer, String
@@ -40,8 +40,13 @@ class Task(Base):
deferred_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False)
updated_at = Column(
DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)
__table_args__ = (Index("ix_task_state_order", "state", "sort_order"),)
@@ -59,4 +64,4 @@ class JournalEntry(Base):
gratitude = Column(String(500), nullable=True)
energy_level = Column(Integer, nullable=True) # User-reported, 1-10
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False)

View File

@@ -12,6 +12,7 @@ from timmy.tool_safety import (
format_action_description,
get_impact_level,
)
from timmy.welcome import WELCOME_MESSAGE
logger = logging.getLogger(__name__)
@@ -56,7 +57,7 @@ async def get_history(request: Request):
return templates.TemplateResponse(
request,
"partials/history.html",
{"messages": message_log.all()},
{"messages": message_log.all(), "welcome_message": WELCOME_MESSAGE},
)
@@ -66,23 +67,91 @@ async def clear_history(request: Request):
return templates.TemplateResponse(
request,
"partials/history.html",
{"messages": []},
{"messages": [], "welcome_message": WELCOME_MESSAGE},
)
def _validate_message(message: str) -> str:
"""Strip and validate chat input; raise HTTPException on bad input."""
from fastapi import HTTPException
message = message.strip()
if not message:
raise HTTPException(status_code=400, detail="Message cannot be empty")
if len(message) > MAX_MESSAGE_LENGTH:
raise HTTPException(status_code=422, detail="Message too long")
return message
def _record_user_activity() -> None:
"""Notify the thinking engine that the user is active."""
try:
from timmy.thinking import thinking_engine
thinking_engine.record_user_input()
except Exception:
logger.debug("Failed to record user input for thinking engine")
def _extract_tool_actions(run_output) -> list[dict]:
"""If Agno paused the run for tool confirmation, build approval items."""
from timmy.approvals import create_item
tool_actions: list[dict] = []
status = getattr(run_output, "status", None)
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
if not (is_paused and getattr(run_output, "active_requirements", None)):
return tool_actions
for req in run_output.active_requirements:
if not getattr(req, "needs_confirmation", False):
continue
te = req.tool_execution
tool_name = getattr(te, "tool_name", "unknown")
tool_args = getattr(te, "tool_args", {}) or {}
item = create_item(
title=f"Dashboard: {tool_name}",
description=format_action_description(tool_name, tool_args),
proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
impact=get_impact_level(tool_name),
)
_pending_runs[item.id] = {
"run_output": run_output,
"requirement": req,
"tool_name": tool_name,
"tool_args": tool_args,
}
tool_actions.append(
{
"approval_id": item.id,
"tool_name": tool_name,
"description": format_action_description(tool_name, tool_args),
"impact": get_impact_level(tool_name),
}
)
return tool_actions
def _log_exchange(
message: str, response_text: str | None, error_text: str | None, timestamp: str
) -> None:
"""Append user message and agent/error reply to the in-memory log."""
message_log.append(role="user", content=message, timestamp=timestamp, source="browser")
if response_text:
message_log.append(
role="agent", content=response_text, timestamp=timestamp, source="browser"
)
elif error_text:
message_log.append(role="error", content=error_text, timestamp=timestamp, source="browser")
@router.post("/default/chat", response_class=HTMLResponse)
async def chat_agent(request: Request, message: str = Form(...)):
"""Chat — synchronous response with native Agno tool confirmation."""
message = message.strip()
if not message:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Message cannot be empty")
if len(message) > MAX_MESSAGE_LENGTH:
from fastapi import HTTPException
raise HTTPException(status_code=422, detail="Message too long")
message = _validate_message(message)
_record_user_activity()
timestamp = datetime.now().strftime("%H:%M:%S")
response_text = None
@@ -95,54 +164,15 @@ async def chat_agent(request: Request, message: str = Form(...)):
error_text = f"Chat error: {exc}"
run_output = None
# Check if Agno paused the run for tool confirmation
tool_actions = []
tool_actions: list[dict] = []
if run_output is not None:
status = getattr(run_output, "status", None)
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
if is_paused and getattr(run_output, "active_requirements", None):
for req in run_output.active_requirements:
if getattr(req, "needs_confirmation", False):
te = req.tool_execution
tool_name = getattr(te, "tool_name", "unknown")
tool_args = getattr(te, "tool_args", {}) or {}
from timmy.approvals import create_item
item = create_item(
title=f"Dashboard: {tool_name}",
description=format_action_description(tool_name, tool_args),
proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
impact=get_impact_level(tool_name),
)
_pending_runs[item.id] = {
"run_output": run_output,
"requirement": req,
"tool_name": tool_name,
"tool_args": tool_args,
}
tool_actions.append(
{
"approval_id": item.id,
"tool_name": tool_name,
"description": format_action_description(tool_name, tool_args),
"impact": get_impact_level(tool_name),
}
)
tool_actions = _extract_tool_actions(run_output)
raw_content = run_output.content if hasattr(run_output, "content") else ""
response_text = _clean_response(raw_content or "")
if not response_text and not tool_actions:
response_text = None # let error template show if needed
response_text = None
message_log.append(role="user", content=message, timestamp=timestamp, source="browser")
if response_text:
message_log.append(
role="agent", content=response_text, timestamp=timestamp, source="browser"
)
elif error_text:
message_log.append(role="error", content=error_text, timestamp=timestamp, source="browser")
_log_exchange(message, response_text, error_text, timestamp)
return templates.TemplateResponse(
request,
@@ -220,7 +250,8 @@ async def reject_tool(request: Request, approval_id: str):
# Resume so the agent knows the tool was rejected
try:
await continue_chat(pending["run_output"])
except Exception:
except Exception as exc:
logger.warning("Agent tool rejection error: %s", exc)
pass
reject(approval_id)

View File

@@ -27,7 +27,8 @@ async def get_briefing(request: Request):
"""Return today's briefing page (generated or cached)."""
try:
briefing = briefing_engine.get_or_generate()
except Exception:
except Exception as exc:
logger.debug("Briefing generation failed: %s", exc)
logger.exception("Briefing generation failed")
now = datetime.now(UTC)
briefing = Briefing(

View File

@@ -1,5 +1,5 @@
import logging
from datetime import date, datetime
from datetime import UTC, date, datetime
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
@@ -19,14 +19,17 @@ router = APIRouter(tags=["calm"])
# Helper functions for state machine logic
def get_now_task(db: Session) -> Task | None:
"""Return the single active NOW task, or None."""
return db.query(Task).filter(Task.state == TaskState.NOW).first()
def get_next_task(db: Session) -> Task | None:
"""Return the single queued NEXT task, or None."""
return db.query(Task).filter(Task.state == TaskState.NEXT).first()
def get_later_tasks(db: Session) -> list[Task]:
"""Return all LATER tasks ordered by MIT flag then sort_order."""
return (
db.query(Task)
.filter(Task.state == TaskState.LATER)
@@ -35,7 +38,63 @@ def get_later_tasks(db: Session) -> list[Task]:
)
def _create_mit_tasks(db: Session, titles: list[str | None]) -> list[int]:
"""Create MIT tasks from a list of titles, return their IDs."""
task_ids: list[int] = []
for title in titles:
if title:
task = Task(
title=title,
is_mit=True,
state=TaskState.LATER,
certainty=TaskCertainty.SOFT,
)
db.add(task)
db.commit()
db.refresh(task)
task_ids.append(task.id)
return task_ids
def _create_other_tasks(db: Session, other_tasks: str):
"""Create non-MIT tasks from newline-separated text."""
for line in other_tasks.split("\n"):
line = line.strip()
if line:
task = Task(
title=line,
state=TaskState.LATER,
certainty=TaskCertainty.FUZZY,
)
db.add(task)
def _seed_now_next(db: Session):
"""Set initial NOW/NEXT states when both slots are empty."""
if get_now_task(db) or get_next_task(db):
return
later_tasks = (
db.query(Task)
.filter(Task.state == TaskState.LATER)
.order_by(Task.is_mit.desc(), Task.sort_order)
.all()
)
if later_tasks:
later_tasks[0].state = TaskState.NOW
db.add(later_tasks[0])
db.flush()
if len(later_tasks) > 1:
later_tasks[1].state = TaskState.NEXT
db.add(later_tasks[1])
def promote_tasks(db: Session):
"""Enforce the NOW/NEXT/LATER state machine invariants.
- At most one NOW task (extras demoted to NEXT).
- If no NOW, promote NEXT -> NOW.
- If no NEXT, promote highest-priority LATER -> NEXT.
"""
# Ensure only one NOW task exists. If multiple, demote extras to NEXT.
now_tasks = db.query(Task).filter(Task.state == TaskState.NOW).all()
if len(now_tasks) > 1:
@@ -74,6 +133,7 @@ def promote_tasks(db: Session):
# Endpoints
@router.get("/calm", response_class=HTMLResponse)
async def get_calm_view(request: Request, db: Session = Depends(get_db)):
"""Render the main CALM dashboard with NOW/NEXT/LATER counts."""
now_task = get_now_task(db)
next_task = get_next_task(db)
later_tasks_count = len(get_later_tasks(db))
@@ -90,6 +150,7 @@ async def get_calm_view(request: Request, db: Session = Depends(get_db)):
@router.get("/calm/ritual/morning", response_class=HTMLResponse)
async def get_morning_ritual_form(request: Request):
"""Render the morning ritual intake form."""
return templates.TemplateResponse(request, "calm/morning_ritual_form.html", {})
@@ -102,63 +163,20 @@ async def post_morning_ritual(
mit3_title: str = Form(None),
other_tasks: str = Form(""),
):
# Create Journal Entry
mit_task_ids = []
"""Process morning ritual: create MITs, other tasks, and set initial states."""
journal_entry = JournalEntry(entry_date=date.today())
db.add(journal_entry)
db.commit()
db.refresh(journal_entry)
# Create MIT tasks
for mit_title in [mit1_title, mit2_title, mit3_title]:
if mit_title:
task = Task(
title=mit_title,
is_mit=True,
state=TaskState.LATER, # Initially LATER, will be promoted
certainty=TaskCertainty.SOFT,
)
db.add(task)
db.commit()
db.refresh(task)
mit_task_ids.append(task.id)
journal_entry.mit_task_ids = mit_task_ids
journal_entry.mit_task_ids = _create_mit_tasks(db, [mit1_title, mit2_title, mit3_title])
db.add(journal_entry)
# Create other tasks
for task_title in other_tasks.split("\n"):
task_title = task_title.strip()
if task_title:
task = Task(
title=task_title,
state=TaskState.LATER,
certainty=TaskCertainty.FUZZY,
)
db.add(task)
_create_other_tasks(db, other_tasks)
db.commit()
# Set initial NOW/NEXT states
# Set initial NOW/NEXT states after all tasks are created
if not get_now_task(db) and not get_next_task(db):
later_tasks = (
db.query(Task)
.filter(Task.state == TaskState.LATER)
.order_by(Task.is_mit.desc(), Task.sort_order)
.all()
)
if later_tasks:
# Set the highest priority LATER task to NOW
later_tasks[0].state = TaskState.NOW
db.add(later_tasks[0])
db.flush() # Flush to make the change visible for the next query
# Set the next highest priority LATER task to NEXT
if len(later_tasks) > 1:
later_tasks[1].state = TaskState.NEXT
db.add(later_tasks[1])
db.commit() # Commit changes after initial NOW/NEXT setup
_seed_now_next(db)
db.commit()
return templates.TemplateResponse(
request,
@@ -173,6 +191,7 @@ async def post_morning_ritual(
@router.get("/calm/ritual/evening", response_class=HTMLResponse)
async def get_evening_ritual_form(request: Request, db: Session = Depends(get_db)):
"""Render the evening ritual form for today's journal entry."""
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
if not journal_entry:
raise HTTPException(status_code=404, detail="No journal entry for today")
@@ -189,6 +208,7 @@ async def post_evening_ritual(
gratitude: str = Form(None),
energy_level: int = Form(None),
):
"""Process evening ritual: save reflection/gratitude, archive active tasks."""
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
if not journal_entry:
raise HTTPException(status_code=404, detail="No journal entry for today")
@@ -206,7 +226,7 @@ async def post_evening_ritual(
)
for task in active_tasks:
task.state = TaskState.DEFERRED # Or DONE, depending on desired archiving logic
task.deferred_at = datetime.utcnow()
task.deferred_at = datetime.now(UTC)
db.add(task)
db.commit()
@@ -223,6 +243,7 @@ async def create_new_task(
is_mit: bool = Form(False),
certainty: TaskCertainty = Form(TaskCertainty.SOFT),
):
"""Create a new task in LATER state and return updated count."""
task = Task(
title=title,
description=description,
@@ -247,6 +268,7 @@ async def start_task(
task_id: int,
db: Session = Depends(get_db),
):
"""Move a task to NOW state, demoting the current NOW to NEXT."""
current_now_task = get_now_task(db)
if current_now_task and current_now_task.id != task_id:
current_now_task.state = TaskState.NEXT # Demote current NOW to NEXT
@@ -257,7 +279,7 @@ async def start_task(
raise HTTPException(status_code=404, detail="Task not found")
task.state = TaskState.NOW
task.started_at = datetime.utcnow()
task.started_at = datetime.now(UTC)
db.add(task)
db.commit()
@@ -281,12 +303,13 @@ async def complete_task(
task_id: int,
db: Session = Depends(get_db),
):
"""Mark a task as DONE and trigger state promotion."""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
task.state = TaskState.DONE
task.completed_at = datetime.utcnow()
task.completed_at = datetime.now(UTC)
db.add(task)
db.commit()
@@ -309,12 +332,13 @@ async def defer_task(
task_id: int,
db: Session = Depends(get_db),
):
"""Defer a task and trigger state promotion."""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
task.state = TaskState.DEFERRED
task.deferred_at = datetime.utcnow()
task.deferred_at = datetime.now(UTC)
db.add(task)
db.commit()
@@ -333,6 +357,7 @@ async def defer_task(
@router.get("/calm/partials/later_tasks_list", response_class=HTMLResponse)
async def get_later_tasks_list(request: Request, db: Session = Depends(get_db)):
"""Render the expandable list of LATER tasks."""
later_tasks = get_later_tasks(db)
return templates.TemplateResponse(
"calm/partials/later_tasks_list.html",
@@ -348,6 +373,7 @@ async def reorder_tasks(
later_task_ids: str = Form(""),
next_task_id: int | None = Form(None),
):
"""Reorder LATER tasks and optionally promote one to NEXT."""
# Reorder LATER tasks
if later_task_ids:
ids_in_order = [int(x.strip()) for x in later_task_ids.split(",") if x.strip()]

View File

@@ -31,6 +31,93 @@ _UPLOAD_DIR = str(Path(settings.repo_root) / "data" / "chat-uploads")
_MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB
# ── POST /api/chat — helpers ─────────────────────────────────────────────────
async def _parse_chat_body(request: Request) -> tuple[dict | None, JSONResponse | None]:
"""Parse and validate the JSON request body.
Returns (body, None) on success or (None, error_response) on failure.
"""
content_length = request.headers.get("content-length")
if content_length and int(content_length) > settings.chat_api_max_body_bytes:
return None, JSONResponse(status_code=413, content={"error": "Request body too large"})
try:
body = await request.json()
except Exception as exc:
logger.warning("Chat API JSON parse error: %s", exc)
return None, JSONResponse(status_code=400, content={"error": "Invalid JSON"})
messages = body.get("messages")
if not messages or not isinstance(messages, list):
return None, JSONResponse(status_code=400, content={"error": "messages array is required"})
return body, None
def _extract_user_message(messages: list[dict]) -> str | None:
"""Return the text of the last user message, or *None* if absent."""
for msg in reversed(messages):
if msg.get("role") == "user":
content = msg.get("content", "")
if isinstance(content, list):
text_parts = [
p.get("text", "")
for p in content
if isinstance(p, dict) and p.get("type") == "text"
]
return " ".join(text_parts).strip() or None
text = str(content).strip()
return text or None
return None
def _build_context_prefix() -> str:
"""Build the system-context preamble injected before the user message."""
now = datetime.now()
return (
f"[System: Current date/time is "
f"{now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
f"[System: Mobile client]\n\n"
)
def _notify_thinking_engine() -> None:
"""Record user activity so the thinking engine knows we're not idle."""
try:
from timmy.thinking import thinking_engine
thinking_engine.record_user_input()
except Exception:
logger.debug("Failed to record user input for thinking engine")
async def _process_chat(user_msg: str) -> dict | JSONResponse:
"""Send *user_msg* to the agent, log the exchange, and return a response."""
_notify_thinking_engine()
timestamp = datetime.now().strftime("%H:%M:%S")
try:
response_text = await agent_chat(
_build_context_prefix() + user_msg,
session_id="mobile",
)
message_log.append(role="user", content=user_msg, timestamp=timestamp, source="api")
message_log.append(role="agent", content=response_text, timestamp=timestamp, source="api")
return {"reply": response_text, "timestamp": timestamp}
except Exception as exc:
error_msg = f"Agent is offline: {exc}"
logger.error("api_chat error: %s", exc)
message_log.append(role="user", content=user_msg, timestamp=timestamp, source="api")
message_log.append(role="error", content=error_msg, timestamp=timestamp, source="api")
return JSONResponse(
status_code=503,
content={"error": error_msg, "timestamp": timestamp},
)
# ── POST /api/chat ────────────────────────────────────────────────────────────
@@ -44,69 +131,15 @@ async def api_chat(request: Request):
Response:
{"reply": "...", "timestamp": "HH:MM:SS"}
"""
# Enforce request body size limit
content_length = request.headers.get("content-length")
if content_length and int(content_length) > settings.chat_api_max_body_bytes:
return JSONResponse(status_code=413, content={"error": "Request body too large"})
body, err = await _parse_chat_body(request)
if err:
return err
try:
body = await request.json()
except Exception:
return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
messages = body.get("messages")
if not messages or not isinstance(messages, list):
return JSONResponse(status_code=400, content={"error": "messages array is required"})
# Extract the latest user message text
last_user_msg = None
for msg in reversed(messages):
if msg.get("role") == "user":
content = msg.get("content", "")
# Handle multimodal content arrays — extract text parts
if isinstance(content, list):
text_parts = [
p.get("text", "")
for p in content
if isinstance(p, dict) and p.get("type") == "text"
]
last_user_msg = " ".join(text_parts).strip()
else:
last_user_msg = str(content).strip()
break
if not last_user_msg:
user_msg = _extract_user_message(body["messages"])
if not user_msg:
return JSONResponse(status_code=400, content={"error": "No user message found"})
timestamp = datetime.now().strftime("%H:%M:%S")
try:
# Inject context (same pattern as the HTMX chat handler in agents.py)
now = datetime.now()
context_prefix = (
f"[System: Current date/time is "
f"{now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
f"[System: Mobile client]\n\n"
)
response_text = await agent_chat(
context_prefix + last_user_msg,
session_id="mobile",
)
message_log.append(role="user", content=last_user_msg, timestamp=timestamp, source="api")
message_log.append(role="agent", content=response_text, timestamp=timestamp, source="api")
return {"reply": response_text, "timestamp": timestamp}
except Exception as exc:
error_msg = f"Agent is offline: {exc}"
logger.error("api_chat error: %s", exc)
message_log.append(role="user", content=last_user_msg, timestamp=timestamp, source="api")
message_log.append(role="error", content=error_msg, timestamp=timestamp, source="api")
return JSONResponse(
status_code=503,
content={"error": error_msg, "timestamp": timestamp},
)
return await _process_chat(user_msg)
# ── POST /api/upload ──────────────────────────────────────────────────────────

View File

@@ -0,0 +1,198 @@
"""Version 1 (v1) JSON REST API for the Timmy Time iPad app.
This module implements the specific endpoints required by the native
iPad app as defined in the project specification.
Endpoints:
POST /api/v1/chat — Streaming SSE chat response
GET /api/v1/chat/history — Retrieve chat history with limit
POST /api/v1/upload — Multipart file upload with auto-detection
GET /api/v1/status — Detailed system and model status
"""
import json
import logging
import os
import uuid
from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from config import APP_START_TIME, settings
from dashboard.routes.health import _check_ollama
from dashboard.store import message_log
from timmy.session import _get_agent
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["chat-api-v1"])
_UPLOAD_DIR = str(Path(settings.repo_root) / "data" / "chat-uploads")
_MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB
# ── POST /api/v1/chat ─────────────────────────────────────────────────────────
@router.post("/chat")
async def api_v1_chat(request: Request):
"""Accept a JSON chat payload and return a streaming SSE response.
Request body:
{
"message": "string",
"session_id": "string",
"attachments": ["id1", "id2"]
}
Response:
text/event-stream (SSE)
"""
try:
body = await request.json()
except Exception as exc:
logger.warning("Chat v1 API JSON parse error: %s", exc)
return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
message = body.get("message")
session_id = body.get("session_id", "ipad-app")
attachments = body.get("attachments", [])
if not message:
return JSONResponse(status_code=400, content={"error": "message is required"})
# Prepare context for the agent
context_prefix = (
f"[System: Current date/time is "
f"{datetime.now().strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
f"[System: iPad App client]\n"
)
if attachments:
context_prefix += f"[System: Attachments: {', '.join(attachments)}]\n"
context_prefix += "\n"
full_prompt = context_prefix + message
async def event_generator():
try:
agent = _get_agent()
# Using streaming mode for SSE
async for chunk in agent.arun(full_prompt, stream=True, session_id=session_id):
# Agno chunks can be strings or RunOutput
content = chunk.content if hasattr(chunk, "content") else str(chunk)
if content:
yield f"data: {json.dumps({'text': content})}\n\n"
yield "data: [DONE]\n\n"
except Exception as exc:
logger.error("SSE stream error: %s", exc)
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
# ── GET /api/v1/chat/history ──────────────────────────────────────────────────
@router.get("/chat/history")
async def api_v1_chat_history(
session_id: str = Query("ipad-app"), limit: int = Query(50, ge=1, le=100)
):
"""Return recent chat history for a specific session."""
# Filter and limit the message log
# Note: message_log.all() returns all messages; we filter by source or just return last N
all_msgs = message_log.all()
# In a real implementation, we'd filter by session_id if message_log supported it.
# For now, we return the last 'limit' messages.
history = [
{
"role": msg.role,
"content": msg.content,
"timestamp": msg.timestamp,
"source": msg.source,
}
for msg in all_msgs[-limit:]
]
return {"messages": history}
# ── POST /api/v1/upload ───────────────────────────────────────────────────────
@router.post("/upload")
async def api_v1_upload(file: UploadFile = File(...)):
"""Accept a file upload, auto-detect type, and return metadata.
Response:
{
"id": "string",
"type": "image|audio|document|url",
"summary": "string",
"metadata": {...}
}
"""
os.makedirs(_UPLOAD_DIR, exist_ok=True)
file_id = uuid.uuid4().hex[:12]
safe_name = os.path.basename(file.filename or "upload")
stored_name = f"{file_id}-{safe_name}"
file_path = os.path.join(_UPLOAD_DIR, stored_name)
# Verify resolved path stays within upload directory
resolved = Path(file_path).resolve()
upload_root = Path(_UPLOAD_DIR).resolve()
if not str(resolved).startswith(str(upload_root)):
raise HTTPException(status_code=400, detail="Invalid file name")
contents = await file.read()
if len(contents) > _MAX_UPLOAD_SIZE:
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
with open(file_path, "wb") as f:
f.write(contents)
# Auto-detect type based on extension/mime
mime_type = file.content_type or "application/octet-stream"
ext = os.path.splitext(safe_name)[1].lower()
media_type = "document"
if mime_type.startswith("image/") or ext in [".jpg", ".jpeg", ".png", ".heic"]:
media_type = "image"
elif mime_type.startswith("audio/") or ext in [".m4a", ".mp3", ".wav", ".caf"]:
media_type = "audio"
elif ext in [".pdf", ".txt", ".md"]:
media_type = "document"
# Placeholder for actual processing (OCR, Whisper, etc.)
summary = f"Uploaded {media_type}: {safe_name}"
return {
"id": file_id,
"type": media_type,
"summary": summary,
"url": f"/uploads/{stored_name}",
"metadata": {"fileName": safe_name, "mimeType": mime_type, "size": len(contents)},
}
# ── GET /api/v1/status ────────────────────────────────────────────────────────
@router.get("/status")
async def api_v1_status():
"""Detailed system and model status."""
ollama_status = await _check_ollama()
uptime = (datetime.now(UTC) - APP_START_TIME).total_seconds()
return {
"timmy": "online" if ollama_status.status == "healthy" else "offline",
"model": settings.ollama_model,
"ollama": "running" if ollama_status.status == "healthy" else "stopped",
"uptime": f"{int(uptime // 3600)}h {int((uptime % 3600) // 60)}m",
"version": "2.0.0-v1-api",
}

View File

@@ -3,6 +3,7 @@
import asyncio
import logging
import sqlite3
from contextlib import closing
from pathlib import Path
from fastapi import APIRouter, Request
@@ -39,56 +40,50 @@ def _query_database(db_path: str) -> dict:
"""Open a database read-only and return all tables with their rows."""
result = {"tables": {}, "error": None}
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
except Exception as exc:
result["error"] = str(exc)
return result
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
conn.row_factory = sqlite3.Row
try:
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
for (table_name,) in tables:
try:
rows = conn.execute(
f"SELECT * FROM [{table_name}] LIMIT {MAX_ROWS}" # noqa: S608
).fetchall()
columns = (
[
desc[0]
for desc in conn.execute(
f"SELECT * FROM [{table_name}] LIMIT 0"
).description
]
if rows
else []
) # noqa: S608
if not columns and rows:
columns = list(rows[0].keys())
elif not columns:
# Get columns even for empty tables
cursor = conn.execute(f"PRAGMA table_info([{table_name}])") # noqa: S608
columns = [r[1] for r in cursor.fetchall()]
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0] # noqa: S608
result["tables"][table_name] = {
"columns": columns,
"rows": [dict(r) for r in rows],
"total_count": count,
"truncated": count > MAX_ROWS,
}
except Exception as exc:
result["tables"][table_name] = {
"error": str(exc),
"columns": [],
"rows": [],
"total_count": 0,
"truncated": False,
}
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
for (table_name,) in tables:
try:
rows = conn.execute(
f"SELECT * FROM [{table_name}] LIMIT {MAX_ROWS}" # noqa: S608
).fetchall()
columns = (
[
desc[0]
for desc in conn.execute(
f"SELECT * FROM [{table_name}] LIMIT 0"
).description
]
if rows
else []
) # noqa: S608
if not columns and rows:
columns = list(rows[0].keys())
elif not columns:
# Get columns even for empty tables
cursor = conn.execute(f"PRAGMA table_info([{table_name}])") # noqa: S608
columns = [r[1] for r in cursor.fetchall()]
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0] # noqa: S608
result["tables"][table_name] = {
"columns": columns,
"rows": [dict(r) for r in rows],
"total_count": count,
"truncated": count > MAX_ROWS,
}
except Exception as exc:
result["tables"][table_name] = {
"error": str(exc),
"columns": [],
"rows": [],
"total_count": 0,
"truncated": False,
}
except Exception as exc:
result["error"] = str(exc)
finally:
conn.close()
return result

View File

@@ -30,8 +30,8 @@ async def experiments_page(request: Request):
history = []
try:
history = get_experiment_history(_workspace())
except Exception:
logger.debug("Failed to load experiment history", exc_info=True)
except Exception as exc:
logger.debug("Failed to load experiment history: %s", exc)
return templates.TemplateResponse(
request,

View File

@@ -52,8 +52,8 @@ async def grok_status(request: Request):
"estimated_cost_sats": backend.stats.estimated_cost_sats,
"errors": backend.stats.errors,
}
except Exception:
logger.debug("Failed to load Grok stats", exc_info=True)
except Exception as exc:
logger.warning("Failed to load Grok stats: %s", exc)
return templates.TemplateResponse(
request,
@@ -94,8 +94,8 @@ async def toggle_grok_mode(request: Request):
tool_name="grok_mode_toggle",
success=True,
)
except Exception:
logger.debug("Failed to log Grok toggle to Spark", exc_info=True)
except Exception as exc:
logger.warning("Failed to log Grok toggle to Spark: %s", exc)
return HTMLResponse(
_render_toggle_card(_grok_mode_active),
@@ -128,8 +128,8 @@ def _run_grok_query(message: str) -> dict:
sats = min(settings.grok_max_sats_per_query, 100)
ln.create_invoice(sats, f"Grok: {message[:50]}")
invoice_note = f" | {sats} sats"
except Exception:
logger.debug("Lightning invoice creation failed", exc_info=True)
except Exception as exc:
logger.warning("Lightning invoice creation failed: %s", exc)
try:
result = backend.run(message)

View File

@@ -6,14 +6,18 @@ for the Mission Control dashboard.
import asyncio
import logging
import sqlite3
import time
from contextlib import closing
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from config import APP_START_TIME as _START_TIME
from config import settings
logger = logging.getLogger(__name__)
@@ -49,7 +53,6 @@ class HealthStatus(BaseModel):
# Simple uptime tracking
_START_TIME = datetime.now(UTC)
# Ollama health cache (30-second TTL)
_ollama_cache: DependencyStatus | None = None
@@ -62,7 +65,7 @@ def _check_ollama_sync() -> DependencyStatus:
try:
import urllib.request
url = settings.ollama_url.replace("localhost", "127.0.0.1")
url = settings.normalized_ollama_url
req = urllib.request.Request(
f"{url}/api/tags",
method="GET",
@@ -76,8 +79,8 @@ def _check_ollama_sync() -> DependencyStatus:
sovereignty_score=10,
details={"url": settings.ollama_url, "model": settings.ollama_model},
)
except Exception:
logger.debug("Ollama health check failed", exc_info=True)
except Exception as exc:
logger.debug("Ollama health check failed: %s", exc)
return DependencyStatus(
name="Ollama AI",
@@ -101,7 +104,8 @@ async def _check_ollama() -> DependencyStatus:
try:
result = await asyncio.to_thread(_check_ollama_sync)
except Exception:
except Exception as exc:
logger.debug("Ollama async check failed: %s", exc)
result = DependencyStatus(
name="Ollama AI",
status="unavailable",
@@ -133,13 +137,9 @@ def _check_lightning() -> DependencyStatus:
def _check_sqlite() -> DependencyStatus:
"""Check SQLite database status."""
try:
import sqlite3
from pathlib import Path
db_path = Path(settings.repo_root) / "data" / "timmy.db"
conn = sqlite3.connect(str(db_path))
conn.execute("SELECT 1")
conn.close()
with closing(sqlite3.connect(str(db_path))) as conn:
conn.execute("SELECT 1")
return DependencyStatus(
name="SQLite Database",

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from dashboard.templating import templates
from timmy.memory.vector_store import (
from timmy.memory_system import (
delete_memory,
get_memory_stats,
recall_personal_facts_with_ids,

View File

@@ -1,10 +1,12 @@
"""System-level dashboard routes (ledger, upgrades, etc.)."""
import logging
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from config import settings
from dashboard.templating import templates
logger = logging.getLogger(__name__)
@@ -14,52 +16,11 @@ router = APIRouter(tags=["system"])
@router.get("/lightning/ledger", response_class=HTMLResponse)
async def lightning_ledger(request: Request):
"""Ledger and balance page."""
# Mock data for now, as this seems to be a UI-first feature
balance = {
"available_sats": 1337,
"incoming_total_sats": 2000,
"outgoing_total_sats": 663,
"fees_paid_sats": 5,
"net_sats": 1337,
"pending_incoming_sats": 0,
"pending_outgoing_sats": 0,
}
"""Ledger and balance page backed by the in-memory Lightning ledger."""
from lightning.ledger import get_balance, get_transactions
# Mock transactions
from collections import namedtuple
from enum import Enum
class TxType(Enum):
incoming = "incoming"
outgoing = "outgoing"
class TxStatus(Enum):
completed = "completed"
pending = "pending"
Tx = namedtuple(
"Tx", ["tx_type", "status", "amount_sats", "payment_hash", "memo", "created_at"]
)
transactions = [
Tx(
TxType.outgoing,
TxStatus.completed,
50,
"hash1",
"Model inference",
"2026-03-04 10:00:00",
),
Tx(
TxType.incoming,
TxStatus.completed,
1000,
"hash2",
"Manual deposit",
"2026-03-03 15:00:00",
),
]
balance = get_balance()
transactions = get_transactions()
return templates.TemplateResponse(
request,
@@ -68,7 +29,7 @@ async def lightning_ledger(request: Request):
"balance": balance,
"transactions": transactions,
"tx_types": ["incoming", "outgoing"],
"tx_statuses": ["completed", "pending"],
"tx_statuses": ["pending", "settled", "failed", "expired"],
"filter_type": None,
"filter_status": None,
"stats": {},
@@ -144,5 +105,83 @@ async def api_notifications():
for e in events
]
)
except Exception:
except Exception as exc:
logger.debug("System events fetch error: %s", exc)
return JSONResponse([])
@router.get("/api/briefing/status", response_class=JSONResponse)
async def api_briefing_status():
"""Return briefing status including pending approvals and last generated time."""
from timmy import approvals
from timmy.briefing import engine as briefing_engine
pending = approvals.list_pending()
pending_count = len(pending)
last_generated = None
try:
cached = briefing_engine.get_cached()
if cached:
last_generated = cached.generated_at.isoformat()
except Exception:
logger.debug("Failed to read briefing cache")
return JSONResponse(
{
"status": "ok",
"pending_approvals": pending_count,
"last_generated": last_generated,
}
)
@router.get("/api/memory/status", response_class=JSONResponse)
async def api_memory_status():
"""Return memory database status including file info and indexed files count."""
from timmy.memory_system import get_memory_stats
db_path = Path(settings.repo_root) / "data" / "memory.db"
db_exists = db_path.exists()
db_size = db_path.stat().st_size if db_exists else 0
try:
stats = get_memory_stats()
indexed_files = stats.get("total_entries", 0)
except Exception:
logger.debug("Failed to get memory stats")
indexed_files = 0
return JSONResponse(
{
"status": "ok",
"db_exists": db_exists,
"db_size_bytes": db_size,
"indexed_files": indexed_files,
}
)
@router.get("/api/swarm/status", response_class=JSONResponse)
async def api_swarm_status():
"""Return swarm worker status and pending tasks count."""
from dashboard.routes.tasks import _get_db
pending_tasks = 0
try:
with _get_db() as db:
row = db.execute(
"SELECT COUNT(*) as cnt FROM tasks WHERE status IN ('pending_approval','approved')"
).fetchone()
pending_tasks = row["cnt"] if row else 0
except Exception:
logger.debug("Failed to count pending tasks")
return JSONResponse(
{
"status": "ok",
"active_workers": 0,
"pending_tasks": pending_tasks,
"message": "Swarm monitoring endpoint",
}
)

View File

@@ -3,7 +3,9 @@
import logging
import sqlite3
import uuid
from datetime import datetime
from collections.abc import Generator
from contextlib import closing, contextmanager
from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
@@ -35,26 +37,27 @@ VALID_STATUSES = {
VALID_PRIORITIES = {"low", "normal", "high", "urgent"}
def _get_db() -> sqlite3.Connection:
@contextmanager
def _get_db() -> Generator[sqlite3.Connection, None, None]:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'pending_approval',
priority TEXT DEFAULT 'normal',
assigned_to TEXT DEFAULT '',
created_by TEXT DEFAULT 'operator',
result TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
completed_at TEXT
)
""")
conn.commit()
return conn
with closing(sqlite3.connect(str(DB_PATH))) as conn:
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'pending_approval',
priority TEXT DEFAULT 'normal',
assigned_to TEXT DEFAULT '',
created_by TEXT DEFAULT 'operator',
result TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
completed_at TEXT
)
""")
conn.commit()
yield conn
def _row_to_dict(row: sqlite3.Row) -> dict:
@@ -101,8 +104,7 @@ class _TaskView:
@router.get("/tasks", response_class=HTMLResponse)
async def tasks_page(request: Request):
"""Render the main task queue page with 3-column layout."""
db = _get_db()
try:
with _get_db() as db:
pending = [
_TaskView(_row_to_dict(r))
for r in db.execute(
@@ -121,8 +123,6 @@ async def tasks_page(request: Request):
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
).fetchall()
]
finally:
db.close()
return templates.TemplateResponse(
request,
@@ -145,13 +145,10 @@ async def tasks_page(request: Request):
@router.get("/tasks/pending", response_class=HTMLResponse)
async def tasks_pending(request: Request):
db = _get_db()
try:
with _get_db() as db:
rows = db.execute(
"SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC"
).fetchall()
finally:
db.close()
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
@@ -167,13 +164,10 @@ async def tasks_pending(request: Request):
@router.get("/tasks/active", response_class=HTMLResponse)
async def tasks_active(request: Request):
db = _get_db()
try:
with _get_db() as db:
rows = db.execute(
"SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
).fetchall()
finally:
db.close()
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
@@ -189,13 +183,10 @@ async def tasks_active(request: Request):
@router.get("/tasks/completed", response_class=HTMLResponse)
async def tasks_completed(request: Request):
db = _get_db()
try:
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()
finally:
db.close()
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
@@ -228,19 +219,16 @@ async def create_task_form(
raise HTTPException(status_code=400, detail="Task title cannot be empty")
task_id = str(uuid.uuid4())
now = datetime.utcnow().isoformat()
now = datetime.now(UTC).isoformat()
priority = priority if priority in VALID_PRIORITIES else "normal"
db = _get_db()
try:
with _get_db() as db:
db.execute(
"INSERT INTO tasks (id, title, description, priority, assigned_to, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(task_id, title, description, priority, assigned_to, now),
)
db.commit()
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
finally:
db.close()
task = _TaskView(_row_to_dict(row))
return templates.TemplateResponse(request, "partials/task_card.html", {"task": task})
@@ -283,16 +271,13 @@ async def modify_task(
title: str = Form(...),
description: str = Form(""),
):
db = _get_db()
try:
with _get_db() as db:
db.execute(
"UPDATE tasks SET title=?, description=? WHERE id=?",
(title, description, task_id),
)
db.commit()
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
finally:
db.close()
if not row:
raise HTTPException(404, "Task not found")
task = _TaskView(_row_to_dict(row))
@@ -302,18 +287,15 @@ async def modify_task(
async def _set_status(request: Request, task_id: str, new_status: str):
"""Helper to update status and return refreshed task card."""
completed_at = (
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
datetime.now(UTC).isoformat() if new_status in ("completed", "vetoed", "failed") else None
)
db = _get_db()
try:
with _get_db() as db:
db.execute(
"UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
(new_status, completed_at, task_id),
)
db.commit()
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
finally:
db.close()
if not row:
raise HTTPException(404, "Task not found")
task = _TaskView(_row_to_dict(row))
@@ -334,13 +316,12 @@ async def api_create_task(request: Request):
raise HTTPException(422, "title is required")
task_id = str(uuid.uuid4())
now = datetime.utcnow().isoformat()
now = datetime.now(UTC).isoformat()
priority = body.get("priority", "normal")
if priority not in VALID_PRIORITIES:
priority = "normal"
db = _get_db()
try:
with _get_db() as db:
db.execute(
"INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
@@ -356,8 +337,6 @@ async def api_create_task(request: Request):
)
db.commit()
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
finally:
db.close()
return JSONResponse(_row_to_dict(row), status_code=201)
@@ -365,11 +344,8 @@ async def api_create_task(request: Request):
@router.get("/api/tasks", response_class=JSONResponse)
async def api_list_tasks():
"""List all tasks as JSON."""
db = _get_db()
try:
with _get_db() as db:
rows = db.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall()
finally:
db.close()
return JSONResponse([_row_to_dict(r) for r in rows])
@@ -382,18 +358,15 @@ async def api_update_status(task_id: str, request: Request):
raise HTTPException(422, f"Invalid status. Must be one of: {VALID_STATUSES}")
completed_at = (
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
datetime.now(UTC).isoformat() if new_status in ("completed", "vetoed", "failed") else None
)
db = _get_db()
try:
with _get_db() as db:
db.execute(
"UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
(new_status, completed_at, task_id),
)
db.commit()
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
finally:
db.close()
if not row:
raise HTTPException(404, "Task not found")
return JSONResponse(_row_to_dict(row))
@@ -402,12 +375,9 @@ async def api_update_status(task_id: str, request: Request):
@router.delete("/api/tasks/{task_id}", response_class=JSONResponse)
async def api_delete_task(task_id: str):
"""Delete a task."""
db = _get_db()
try:
with _get_db() as db:
cursor = db.execute("DELETE FROM tasks WHERE id=?", (task_id,))
db.commit()
finally:
db.close()
if cursor.rowcount == 0:
raise HTTPException(404, "Task not found")
return JSONResponse({"success": True, "id": task_id})
@@ -421,8 +391,7 @@ async def api_delete_task(task_id: str):
@router.get("/api/queue/status", response_class=JSONResponse)
async def queue_status(assigned_to: str = "default"):
"""Return queue status for the chat panel's agent status indicator."""
db = _get_db()
try:
with _get_db() as db:
running = db.execute(
"SELECT * FROM tasks WHERE status='running' AND assigned_to=? LIMIT 1",
(assigned_to,),
@@ -431,8 +400,6 @@ async def queue_status(assigned_to: str = "default"):
"SELECT COUNT(*) as cnt FROM tasks WHERE status IN ('pending_approval','approved') AND assigned_to=?",
(assigned_to,),
).fetchone()
finally:
db.close()
if running:
return JSONResponse(

View File

@@ -0,0 +1,108 @@
"""Tower dashboard — real-time Spark visualization via WebSocket.
GET /tower — HTML Tower dashboard (Thinking / Predicting / Advising)
WS /tower/ws — WebSocket stream of Spark engine state updates
"""
import asyncio
import json
import logging
from fastapi import APIRouter, Request, WebSocket
from fastapi.responses import HTMLResponse
from dashboard.templating import templates
from spark.engine import spark_engine
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tower", tags=["tower"])
_PUSH_INTERVAL = 5 # seconds between state broadcasts
def _spark_snapshot() -> dict:
"""Build a JSON-serialisable snapshot of Spark state."""
status = spark_engine.status()
timeline = spark_engine.get_timeline(limit=10)
events = []
for ev in timeline:
entry = {
"event_type": ev.event_type,
"description": ev.description,
"importance": ev.importance,
"created_at": ev.created_at,
}
if ev.agent_id:
entry["agent_id"] = ev.agent_id[:8]
if ev.task_id:
entry["task_id"] = ev.task_id[:8]
try:
entry["data"] = json.loads(ev.data)
except (json.JSONDecodeError, TypeError):
entry["data"] = {}
events.append(entry)
predictions = spark_engine.get_predictions(limit=5)
preds = []
for p in predictions:
pred = {
"task_id": p.task_id[:8] if p.task_id else "?",
"accuracy": p.accuracy,
"evaluated": p.evaluated_at is not None,
"created_at": p.created_at,
}
try:
pred["predicted"] = json.loads(p.predicted_value)
except (json.JSONDecodeError, TypeError):
pred["predicted"] = {}
preds.append(pred)
advisories = spark_engine.get_advisories()
advs = [
{
"category": a.category,
"priority": a.priority,
"title": a.title,
"detail": a.detail,
"suggested_action": a.suggested_action,
}
for a in advisories
]
return {
"type": "spark_state",
"status": status,
"events": events,
"predictions": preds,
"advisories": advs,
}
@router.get("", response_class=HTMLResponse)
async def tower_ui(request: Request):
"""Render the Tower dashboard page."""
snapshot = _spark_snapshot()
return templates.TemplateResponse(
request,
"tower.html",
{"snapshot": snapshot},
)
@router.websocket("/ws")
async def tower_ws(websocket: WebSocket) -> None:
"""Stream Spark state snapshots to the Tower dashboard."""
await websocket.accept()
logger.info("Tower WS connected")
try:
# Send initial snapshot
await websocket.send_text(json.dumps(_spark_snapshot()))
while True:
await asyncio.sleep(_PUSH_INTERVAL)
await websocket.send_text(json.dumps(_spark_snapshot()))
except Exception:
logger.debug("Tower WS disconnected")

View File

@@ -43,7 +43,8 @@ async def tts_status():
"available": voice_tts.available,
"voices": voice_tts.get_voices() if voice_tts.available else [],
}
except Exception:
except Exception as exc:
logger.debug("Voice config error: %s", exc)
return {"available": False, "voices": []}
@@ -139,7 +140,8 @@ async def process_voice_input(
if voice_tts.available:
voice_tts.speak(response_text)
except Exception:
except Exception as exc:
logger.debug("Voice TTS error: %s", exc)
pass
return {

View File

@@ -3,7 +3,9 @@
import logging
import sqlite3
import uuid
from datetime import datetime
from collections.abc import Generator
from contextlib import closing, contextmanager
from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
@@ -23,28 +25,29 @@ CATEGORIES = ["bug", "feature", "suggestion", "maintenance", "security"]
VALID_STATUSES = {"submitted", "triaged", "approved", "in_progress", "completed", "rejected"}
def _get_db() -> sqlite3.Connection:
@contextmanager
def _get_db() -> Generator[sqlite3.Connection, None, None]:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS work_orders (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
priority TEXT DEFAULT 'medium',
category TEXT DEFAULT 'suggestion',
submitter TEXT DEFAULT 'dashboard',
related_files TEXT DEFAULT '',
status TEXT DEFAULT 'submitted',
result TEXT DEFAULT '',
rejection_reason TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
completed_at TEXT
)
""")
conn.commit()
return conn
with closing(sqlite3.connect(str(DB_PATH))) as conn:
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS work_orders (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
priority TEXT DEFAULT 'medium',
category TEXT DEFAULT 'suggestion',
submitter TEXT DEFAULT 'dashboard',
related_files TEXT DEFAULT '',
status TEXT DEFAULT 'submitted',
result TEXT DEFAULT '',
rejection_reason TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
completed_at TEXT
)
""")
conn.commit()
yield conn
class _EnumLike:
@@ -104,14 +107,11 @@ def _query_wos(db, statuses):
@router.get("/work-orders/queue", response_class=HTMLResponse)
async def work_orders_page(request: Request):
db = _get_db()
try:
with _get_db() as db:
pending = _query_wos(db, ["submitted", "triaged"])
active = _query_wos(db, ["approved", "in_progress"])
completed = _query_wos(db, ["completed"])
rejected = _query_wos(db, ["rejected"])
finally:
db.close()
return templates.TemplateResponse(
request,
@@ -144,12 +144,11 @@ async def submit_work_order(
related_files: str = Form(""),
):
wo_id = str(uuid.uuid4())
now = datetime.utcnow().isoformat()
now = datetime.now(UTC).isoformat()
priority = priority if priority in PRIORITIES else "medium"
category = category if category in CATEGORIES else "suggestion"
db = _get_db()
try:
with _get_db() as db:
db.execute(
"INSERT INTO work_orders (id, title, description, priority, category, submitter, related_files, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
@@ -157,8 +156,6 @@ async def submit_work_order(
)
db.commit()
row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone()
finally:
db.close()
wo = _WOView(_row_to_dict(row))
return templates.TemplateResponse(request, "partials/work_order_card.html", {"wo": wo})
@@ -171,11 +168,8 @@ async def submit_work_order(
@router.get("/work-orders/queue/pending", response_class=HTMLResponse)
async def pending_partial(request: Request):
db = _get_db()
try:
with _get_db() as db:
wos = _query_wos(db, ["submitted", "triaged"])
finally:
db.close()
if not wos:
return HTMLResponse(
'<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">'
@@ -193,11 +187,8 @@ async def pending_partial(request: Request):
@router.get("/work-orders/queue/active", response_class=HTMLResponse)
async def active_partial(request: Request):
db = _get_db()
try:
with _get_db() as db:
wos = _query_wos(db, ["approved", "in_progress"])
finally:
db.close()
if not wos:
return HTMLResponse(
'<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">'
@@ -220,10 +211,9 @@ async def active_partial(request: Request):
async def _update_status(request: Request, wo_id: str, new_status: str, **extra):
completed_at = (
datetime.utcnow().isoformat() if new_status in ("completed", "rejected") else None
datetime.now(UTC).isoformat() if new_status in ("completed", "rejected") else None
)
db = _get_db()
try:
with _get_db() as db:
sets = ["status=?", "completed_at=COALESCE(?, completed_at)"]
vals = [new_status, completed_at]
for col, val in extra.items():
@@ -233,8 +223,6 @@ async def _update_status(request: Request, wo_id: str, new_status: str, **extra)
db.execute(f"UPDATE work_orders SET {', '.join(sets)} WHERE id=?", vals)
db.commit()
row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone()
finally:
db.close()
if not row:
raise HTTPException(404, "Work order not found")
wo = _WOView(_row_to_dict(row))

View File

@@ -0,0 +1,385 @@
"""Workshop world state API and WebSocket relay.
Serves Timmy's current presence state to the Workshop 3D renderer.
The primary consumer is the browser on first load — before any
WebSocket events arrive, the client needs a full state snapshot.
The ``/ws/world`` endpoint streams ``timmy_state`` messages whenever
the heartbeat detects a state change. It also accepts ``visitor_message``
frames from the 3D client and responds with ``timmy_speech`` barks.
Source of truth: ``~/.timmy/presence.json`` written by
:class:`~timmy.workshop_state.WorkshopHeartbeat`.
Falls back to a live ``get_state_dict()`` call if the file is stale
or missing.
"""
import asyncio
import json
import logging
import re
import time
from collections import deque
from datetime import UTC, datetime
from fastapi import APIRouter, WebSocket
from fastapi.responses import JSONResponse
from timmy.workshop_state import PRESENCE_FILE
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/world", tags=["world"])
# ---------------------------------------------------------------------------
# WebSocket relay for live state changes
# ---------------------------------------------------------------------------
_ws_clients: list[WebSocket] = []
_STALE_THRESHOLD = 90 # seconds — file older than this triggers live rebuild
# Recent conversation buffer — kept in memory for the Workshop overlay.
# Stores the last _MAX_EXCHANGES (visitor_text, timmy_text) pairs.
_MAX_EXCHANGES = 3
_conversation: deque[dict] = deque(maxlen=_MAX_EXCHANGES)
_WORKSHOP_SESSION_ID = "workshop"
_HEARTBEAT_INTERVAL = 15 # seconds — ping to detect dead iPad/Safari connections
# ---------------------------------------------------------------------------
# Conversation grounding — commitment tracking (rescued from PR #408)
# ---------------------------------------------------------------------------
# Patterns that indicate Timmy is committing to an action.
_COMMITMENT_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"I'll (.+?)(?:\.|!|\?|$)", re.IGNORECASE),
re.compile(r"I will (.+?)(?:\.|!|\?|$)", re.IGNORECASE),
re.compile(r"[Ll]et me (.+?)(?:\.|!|\?|$)", re.IGNORECASE),
]
# After this many messages without follow-up, surface open commitments.
_REMIND_AFTER = 5
_MAX_COMMITMENTS = 10
# In-memory list of open commitments.
# Each entry: {"text": str, "created_at": float, "messages_since": int}
_commitments: list[dict] = []
def _extract_commitments(text: str) -> list[str]:
"""Pull commitment phrases from Timmy's reply text."""
found: list[str] = []
for pattern in _COMMITMENT_PATTERNS:
for match in pattern.finditer(text):
phrase = match.group(1).strip()
if len(phrase) > 5: # skip trivially short matches
found.append(phrase[:120])
return found
def _record_commitments(reply: str) -> None:
"""Scan a Timmy reply for commitments and store them."""
for phrase in _extract_commitments(reply):
# Avoid near-duplicate commitments
if any(c["text"] == phrase for c in _commitments):
continue
_commitments.append({"text": phrase, "created_at": time.time(), "messages_since": 0})
if len(_commitments) > _MAX_COMMITMENTS:
_commitments.pop(0)
def _tick_commitments() -> None:
"""Increment messages_since for every open commitment."""
for c in _commitments:
c["messages_since"] += 1
def _build_commitment_context() -> str:
"""Return a grounding note if any commitments are overdue for follow-up."""
overdue = [c for c in _commitments if c["messages_since"] >= _REMIND_AFTER]
if not overdue:
return ""
lines = [f"- {c['text']}" for c in overdue]
return (
"[Open commitments Timmy made earlier — "
"weave awareness naturally, don't list robotically]\n" + "\n".join(lines)
)
def close_commitment(index: int) -> bool:
"""Remove a commitment by index. Returns True if removed."""
if 0 <= index < len(_commitments):
_commitments.pop(index)
return True
return False
def get_commitments() -> list[dict]:
"""Return a copy of open commitments (for testing / API)."""
return list(_commitments)
def reset_commitments() -> None:
"""Clear all commitments (for testing / session reset)."""
_commitments.clear()
# Conversation grounding — anchor to opening topic so Timmy doesn't drift.
_ground_topic: str | None = None
_ground_set_at: float = 0.0
_GROUND_TTL = 300 # seconds of inactivity before the anchor expires
def _read_presence_file() -> dict | None:
"""Read presence.json if it exists and is fresh enough."""
try:
if not PRESENCE_FILE.exists():
return None
age = time.time() - PRESENCE_FILE.stat().st_mtime
if age > _STALE_THRESHOLD:
logger.debug("presence.json is stale (%.0fs old)", age)
return None
return json.loads(PRESENCE_FILE.read_text())
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Failed to read presence.json: %s", exc)
return 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),
}
def _get_current_state() -> dict:
"""Build the current world-state dict from best available source."""
presence = _read_presence_file()
if presence is None:
try:
from timmy.workshop_state import get_state_dict
presence = get_state_dict()
except Exception as exc:
logger.warning("Live state build failed: %s", exc)
presence = {
"version": 1,
"liveness": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
"mood": "calm",
"current_focus": "",
"active_threads": [],
"recent_events": [],
"concerns": [],
}
return _build_world_state(presence)
@router.get("/state")
async def get_world_state() -> JSONResponse:
"""Return Timmy's current world state for Workshop bootstrap.
Reads from ``~/.timmy/presence.json`` if fresh, otherwise
rebuilds live from cognitive state.
"""
return JSONResponse(
content=_get_current_state(),
headers={"Cache-Control": "no-cache, no-store"},
)
# ---------------------------------------------------------------------------
# WebSocket endpoint — streams timmy_state changes to Workshop clients
# ---------------------------------------------------------------------------
async def _heartbeat(websocket: WebSocket) -> None:
"""Send periodic pings to detect dead connections (iPad resilience).
Safari suspends background tabs, killing the TCP socket silently.
A 15-second ping ensures we notice within one interval.
Rescued from stale PR #399.
"""
try:
while True:
await asyncio.sleep(_HEARTBEAT_INTERVAL)
await websocket.send_text(json.dumps({"type": "ping"}))
except Exception:
logger.debug("Heartbeat stopped — connection gone")
@router.websocket("/ws")
async def world_ws(websocket: WebSocket) -> None:
"""Accept a Workshop client and keep it alive for state broadcasts.
Sends a full ``world_state`` snapshot immediately on connect so the
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.
"""
await websocket.accept()
_ws_clients.append(websocket)
logger.info("World WS connected — %d clients", len(_ws_clients))
# Send full world-state snapshot so client bootstraps instantly
try:
snapshot = _get_current_state()
await websocket.send_text(json.dumps({"type": "world_state", **snapshot}))
except Exception as exc:
logger.warning("Failed to send WS snapshot: %s", exc)
ping_task = asyncio.create_task(_heartbeat(websocket))
try:
while True:
raw = await websocket.receive_text()
await _handle_client_message(raw)
except Exception:
logger.debug("WebSocket receive loop ended")
finally:
ping_task.cancel()
if websocket in _ws_clients:
_ws_clients.remove(websocket)
logger.info("World WS disconnected — %d clients", len(_ws_clients))
async def _broadcast(message: str) -> None:
"""Send *message* to every connected Workshop client, pruning dead ones."""
dead: list[WebSocket] = []
for ws in _ws_clients:
try:
await ws.send_text(message)
except Exception:
logger.debug("Pruning dead WebSocket client")
dead.append(ws)
for ws in dead:
if ws in _ws_clients:
_ws_clients.remove(ws)
async def broadcast_world_state(presence: dict) -> None:
"""Broadcast a ``timmy_state`` message to all connected Workshop clients.
Called by :class:`~timmy.workshop_state.WorkshopHeartbeat` via its
``on_change`` callback.
"""
state = _build_world_state(presence)
await _broadcast(json.dumps({"type": "timmy_state", **state["timmyState"]}))
# ---------------------------------------------------------------------------
# Visitor chat — bark engine
# ---------------------------------------------------------------------------
async def _handle_client_message(raw: str) -> None:
"""Dispatch an incoming WebSocket frame from the Workshop client."""
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return # ignore non-JSON keep-alive pings
if data.get("type") == "visitor_message":
text = (data.get("text") or "").strip()
if text:
task = asyncio.create_task(_bark_and_broadcast(text))
task.add_done_callback(_log_bark_failure)
def _log_bark_failure(task: asyncio.Task) -> None:
"""Log unhandled exceptions from fire-and-forget bark tasks."""
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.error("Bark task failed: %s", exc)
def reset_conversation_ground() -> None:
"""Clear the conversation grounding anchor (e.g. after inactivity)."""
global _ground_topic, _ground_set_at
_ground_topic = None
_ground_set_at = 0.0
def _refresh_ground(visitor_text: str) -> None:
"""Set or refresh the conversation grounding anchor.
The first visitor message in a session (or after the TTL expires)
becomes the anchor topic. Subsequent messages are grounded against it.
"""
global _ground_topic, _ground_set_at
now = time.time()
if _ground_topic is None or (now - _ground_set_at) > _GROUND_TTL:
_ground_topic = visitor_text[:120]
logger.debug("Ground topic set: %s", _ground_topic)
_ground_set_at = now
async def _bark_and_broadcast(visitor_text: str) -> None:
"""Generate a bark response and broadcast it to all Workshop clients."""
await _broadcast(json.dumps({"type": "timmy_thinking"}))
# Notify Pip that a visitor spoke
try:
from timmy.familiar import pip_familiar
pip_familiar.on_event("visitor_spoke")
except Exception:
logger.debug("Pip familiar notification failed (optional)")
_refresh_ground(visitor_text)
_tick_commitments()
reply = await _generate_bark(visitor_text)
_record_commitments(reply)
_conversation.append({"visitor": visitor_text, "timmy": reply})
await _broadcast(
json.dumps(
{
"type": "timmy_speech",
"text": reply,
"recentExchanges": list(_conversation),
}
)
)
async def _generate_bark(visitor_text: str) -> str:
"""Generate a short in-character bark response.
Uses the existing Timmy session with a dedicated workshop session ID.
When a grounding anchor exists, the opening topic is prepended so the
model stays on-topic across long sessions.
Gracefully degrades to a canned response if inference fails.
"""
try:
from timmy import session as _session
grounded = visitor_text
commitment_ctx = _build_commitment_context()
if commitment_ctx:
grounded = f"{commitment_ctx}\n{grounded}"
if _ground_topic and visitor_text != _ground_topic:
grounded = f"[Workshop conversation topic: {_ground_topic}]\n{grounded}"
response = await _session.chat(grounded, session_id=_WORKSHOP_SESSION_ID)
return response
except Exception as exc:
logger.warning("Bark generation failed: %s", exc)
return "Hmm, my thoughts are a bit tangled right now."

View File

@@ -1,34 +1,5 @@
from dataclasses import dataclass
"""Backward-compatible re-export — canonical home is infrastructure.chat_store."""
from infrastructure.chat_store import DB_PATH, MAX_MESSAGES, Message, MessageLog, message_log
@dataclass
class Message:
role: str # "user" | "agent" | "error"
content: str
timestamp: str
source: str = "browser" # "browser" | "api" | "telegram" | "discord" | "system"
class MessageLog:
"""In-memory chat history for the lifetime of the server process."""
def __init__(self) -> None:
self._entries: list[Message] = []
def append(self, role: str, content: str, timestamp: str, source: str = "browser") -> None:
self._entries.append(
Message(role=role, content=content, timestamp=timestamp, source=source)
)
def all(self) -> list[Message]:
return list(self._entries)
def clear(self) -> None:
self._entries.clear()
def __len__(self) -> int:
return len(self._entries)
# Module-level singleton shared across the app
message_log = MessageLog()
__all__ = ["DB_PATH", "MAX_MESSAGES", "Message", "MessageLog", "message_log"]

View File

@@ -327,7 +327,11 @@
.then(function(data) {
var list = document.getElementById('notif-list');
if (!data.length) {
list.innerHTML = '<div class="mc-notif-empty">No recent notifications</div>';
list.innerHTML = '';
var emptyDiv = document.createElement('div');
emptyDiv.className = 'mc-notif-empty';
emptyDiv.textContent = 'No recent notifications';
list.appendChild(emptyDiv);
return;
}
list.innerHTML = '';

View File

@@ -138,6 +138,47 @@
</div>
</div>
<!-- Spark Intelligence -->
{% from "macros.html" import panel %}
<div class="mc-card-spaced">
<div class="card">
<div class="card-header">
<h2 class="card-title">Spark Intelligence</h2>
<div>
<span class="badge" id="spark-status-badge">Loading...</span>
</div>
</div>
<div class="grid grid-3">
<div class="stat">
<div class="stat-value" id="spark-events">-</div>
<div class="stat-label">Events</div>
</div>
<div class="stat">
<div class="stat-value" id="spark-memories">-</div>
<div class="stat-label">Memories</div>
</div>
<div class="stat">
<div class="stat-value" id="spark-predictions">-</div>
<div class="stat-label">Predictions</div>
</div>
</div>
</div>
<div class="grid grid-2 mc-section-gap">
{% call panel("SPARK TIMELINE", id="spark-timeline-panel",
hx_get="/spark/timeline",
hx_trigger="load, every 10s") %}
<div class="spark-timeline-scroll">
<p class="chat-history-placeholder">Loading timeline...</p>
</div>
{% endcall %}
{% call panel("SPARK INSIGHTS", id="spark-insights-panel",
hx_get="/spark/insights",
hx_trigger="load, every 30s") %}
<p class="chat-history-placeholder">Loading insights...</p>
{% endcall %}
</div>
</div>
<!-- Chat History -->
<div class="card mc-card-spaced">
<div class="card-header">
@@ -428,7 +469,34 @@ async function loadGrokStats() {
}
}
// Load Spark status
async function loadSparkStatus() {
try {
var response = await fetch('/spark');
var data = await response.json();
var st = data.status || {};
document.getElementById('spark-events').textContent = st.total_events || 0;
document.getElementById('spark-memories').textContent = st.total_memories || 0;
document.getElementById('spark-predictions').textContent = st.total_predictions || 0;
var badge = document.getElementById('spark-status-badge');
if (st.total_events > 0) {
badge.textContent = 'Active';
badge.className = 'badge badge-success';
} else {
badge.textContent = 'Idle';
badge.className = 'badge badge-warning';
}
} catch (error) {
var badge = document.getElementById('spark-status-badge');
badge.textContent = 'Offline';
badge.className = 'badge badge-danger';
}
}
// Initial load
loadSparkStatus();
loadSovereignty();
loadHealth();
loadSwarmStats();
@@ -442,5 +510,6 @@ setInterval(loadHealth, 10000);
setInterval(loadSwarmStats, 5000);
setInterval(updateHeartbeat, 5000);
setInterval(loadGrokStats, 10000);
setInterval(loadSparkStatus, 15000);
</script>
{% endblock %}

View File

@@ -120,14 +120,17 @@
function updateFromData(data) {
if (data.is_working && data.current_task) {
statusEl.innerHTML = '<span style="color: #ffaa00;">working...</span>';
statusEl.textContent = 'working...';
statusEl.style.color = '#ffaa00';
banner.style.display = 'block';
taskTitle.textContent = data.current_task.title;
} else if (data.tasks_ahead > 0) {
statusEl.innerHTML = '<span style="color: #888;">queue: ' + data.tasks_ahead + ' ahead</span>';
statusEl.textContent = 'queue: ' + data.tasks_ahead + ' ahead';
statusEl.style.color = '#888';
banner.style.display = 'none';
} else {
statusEl.innerHTML = '<span style="color: #00ff88;">ready</span>';
statusEl.textContent = 'ready';
statusEl.style.color = '#00ff88';
banner.style.display = 'none';
}
}

View File

@@ -20,7 +20,7 @@
{% else %}
<div class="chat-message agent">
<div class="msg-meta">TIMMY // SYSTEM</div>
<div class="msg-body">Mission Control initialized. Timmy ready — awaiting input.</div>
<div class="msg-body">{{ welcome_message | e }}</div>
</div>
{% endif %}
<script>if(typeof scrollChat==='function'){setTimeout(scrollChat,50);}</script>

View File

@@ -198,17 +198,43 @@ function addActivityEvent(evt) {
} catch(e) {}
}
item.innerHTML = `
<div class="activity-icon">${icon}</div>
<div class="activity-content">
<div class="activity-label">${label}</div>
${desc ? `<div class="activity-desc">${desc}</div>` : ''}
<div class="activity-meta">
<span class="activity-time">${time}</span>
<span class="activity-source">${evt.source || 'system'}</span>
</div>
</div>
`;
// Build DOM safely using createElement and textContent
var iconDiv = document.createElement('div');
iconDiv.className = 'activity-icon';
iconDiv.textContent = icon;
var contentDiv = document.createElement('div');
contentDiv.className = 'activity-content';
var labelDiv = document.createElement('div');
labelDiv.className = 'activity-label';
labelDiv.textContent = label;
contentDiv.appendChild(labelDiv);
if (desc) {
var descDiv = document.createElement('div');
descDiv.className = 'activity-desc';
descDiv.textContent = desc;
contentDiv.appendChild(descDiv);
}
var metaDiv = document.createElement('div');
metaDiv.className = 'activity-meta';
var timeSpan = document.createElement('span');
timeSpan.className = 'activity-time';
timeSpan.textContent = time;
var sourceSpan = document.createElement('span');
sourceSpan.className = 'activity-source';
sourceSpan.textContent = evt.source || 'system';
metaDiv.appendChild(timeSpan);
metaDiv.appendChild(sourceSpan);
contentDiv.appendChild(metaDiv);
item.appendChild(iconDiv);
item.appendChild(contentDiv);
// Add to top
container.insertBefore(item, container.firstChild);

View File

@@ -0,0 +1,180 @@
{% extends "base.html" %}
{% block title %}Timmy Time — Tower{% endblock %}
{% block extra_styles %}{% endblock %}
{% block content %}
<div class="container-fluid tower-container py-3">
<div class="tower-header">
<div class="tower-title">TOWER</div>
<div class="tower-subtitle">
Real-time Spark visualization &mdash;
<span id="tower-conn" class="tower-conn-badge tower-conn-connecting">CONNECTING</span>
</div>
</div>
<div class="row g-3">
<!-- Left: THINKING (events) -->
<div class="col-12 col-lg-4 d-flex flex-column gap-3">
<div class="card mc-panel tower-phase-card">
<div class="card-header mc-panel-header tower-phase-thinking">// THINKING</div>
<div class="card-body p-3 tower-scroll" id="tower-events">
<div class="tower-empty">Waiting for Spark data&hellip;</div>
</div>
</div>
</div>
<!-- Middle: PREDICTING (EIDOS) -->
<div class="col-12 col-lg-4 d-flex flex-column gap-3">
<div class="card mc-panel tower-phase-card">
<div class="card-header mc-panel-header tower-phase-predicting">// PREDICTING</div>
<div class="card-body p-3" id="tower-predictions">
<div class="tower-empty">Waiting for Spark data&hellip;</div>
</div>
</div>
<div class="card mc-panel">
<div class="card-header mc-panel-header">// EIDOS STATS</div>
<div class="card-body p-3">
<div class="tower-stat-grid" id="tower-stats">
<div class="tower-stat"><span class="tower-stat-label">EVENTS</span><span class="tower-stat-value" id="ts-events">0</span></div>
<div class="tower-stat"><span class="tower-stat-label">MEMORIES</span><span class="tower-stat-value" id="ts-memories">0</span></div>
<div class="tower-stat"><span class="tower-stat-label">PREDICTIONS</span><span class="tower-stat-value" id="ts-preds">0</span></div>
<div class="tower-stat"><span class="tower-stat-label">ACCURACY</span><span class="tower-stat-value" id="ts-accuracy"></span></div>
</div>
</div>
</div>
</div>
<!-- Right: ADVISING -->
<div class="col-12 col-lg-4 d-flex flex-column gap-3">
<div class="card mc-panel tower-phase-card">
<div class="card-header mc-panel-header tower-phase-advising">// ADVISING</div>
<div class="card-body p-3 tower-scroll" id="tower-advisories">
<div class="tower-empty">Waiting for Spark data&hellip;</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
var ws = null;
var badge = document.getElementById('tower-conn');
function setConn(state) {
badge.textContent = state.toUpperCase();
badge.className = 'tower-conn-badge tower-conn-' + state;
}
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function renderEvents(events) {
var el = document.getElementById('tower-events');
if (!events || !events.length) { el.innerHTML = '<div class="tower-empty">No events captured yet.</div>'; return; }
var html = '';
for (var i = 0; i < events.length; i++) {
var ev = events[i];
var dots = ev.importance >= 0.8 ? '\u25cf\u25cf\u25cf' : ev.importance >= 0.5 ? '\u25cf\u25cf' : '\u25cf';
html += '<div class="tower-event tower-etype-' + esc(ev.event_type) + '">'
+ '<div class="tower-ev-head">'
+ '<span class="tower-ev-badge">' + esc(ev.event_type.replace(/_/g, ' ').toUpperCase()) + '</span>'
+ '<span class="tower-ev-dots">' + dots + '</span>'
+ '</div>'
+ '<div class="tower-ev-desc">' + esc(ev.description) + '</div>'
+ '<div class="tower-ev-time">' + esc((ev.created_at || '').slice(0, 19)) + '</div>'
+ '</div>';
}
el.innerHTML = html;
}
function renderPredictions(preds) {
var el = document.getElementById('tower-predictions');
if (!preds || !preds.length) { el.innerHTML = '<div class="tower-empty">No predictions yet.</div>'; return; }
var html = '';
for (var i = 0; i < preds.length; i++) {
var p = preds[i];
var cls = p.evaluated ? 'tower-pred-done' : 'tower-pred-pending';
var accTxt = p.accuracy != null ? Math.round(p.accuracy * 100) + '%' : 'PENDING';
var accCls = p.accuracy != null ? (p.accuracy >= 0.7 ? 'text-success' : p.accuracy < 0.4 ? 'text-danger' : 'text-warning') : '';
html += '<div class="tower-pred ' + cls + '">'
+ '<div class="tower-pred-head">'
+ '<span class="tower-pred-task">' + esc(p.task_id) + '</span>'
+ '<span class="tower-pred-acc ' + accCls + '">' + accTxt + '</span>'
+ '</div>';
if (p.predicted) {
var pr = p.predicted;
html += '<div class="tower-pred-detail">';
if (pr.likely_winner) html += '<span>Winner: ' + esc(pr.likely_winner.slice(0, 8)) + '</span> ';
if (pr.success_probability != null) html += '<span>Success: ' + Math.round(pr.success_probability * 100) + '%</span> ';
html += '</div>';
}
html += '<div class="tower-ev-time">' + esc((p.created_at || '').slice(0, 19)) + '</div>'
+ '</div>';
}
el.innerHTML = html;
}
function renderAdvisories(advs) {
var el = document.getElementById('tower-advisories');
if (!advs || !advs.length) { el.innerHTML = '<div class="tower-empty">No advisories yet.</div>'; return; }
var html = '';
for (var i = 0; i < advs.length; i++) {
var a = advs[i];
var prio = a.priority >= 0.7 ? 'high' : a.priority >= 0.4 ? 'medium' : 'low';
html += '<div class="tower-advisory tower-adv-' + prio + '">'
+ '<div class="tower-adv-head">'
+ '<span class="tower-adv-cat">' + esc(a.category.replace(/_/g, ' ').toUpperCase()) + '</span>'
+ '<span class="tower-adv-prio">' + Math.round(a.priority * 100) + '%</span>'
+ '</div>'
+ '<div class="tower-adv-title">' + esc(a.title) + '</div>'
+ '<div class="tower-adv-detail">' + esc(a.detail) + '</div>'
+ '<div class="tower-adv-action">' + esc(a.suggested_action) + '</div>'
+ '</div>';
}
el.innerHTML = html;
}
function renderStats(status) {
if (!status) return;
document.getElementById('ts-events').textContent = status.events_captured || 0;
document.getElementById('ts-memories').textContent = status.memories_stored || 0;
var p = status.predictions || {};
document.getElementById('ts-preds').textContent = p.total_predictions || 0;
var acc = p.avg_accuracy;
var accEl = document.getElementById('ts-accuracy');
if (acc != null) {
accEl.textContent = Math.round(acc * 100) + '%';
accEl.className = 'tower-stat-value ' + (acc >= 0.7 ? 'text-success' : acc < 0.4 ? 'text-danger' : 'text-warning');
} else {
accEl.textContent = '\u2014';
}
}
function handleMsg(data) {
if (data.type !== 'spark_state') return;
renderEvents(data.events);
renderPredictions(data.predictions);
renderAdvisories(data.advisories);
renderStats(data.status);
}
function connect() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/tower/ws');
ws.onopen = function() { setConn('live'); };
ws.onclose = function() { setConn('offline'); setTimeout(connect, 3000); };
ws.onerror = function() { setConn('offline'); };
ws.onmessage = function(e) {
try { handleMsg(JSON.parse(e.data)); } catch(err) { console.error('Tower WS parse error', err); }
};
}
connect();
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,153 @@
"""Persistent chat message store backed by SQLite.
Provides the same API as the original in-memory MessageLog so all callers
(dashboard routes, chat_api, thinking, briefing) work without changes.
Data lives in ``data/chat.db`` — survives server restarts.
A configurable retention policy (default 500 messages) keeps the DB lean.
"""
import sqlite3
import threading
from collections.abc import Generator
from contextlib import closing, contextmanager
from dataclasses import dataclass
from pathlib import Path
# ── Data dir — resolved relative to repo root (three levels up from this file) ──
_REPO_ROOT = Path(__file__).resolve().parents[3]
DB_PATH: Path = _REPO_ROOT / "data" / "chat.db"
# Maximum messages to retain (oldest pruned on append)
MAX_MESSAGES: int = 500
@dataclass
class Message:
role: str # "user" | "agent" | "error"
content: str
timestamp: str
source: str = "browser" # "browser" | "api" | "telegram" | "discord" | "system"
@contextmanager
def _get_conn(db_path: Path | None = None) -> Generator[sqlite3.Connection, None, None]:
"""Open (or create) the chat database and ensure schema exists."""
path = db_path or DB_PATH
path.parent.mkdir(parents=True, exist_ok=True)
with closing(sqlite3.connect(str(path), check_same_thread=False)) as conn:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'browser'
)
""")
conn.commit()
yield conn
class MessageLog:
"""SQLite-backed chat history — drop-in replacement for the old in-memory list."""
def __init__(self, db_path: Path | None = None) -> None:
self._db_path = db_path or DB_PATH
self._lock = threading.Lock()
self._conn: sqlite3.Connection | None = None
# Lazy connection — opened on first use, not at import time.
def _ensure_conn(self) -> sqlite3.Connection:
if self._conn is None:
# Open a persistent connection for the class instance
path = self._db_path or DB_PATH
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'browser'
)
""")
conn.commit()
self._conn = conn
return self._conn
def append(self, role: str, content: str, timestamp: str, source: str = "browser") -> None:
with self._lock:
conn = self._ensure_conn()
conn.execute(
"INSERT INTO chat_messages (role, content, timestamp, source) VALUES (?, ?, ?, ?)",
(role, content, timestamp, source),
)
conn.commit()
self._prune(conn)
def all(self) -> list[Message]:
with self._lock:
conn = self._ensure_conn()
rows = conn.execute(
"SELECT role, content, timestamp, source FROM chat_messages ORDER BY id"
).fetchall()
return [
Message(
role=r["role"], content=r["content"], timestamp=r["timestamp"], source=r["source"]
)
for r in rows
]
def recent(self, limit: int = 50) -> list[Message]:
"""Return the *limit* most recent messages (oldest-first)."""
with self._lock:
conn = self._ensure_conn()
rows = conn.execute(
"SELECT role, content, timestamp, source FROM chat_messages "
"ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [
Message(
role=r["role"], content=r["content"], timestamp=r["timestamp"], source=r["source"]
)
for r in reversed(rows)
]
def clear(self) -> None:
with self._lock:
conn = self._ensure_conn()
conn.execute("DELETE FROM chat_messages")
conn.commit()
def _prune(self, conn: sqlite3.Connection) -> None:
"""Keep at most MAX_MESSAGES rows, deleting the oldest."""
count = conn.execute("SELECT COUNT(*) FROM chat_messages").fetchone()[0]
if count > MAX_MESSAGES:
excess = count - MAX_MESSAGES
conn.execute(
"DELETE FROM chat_messages WHERE id IN "
"(SELECT id FROM chat_messages ORDER BY id LIMIT ?)",
(excess,),
)
conn.commit()
def close(self) -> None:
if self._conn is not None:
self._conn.close()
self._conn = None
def __len__(self) -> int:
with self._lock:
conn = self._ensure_conn()
return conn.execute("SELECT COUNT(*) FROM chat_messages").fetchone()[0]
# Module-level singleton shared across the app
message_log = MessageLog()

View File

@@ -22,6 +22,14 @@ logger = logging.getLogger(__name__)
# In-memory dedup cache: hash -> last_seen timestamp
_dedup_cache: dict[str, datetime] = {}
_error_recorder = None
def register_error_recorder(fn):
"""Register a callback for recording errors to session log."""
global _error_recorder
_error_recorder = fn
def _stack_hash(exc: Exception) -> str:
"""Create a stable hash of the exception type + traceback locations.
@@ -87,10 +95,177 @@ def _get_git_context() -> dict:
).stdout.strip()
return {"branch": branch, "commit": commit}
except Exception:
except Exception as exc:
logger.warning("Git info capture error: %s", exc)
return {"branch": "unknown", "commit": "unknown"}
def _extract_traceback_info(exc: Exception) -> tuple[str, str, int]:
"""Extract formatted traceback, affected file, and line number.
Returns:
Tuple of (traceback_string, affected_file, affected_line).
"""
tb_str = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
tb_obj = exc.__traceback__
affected_file = "unknown"
affected_line = 0
while tb_obj and tb_obj.tb_next:
tb_obj = tb_obj.tb_next
if tb_obj:
affected_file = tb_obj.tb_frame.f_code.co_filename
affected_line = tb_obj.tb_lineno
return tb_str, affected_file, affected_line
def _log_error_event(
exc: Exception,
source: str,
error_hash: str,
affected_file: str,
affected_line: int,
git_ctx: dict,
) -> None:
"""Log the captured error to the event log."""
try:
from swarm.event_log import EventType, log_event
log_event(
EventType.ERROR_CAPTURED,
source=source,
data={
"error_type": type(exc).__name__,
"message": str(exc)[:500],
"hash": error_hash,
"file": affected_file,
"line": affected_line,
"git_branch": git_ctx.get("branch", ""),
"git_commit": git_ctx.get("commit", ""),
},
)
except Exception as log_exc:
logger.debug("Failed to log error event: %s", log_exc)
def _build_report_description(
exc: Exception,
source: str,
context: dict | None,
error_hash: str,
tb_str: str,
affected_file: str,
affected_line: int,
git_ctx: dict,
) -> str:
"""Build the markdown description for a bug report task."""
parts = [
f"**Error:** {type(exc).__name__}: {str(exc)}",
f"**Source:** {source}",
f"**File:** {affected_file}:{affected_line}",
f"**Git:** {git_ctx.get('branch', '?')} @ {git_ctx.get('commit', '?')}",
f"**Time:** {datetime.now(UTC).isoformat()}",
f"**Hash:** {error_hash}",
]
if context:
ctx_str = ", ".join(f"{k}={v}" for k, v in context.items())
parts.append(f"**Context:** {ctx_str}")
parts.append(f"\n**Stack Trace:**\n```\n{tb_str[:2000]}\n```")
return "\n".join(parts)
def _log_bug_report_created(source: str, task_id: str, error_hash: str, title: str) -> None:
"""Log a BUG_REPORT_CREATED event (best-effort)."""
try:
from swarm.event_log import EventType, log_event
log_event(
EventType.BUG_REPORT_CREATED,
source=source,
task_id=task_id,
data={
"error_hash": error_hash,
"title": title[:100],
},
)
except Exception as exc:
logger.warning("Bug report event log error: %s", exc)
def _create_bug_report(
exc: Exception,
source: str,
context: dict | None,
error_hash: str,
tb_str: str,
affected_file: str,
affected_line: int,
git_ctx: dict,
) -> str | None:
"""Create a bug report task and return the task ID (or None on failure)."""
try:
from swarm.task_queue.models import create_task
title = f"[BUG] {type(exc).__name__}: {str(exc)[:80]}"
description = _build_report_description(
exc,
source,
context,
error_hash,
tb_str,
affected_file,
affected_line,
git_ctx,
)
task = create_task(
title=title,
description=description,
assigned_to="default",
created_by="system",
priority="normal",
requires_approval=False,
auto_approve=True,
task_type="bug_report",
)
_log_bug_report_created(source, task.id, error_hash, title)
return task.id
except Exception as task_exc:
logger.debug("Failed to create bug report task: %s", task_exc)
return None
def _notify_bug_report(exc: Exception, source: str) -> None:
"""Send a push notification about the captured error."""
try:
from infrastructure.notifications.push import notifier
notifier.notify(
title="Bug Report Filed",
message=f"{type(exc).__name__} in {source}: {str(exc)[:80]}",
category="system",
)
except Exception as notify_exc:
logger.warning("Bug report notification error: %s", notify_exc)
def _record_to_session(exc: Exception, source: str) -> None:
"""Record the error via the registered session callback."""
if _error_recorder is not None:
try:
_error_recorder(
error=f"{type(exc).__name__}: {str(exc)}",
context=source,
)
except Exception as log_exc:
logger.warning("Bug report session logging error: %s", log_exc)
def capture_error(
exc: Exception,
source: str = "unknown",
@@ -117,116 +292,23 @@ def capture_error(
logger.debug("Duplicate error suppressed: %s (hash=%s)", exc, error_hash)
return None
# Format the stack trace
tb_str = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
# Extract file/line from traceback
tb_obj = exc.__traceback__
affected_file = "unknown"
affected_line = 0
while tb_obj and tb_obj.tb_next:
tb_obj = tb_obj.tb_next
if tb_obj:
affected_file = tb_obj.tb_frame.f_code.co_filename
affected_line = tb_obj.tb_lineno
tb_str, affected_file, affected_line = _extract_traceback_info(exc)
git_ctx = _get_git_context()
# 1. Log to event_log
try:
from swarm.event_log import EventType, log_event
_log_error_event(exc, source, error_hash, affected_file, affected_line, git_ctx)
log_event(
EventType.ERROR_CAPTURED,
source=source,
data={
"error_type": type(exc).__name__,
"message": str(exc)[:500],
"hash": error_hash,
"file": affected_file,
"line": affected_line,
"git_branch": git_ctx.get("branch", ""),
"git_commit": git_ctx.get("commit", ""),
},
)
except Exception as log_exc:
logger.debug("Failed to log error event: %s", log_exc)
task_id = _create_bug_report(
exc,
source,
context,
error_hash,
tb_str,
affected_file,
affected_line,
git_ctx,
)
# 2. Create bug report task
task_id = None
try:
from swarm.task_queue.models import create_task
title = f"[BUG] {type(exc).__name__}: {str(exc)[:80]}"
description_parts = [
f"**Error:** {type(exc).__name__}: {str(exc)}",
f"**Source:** {source}",
f"**File:** {affected_file}:{affected_line}",
f"**Git:** {git_ctx.get('branch', '?')} @ {git_ctx.get('commit', '?')}",
f"**Time:** {datetime.now(UTC).isoformat()}",
f"**Hash:** {error_hash}",
]
if context:
ctx_str = ", ".join(f"{k}={v}" for k, v in context.items())
description_parts.append(f"**Context:** {ctx_str}")
description_parts.append(f"\n**Stack Trace:**\n```\n{tb_str[:2000]}\n```")
task = create_task(
title=title,
description="\n".join(description_parts),
assigned_to="default",
created_by="system",
priority="normal",
requires_approval=False,
auto_approve=True,
task_type="bug_report",
)
task_id = task.id
# Log the creation event
try:
from swarm.event_log import EventType, log_event
log_event(
EventType.BUG_REPORT_CREATED,
source=source,
task_id=task_id,
data={
"error_hash": error_hash,
"title": title[:100],
},
)
except Exception:
pass
except Exception as task_exc:
logger.debug("Failed to create bug report task: %s", task_exc)
# 3. Send notification
try:
from infrastructure.notifications.push import notifier
notifier.notify(
title="Bug Report Filed",
message=f"{type(exc).__name__} in {source}: {str(exc)[:80]}",
category="system",
)
except Exception:
pass
# 4. Record in session logger
try:
from timmy.session_logger import get_session_logger
session_logger = get_session_logger()
session_logger.record_error(
error=f"{type(exc).__name__}: {str(exc)}",
context=source,
)
except Exception:
pass
_notify_bug_report(exc, source)
_record_to_session(exc, source)
return task_id

View File

@@ -1,193 +0,0 @@
"""Event Broadcaster - bridges event_log to WebSocket clients.
When events are logged, they are broadcast to all connected dashboard clients
via WebSocket for real-time activity feed updates.
"""
import asyncio
import logging
from typing import Optional
try:
from swarm.event_log import EventLogEntry
except ImportError:
EventLogEntry = None
logger = logging.getLogger(__name__)
class EventBroadcaster:
"""Broadcasts events to WebSocket clients.
Usage:
from infrastructure.events.broadcaster import event_broadcaster
event_broadcaster.broadcast(event)
"""
def __init__(self) -> None:
self._ws_manager: Optional = None
def _get_ws_manager(self):
"""Lazy import to avoid circular deps."""
if self._ws_manager is None:
try:
from infrastructure.ws_manager.handler import ws_manager
self._ws_manager = ws_manager
except Exception as exc:
logger.debug("WebSocket manager not available: %s", exc)
return self._ws_manager
async def broadcast(self, event: EventLogEntry) -> int:
"""Broadcast an event to all connected WebSocket clients.
Args:
event: The event to broadcast
Returns:
Number of clients notified
"""
ws_manager = self._get_ws_manager()
if not ws_manager:
return 0
# Build message payload
payload = {
"type": "event",
"payload": {
"id": event.id,
"event_type": event.event_type.value,
"source": event.source,
"task_id": event.task_id,
"agent_id": event.agent_id,
"timestamp": event.timestamp,
"data": event.data,
},
}
try:
# Broadcast to all connected clients
count = await ws_manager.broadcast_json(payload)
logger.debug("Broadcasted event %s to %d clients", event.id[:8], count)
return count
except Exception as exc:
logger.error("Failed to broadcast event: %s", exc)
return 0
def broadcast_sync(self, event: EventLogEntry) -> None:
"""Synchronous wrapper for broadcast.
Use this from synchronous code - it schedules the async broadcast
in the event loop if one is running.
"""
try:
asyncio.get_running_loop()
# Schedule in background, don't wait
asyncio.create_task(self.broadcast(event))
except RuntimeError:
# No event loop running, skip broadcast
pass
# Global singleton
event_broadcaster = EventBroadcaster()
# Event type to icon/emoji mapping
EVENT_ICONS = {
"task.created": "📝",
"task.bidding": "",
"task.assigned": "👤",
"task.started": "▶️",
"task.completed": "",
"task.failed": "",
"agent.joined": "🟢",
"agent.left": "🔴",
"agent.status_changed": "🔄",
"bid.submitted": "💰",
"auction.closed": "🏁",
"tool.called": "🔧",
"tool.completed": "⚙️",
"tool.failed": "💥",
"system.error": "⚠️",
"system.warning": "🔶",
"system.info": "",
"error.captured": "🐛",
"bug_report.created": "📋",
}
EVENT_LABELS = {
"task.created": "New task",
"task.bidding": "Bidding open",
"task.assigned": "Task assigned",
"task.started": "Task started",
"task.completed": "Task completed",
"task.failed": "Task failed",
"agent.joined": "Agent joined",
"agent.left": "Agent left",
"agent.status_changed": "Status changed",
"bid.submitted": "Bid submitted",
"auction.closed": "Auction closed",
"tool.called": "Tool called",
"tool.completed": "Tool completed",
"tool.failed": "Tool failed",
"system.error": "Error",
"system.warning": "Warning",
"system.info": "Info",
"error.captured": "Error captured",
"bug_report.created": "Bug report filed",
}
def get_event_icon(event_type: str) -> str:
"""Get emoji icon for event type."""
return EVENT_ICONS.get(event_type, "")
def get_event_label(event_type: str) -> str:
"""Get human-readable label for event type."""
return EVENT_LABELS.get(event_type, event_type)
def format_event_for_display(event: EventLogEntry) -> dict:
"""Format event for display in activity feed.
Returns dict with display-friendly fields.
"""
data = event.data or {}
# Build description based on event type
description = ""
if event.event_type.value == "task.created":
desc = data.get("description", "")
description = desc[:60] + "..." if len(desc) > 60 else desc
elif event.event_type.value == "task.assigned":
agent = event.agent_id[:8] if event.agent_id else "unknown"
bid = data.get("bid_sats", "?")
description = f"to {agent} ({bid} sats)"
elif event.event_type.value == "bid.submitted":
bid = data.get("bid_sats", "?")
description = f"{bid} sats"
elif event.event_type.value == "agent.joined":
persona = data.get("persona_id", "")
description = f"Persona: {persona}" if persona else "New agent"
else:
# Generic: use any string data
for key in ["message", "reason", "description"]:
if key in data:
val = str(data[key])
description = val[:60] + "..." if len(val) > 60 else val
break
return {
"id": event.id,
"icon": get_event_icon(event.event_type.value),
"label": get_event_label(event.event_type.value),
"type": event.event_type.value,
"source": event.source,
"description": description,
"timestamp": event.timestamp,
"time_short": event.timestamp[11:19] if event.timestamp else "",
"task_id": event.task_id,
"agent_id": event.agent_id,
}

View File

@@ -9,7 +9,8 @@ import asyncio
import json
import logging
import sqlite3
from collections.abc import Callable, Coroutine
from collections.abc import Callable, Coroutine, Generator
from contextlib import closing, contextmanager
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
@@ -63,7 +64,7 @@ class EventBus:
@bus.subscribe("agent.task.*")
async def handle_task(event: Event):
print(f"Task event: {event.data}")
logger.debug("Task event: %s", event.data)
await bus.publish(Event(
type="agent.task.assigned",
@@ -99,51 +100,48 @@ class EventBus:
if self._persistence_db_path is None:
return
self._persistence_db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(self._persistence_db_path))
try:
with closing(sqlite3.connect(str(self._persistence_db_path))) as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.executescript(_EVENTS_SCHEMA)
conn.commit()
finally:
conn.close()
def _get_persistence_conn(self) -> sqlite3.Connection | None:
@contextmanager
def _get_persistence_conn(self) -> Generator[sqlite3.Connection | None, None, None]:
"""Get a connection to the persistence database."""
if self._persistence_db_path is None:
return None
conn = sqlite3.connect(str(self._persistence_db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
return conn
yield None
return
with closing(sqlite3.connect(str(self._persistence_db_path))) as conn:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
yield conn
def _persist_event(self, event: Event) -> None:
"""Write an event to the persistence database."""
conn = self._get_persistence_conn()
if conn is None:
return
try:
task_id = event.data.get("task_id", "")
agent_id = event.data.get("agent_id", "")
conn.execute(
"INSERT OR IGNORE INTO events "
"(id, event_type, source, task_id, agent_id, data, timestamp) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
event.id,
event.type,
event.source,
task_id,
agent_id,
json.dumps(event.data),
event.timestamp,
),
)
conn.commit()
except Exception as exc:
logger.debug("Failed to persist event: %s", exc)
finally:
conn.close()
with self._get_persistence_conn() as conn:
if conn is None:
return
try:
task_id = event.data.get("task_id", "")
agent_id = event.data.get("agent_id", "")
conn.execute(
"INSERT OR IGNORE INTO events "
"(id, event_type, source, task_id, agent_id, data, timestamp) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
event.id,
event.type,
event.source,
task_id,
agent_id,
json.dumps(event.data),
event.timestamp,
),
)
conn.commit()
except Exception as exc:
logger.debug("Failed to persist event: %s", exc)
# ── Replay ───────────────────────────────────────────────────────────
@@ -165,45 +163,43 @@ class EventBus:
Returns:
List of Event objects from persistent storage.
"""
conn = self._get_persistence_conn()
if conn is None:
return []
with self._get_persistence_conn() as conn:
if conn is None:
return []
try:
conditions = []
params: list = []
try:
conditions = []
params: list = []
if event_type:
conditions.append("event_type = ?")
params.append(event_type)
if source:
conditions.append("source = ?")
params.append(source)
if task_id:
conditions.append("task_id = ?")
params.append(task_id)
if event_type:
conditions.append("event_type = ?")
params.append(event_type)
if source:
conditions.append("source = ?")
params.append(source)
if task_id:
conditions.append("task_id = ?")
params.append(task_id)
where = " AND ".join(conditions) if conditions else "1=1"
sql = f"SELECT * FROM events WHERE {where} ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
where = " AND ".join(conditions) if conditions else "1=1"
sql = f"SELECT * FROM events WHERE {where} ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
rows = conn.execute(sql, params).fetchall()
rows = conn.execute(sql, params).fetchall()
return [
Event(
id=row["id"],
type=row["event_type"],
source=row["source"],
data=json.loads(row["data"]) if row["data"] else {},
timestamp=row["timestamp"],
)
for row in rows
]
except Exception as exc:
logger.debug("Failed to replay events: %s", exc)
return []
finally:
conn.close()
return [
Event(
id=row["id"],
type=row["event_type"],
source=row["source"],
data=json.loads(row["data"]) if row["data"] else {},
timestamp=row["timestamp"],
)
for row in rows
]
except Exception as exc:
logger.debug("Failed to replay events: %s", exc)
return []
# ── Subscribe / Publish ──────────────────────────────────────────────

View File

@@ -144,6 +144,65 @@ class ShellHand:
return None
@staticmethod
def _build_run_env(env: dict | None) -> dict:
"""Merge *env* overrides into a copy of the current environment."""
import os
run_env = os.environ.copy()
if env:
run_env.update(env)
return run_env
async def _execute_subprocess(
self,
command: str,
effective_timeout: int,
cwd: str | None,
run_env: dict,
start: float,
) -> ShellResult:
"""Run *command* as a subprocess with timeout enforcement."""
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=run_env,
)
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
proc.communicate(), timeout=effective_timeout
)
except TimeoutError:
proc.kill()
await proc.wait()
latency = (time.time() - start) * 1000
logger.warning("Shell command timed out after %ds: %s", effective_timeout, command)
return ShellResult(
command=command,
success=False,
exit_code=-1,
error=f"Command timed out after {effective_timeout}s",
latency_ms=latency,
timed_out=True,
)
latency = (time.time() - start) * 1000
exit_code = proc.returncode if proc.returncode is not None else -1
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
return ShellResult(
command=command,
success=exit_code == 0,
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
latency_ms=latency,
)
async def run(
self,
command: str,
@@ -164,7 +223,6 @@ class ShellHand:
"""
start = time.time()
# Validate
validation_error = self._validate_command(command)
if validation_error:
return ShellResult(
@@ -178,52 +236,8 @@ class ShellHand:
cwd = working_dir or self._working_dir
try:
import os
run_env = os.environ.copy()
if env:
run_env.update(env)
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=run_env,
)
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
proc.communicate(), timeout=effective_timeout
)
except TimeoutError:
proc.kill()
await proc.wait()
latency = (time.time() - start) * 1000
logger.warning("Shell command timed out after %ds: %s", effective_timeout, command)
return ShellResult(
command=command,
success=False,
exit_code=-1,
error=f"Command timed out after {effective_timeout}s",
latency_ms=latency,
timed_out=True,
)
latency = (time.time() - start) * 1000
exit_code = proc.returncode or 0
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
return ShellResult(
command=command,
success=exit_code == 0,
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
latency_ms=latency,
)
run_env = self._build_run_env(env)
return await self._execute_subprocess(command, effective_timeout, cwd, run_env, start)
except Exception as exc:
latency = (time.time() - start) * 1000
logger.warning("Shell command failed: %s%s", command, exc)

View File

@@ -13,7 +13,7 @@ import logging
from dataclasses import dataclass, field
from enum import Enum, auto
from config import settings
from config import normalize_ollama_url, settings
logger = logging.getLogger(__name__)
@@ -93,18 +93,6 @@ KNOWN_MODEL_CAPABILITIES: dict[str, set[ModelCapability]] = {
ModelCapability.VISION,
},
# Qwen series
"qwen3.5": {
ModelCapability.TEXT,
ModelCapability.TOOLS,
ModelCapability.JSON,
ModelCapability.STREAMING,
},
"qwen3.5:latest": {
ModelCapability.TEXT,
ModelCapability.TOOLS,
ModelCapability.JSON,
ModelCapability.STREAMING,
},
"qwen2.5": {
ModelCapability.TEXT,
ModelCapability.TOOLS,
@@ -271,9 +259,8 @@ DEFAULT_FALLBACK_CHAINS: dict[ModelCapability, list[str]] = {
],
ModelCapability.TOOLS: [
"llama3.1:8b-instruct", # Best tool use
"qwen3.5:latest", # Qwen 3.5 — strong tool use
"llama3.2:3b", # Smaller but capable
"qwen2.5:7b", # Reliable fallback
"llama3.2:3b", # Smaller but capable
],
ModelCapability.AUDIO: [
# Audio models are less common in Ollama
@@ -320,7 +307,7 @@ class MultiModalManager:
import json
import urllib.request
url = self.ollama_url.replace("localhost", "127.0.0.1")
url = normalize_ollama_url(self.ollama_url)
req = urllib.request.Request(
f"{url}/api/tags",
method="GET",
@@ -475,7 +462,7 @@ class MultiModalManager:
logger.info("Pulling model: %s", model_name)
url = self.ollama_url.replace("localhost", "127.0.0.1")
url = normalize_ollama_url(self.ollama_url)
req = urllib.request.Request(
f"{url}/api/pull",
method="POST",

View File

@@ -11,6 +11,8 @@ model roles (student, teacher, judge/PRM) run on dedicated resources.
import logging
import sqlite3
import threading
from collections.abc import Generator
from contextlib import closing, contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
@@ -60,36 +62,37 @@ class CustomModel:
self.registered_at = datetime.now(UTC).isoformat()
def _get_conn() -> sqlite3.Connection:
@contextmanager
def _get_conn() -> Generator[sqlite3.Connection, None, None]:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("""
CREATE TABLE IF NOT EXISTS custom_models (
name TEXT PRIMARY KEY,
format TEXT NOT NULL,
path TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'general',
context_window INTEGER NOT NULL DEFAULT 4096,
description TEXT NOT NULL DEFAULT '',
registered_at TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
default_temperature REAL NOT NULL DEFAULT 0.7,
max_tokens INTEGER NOT NULL DEFAULT 2048
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_model_assignments (
agent_id TEXT PRIMARY KEY,
model_name TEXT NOT NULL,
assigned_at TEXT NOT NULL,
FOREIGN KEY (model_name) REFERENCES custom_models(name)
)
""")
conn.commit()
return conn
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("""
CREATE TABLE IF NOT EXISTS custom_models (
name TEXT PRIMARY KEY,
format TEXT NOT NULL,
path TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'general',
context_window INTEGER NOT NULL DEFAULT 4096,
description TEXT NOT NULL DEFAULT '',
registered_at TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
default_temperature REAL NOT NULL DEFAULT 0.7,
max_tokens INTEGER NOT NULL DEFAULT 2048
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_model_assignments (
agent_id TEXT PRIMARY KEY,
model_name TEXT NOT NULL,
assigned_at TEXT NOT NULL,
FOREIGN KEY (model_name) REFERENCES custom_models(name)
)
""")
conn.commit()
yield conn
class ModelRegistry:
@@ -105,23 +108,22 @@ class ModelRegistry:
def _load_from_db(self) -> None:
"""Bootstrap cache from SQLite."""
try:
conn = _get_conn()
for row in conn.execute("SELECT * FROM custom_models WHERE active = 1").fetchall():
self._models[row["name"]] = CustomModel(
name=row["name"],
format=ModelFormat(row["format"]),
path=row["path"],
role=ModelRole(row["role"]),
context_window=row["context_window"],
description=row["description"],
registered_at=row["registered_at"],
active=bool(row["active"]),
default_temperature=row["default_temperature"],
max_tokens=row["max_tokens"],
)
for row in conn.execute("SELECT * FROM agent_model_assignments").fetchall():
self._agent_assignments[row["agent_id"]] = row["model_name"]
conn.close()
with _get_conn() as conn:
for row in conn.execute("SELECT * FROM custom_models WHERE active = 1").fetchall():
self._models[row["name"]] = CustomModel(
name=row["name"],
format=ModelFormat(row["format"]),
path=row["path"],
role=ModelRole(row["role"]),
context_window=row["context_window"],
description=row["description"],
registered_at=row["registered_at"],
active=bool(row["active"]),
default_temperature=row["default_temperature"],
max_tokens=row["max_tokens"],
)
for row in conn.execute("SELECT * FROM agent_model_assignments").fetchall():
self._agent_assignments[row["agent_id"]] = row["model_name"]
except Exception as exc:
logger.warning("Failed to load model registry from DB: %s", exc)
@@ -130,29 +132,28 @@ class ModelRegistry:
def register(self, model: CustomModel) -> CustomModel:
"""Register a new custom model."""
with self._lock:
conn = _get_conn()
conn.execute(
"""
INSERT OR REPLACE INTO custom_models
(name, format, path, role, context_window, description,
registered_at, active, default_temperature, max_tokens)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
model.name,
model.format.value,
model.path,
model.role.value,
model.context_window,
model.description,
model.registered_at,
int(model.active),
model.default_temperature,
model.max_tokens,
),
)
conn.commit()
conn.close()
with _get_conn() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO custom_models
(name, format, path, role, context_window, description,
registered_at, active, default_temperature, max_tokens)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
model.name,
model.format.value,
model.path,
model.role.value,
model.context_window,
model.description,
model.registered_at,
int(model.active),
model.default_temperature,
model.max_tokens,
),
)
conn.commit()
self._models[model.name] = model
logger.info("Registered model: %s (%s)", model.name, model.format.value)
return model
@@ -162,11 +163,10 @@ class ModelRegistry:
with self._lock:
if name not in self._models:
return False
conn = _get_conn()
conn.execute("DELETE FROM custom_models WHERE name = ?", (name,))
conn.execute("DELETE FROM agent_model_assignments WHERE model_name = ?", (name,))
conn.commit()
conn.close()
with _get_conn() as conn:
conn.execute("DELETE FROM custom_models WHERE name = ?", (name,))
conn.execute("DELETE FROM agent_model_assignments WHERE model_name = ?", (name,))
conn.commit()
del self._models[name]
# Remove any agent assignments using this model
self._agent_assignments = {
@@ -193,13 +193,12 @@ class ModelRegistry:
return False
with self._lock:
model.active = active
conn = _get_conn()
conn.execute(
"UPDATE custom_models SET active = ? WHERE name = ?",
(int(active), name),
)
conn.commit()
conn.close()
with _get_conn() as conn:
conn.execute(
"UPDATE custom_models SET active = ? WHERE name = ?",
(int(active), name),
)
conn.commit()
return True
# ── Agent-model assignments ────────────────────────────────────────────
@@ -210,17 +209,16 @@ class ModelRegistry:
return False
with self._lock:
now = datetime.now(UTC).isoformat()
conn = _get_conn()
conn.execute(
"""
INSERT OR REPLACE INTO agent_model_assignments
(agent_id, model_name, assigned_at)
VALUES (?, ?, ?)
""",
(agent_id, model_name, now),
)
conn.commit()
conn.close()
with _get_conn() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO agent_model_assignments
(agent_id, model_name, assigned_at)
VALUES (?, ?, ?)
""",
(agent_id, model_name, now),
)
conn.commit()
self._agent_assignments[agent_id] = model_name
logger.info("Assigned model %s to agent %s", model_name, agent_id)
return True
@@ -230,13 +228,12 @@ class ModelRegistry:
with self._lock:
if agent_id not in self._agent_assignments:
return False
conn = _get_conn()
conn.execute(
"DELETE FROM agent_model_assignments WHERE agent_id = ?",
(agent_id,),
)
conn.commit()
conn.close()
with _get_conn() as conn:
conn.execute(
"DELETE FROM agent_model_assignments WHERE agent_id = ?",
(agent_id,),
)
conn.commit()
del self._agent_assignments[agent_id]
return True

View File

@@ -2,6 +2,7 @@
from .api import router
from .cascade import CascadeRouter, Provider, ProviderStatus, get_router
from .history import HealthHistoryStore, get_history_store
__all__ = [
"CascadeRouter",
@@ -9,4 +10,6 @@ __all__ = [
"ProviderStatus",
"get_router",
"router",
"HealthHistoryStore",
"get_history_store",
]

View File

@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .cascade import CascadeRouter, get_router
from .history import HealthHistoryStore, get_history_store
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/router", tags=["router"])
@@ -183,6 +184,33 @@ async def run_health_check(
}
@router.post("/reload")
async def reload_config(
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
) -> dict[str, Any]:
"""Hot-reload providers.yaml without restart.
Preserves circuit breaker state and metrics for existing providers.
"""
try:
result = cascade.reload_config()
return {"status": "ok", **result}
except Exception as exc:
logger.error("Config reload failed: %s", exc)
raise HTTPException(status_code=500, detail=f"Reload failed: {exc}") from exc
@router.get("/history")
async def get_history(
hours: int = 24,
store: Annotated[HealthHistoryStore, Depends(get_history_store)] = None,
) -> list[dict[str, Any]]:
"""Get provider health history for the last N hours."""
if store is None:
store = get_history_store()
return store.get_history(hours=hours)
@router.get("/config")
async def get_config(
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],

View File

@@ -18,6 +18,8 @@ from enum import Enum
from pathlib import Path
from typing import Any
from config import settings
try:
import yaml
except ImportError:
@@ -100,7 +102,7 @@ class Provider:
"""LLM provider configuration and state."""
name: str
type: str # ollama, openai, anthropic, airllm
type: str # ollama, openai, anthropic
enabled: bool
priority: int
url: str | None = None
@@ -219,65 +221,56 @@ class CascadeRouter:
raise RuntimeError("PyYAML not installed")
content = self.config_path.read_text()
# Expand environment variables
content = self._expand_env_vars(content)
data = yaml.safe_load(content)
# Load cascade settings
cascade = data.get("cascade", {})
# Load fallback chains
fallback_chains = data.get("fallback_chains", {})
# Load multi-modal settings
multimodal = data.get("multimodal", {})
self.config = RouterConfig(
timeout_seconds=cascade.get("timeout_seconds", 30),
max_retries_per_provider=cascade.get("max_retries_per_provider", 2),
retry_delay_seconds=cascade.get("retry_delay_seconds", 1),
circuit_breaker_failure_threshold=cascade.get("circuit_breaker", {}).get(
"failure_threshold", 5
),
circuit_breaker_recovery_timeout=cascade.get("circuit_breaker", {}).get(
"recovery_timeout", 60
),
circuit_breaker_half_open_max_calls=cascade.get("circuit_breaker", {}).get(
"half_open_max_calls", 2
),
auto_pull_models=multimodal.get("auto_pull", True),
fallback_chains=fallback_chains,
)
# Load providers
for p_data in data.get("providers", []):
# Skip disabled providers
if not p_data.get("enabled", False):
continue
provider = Provider(
name=p_data["name"],
type=p_data["type"],
enabled=p_data.get("enabled", True),
priority=p_data.get("priority", 99),
url=p_data.get("url"),
api_key=p_data.get("api_key"),
base_url=p_data.get("base_url"),
models=p_data.get("models", []),
)
# Check if provider is actually available
if self._check_provider_available(provider):
self.providers.append(provider)
else:
logger.warning("Provider %s not available, skipping", provider.name)
# Sort by priority
self.providers.sort(key=lambda p: p.priority)
self.config = self._parse_router_config(data)
self._load_providers(data)
except Exception as exc:
logger.error("Failed to load config: %s", exc)
def _parse_router_config(self, data: dict) -> RouterConfig:
"""Build a RouterConfig from parsed YAML data."""
cascade = data.get("cascade", {})
cb = cascade.get("circuit_breaker", {})
multimodal = data.get("multimodal", {})
return RouterConfig(
timeout_seconds=cascade.get("timeout_seconds", 30),
max_retries_per_provider=cascade.get("max_retries_per_provider", 2),
retry_delay_seconds=cascade.get("retry_delay_seconds", 1),
circuit_breaker_failure_threshold=cb.get("failure_threshold", 5),
circuit_breaker_recovery_timeout=cb.get("recovery_timeout", 60),
circuit_breaker_half_open_max_calls=cb.get("half_open_max_calls", 2),
auto_pull_models=multimodal.get("auto_pull", True),
fallback_chains=data.get("fallback_chains", {}),
)
def _load_providers(self, data: dict) -> None:
"""Load, filter, and sort providers from parsed YAML data."""
for p_data in data.get("providers", []):
if not p_data.get("enabled", False):
continue
provider = Provider(
name=p_data["name"],
type=p_data["type"],
enabled=p_data.get("enabled", True),
priority=p_data.get("priority", 99),
url=p_data.get("url"),
api_key=p_data.get("api_key"),
base_url=p_data.get("base_url"),
models=p_data.get("models", []),
)
if self._check_provider_available(provider):
self.providers.append(provider)
else:
logger.warning("Provider %s not available, skipping", provider.name)
self.providers.sort(key=lambda p: p.priority)
def _expand_env_vars(self, content: str) -> str:
"""Expand ${VAR} syntax in YAML content.
@@ -301,19 +294,11 @@ class CascadeRouter:
# Can't check without requests, assume available
return True
try:
url = provider.url or "http://localhost:11434"
url = provider.url or settings.ollama_url
response = requests.get(f"{url}/api/tags", timeout=5)
return response.status_code == 200
except Exception:
return False
elif provider.type == "airllm":
# Check if airllm is installed
try:
import importlib.util
return importlib.util.find_spec("airllm") is not None
except (ImportError, ModuleNotFoundError):
except Exception as exc:
logger.debug("Ollama provider check error: %s", exc)
return False
elif provider.type in ("openai", "anthropic", "grok"):
@@ -394,6 +379,101 @@ class CascadeRouter:
return None
def _select_model(
self, provider: Provider, model: str | None, content_type: ContentType
) -> tuple[str | None, bool]:
"""Select the best model for the request, with vision fallback.
Returns:
Tuple of (selected_model, is_fallback_model).
"""
selected_model = model or provider.get_default_model()
is_fallback = False
if content_type != ContentType.TEXT and selected_model:
if provider.type == "ollama" and self._mm_manager:
from infrastructure.models.multimodal import ModelCapability
if content_type == ContentType.VISION:
supports = self._mm_manager.model_supports(
selected_model, ModelCapability.VISION
)
if not supports:
fallback = self._get_fallback_model(provider, selected_model, content_type)
if fallback:
logger.info(
"Model %s doesn't support vision, falling back to %s",
selected_model,
fallback,
)
selected_model = fallback
is_fallback = True
else:
logger.warning(
"No vision-capable model found on %s, trying anyway",
provider.name,
)
return selected_model, is_fallback
async def _attempt_with_retry(
self,
provider: Provider,
messages: list[dict],
model: str | None,
temperature: float,
max_tokens: int | None,
content_type: ContentType,
) -> dict:
"""Try a provider with retries, returning the result dict.
Raises:
RuntimeError: If all retry attempts fail.
Returns error strings collected during retries via the exception message.
"""
errors: list[str] = []
for attempt in range(self.config.max_retries_per_provider):
try:
return await self._try_provider(
provider=provider,
messages=messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
content_type=content_type,
)
except Exception as exc:
error_msg = str(exc)
logger.warning(
"Provider %s attempt %d failed: %s",
provider.name,
attempt + 1,
error_msg,
)
errors.append(f"{provider.name}: {error_msg}")
if attempt < self.config.max_retries_per_provider - 1:
await asyncio.sleep(self.config.retry_delay_seconds)
raise RuntimeError("; ".join(errors))
def _is_provider_available(self, provider: Provider) -> bool:
"""Check if a provider should be tried (enabled + circuit breaker)."""
if not provider.enabled:
logger.debug("Skipping %s (disabled)", provider.name)
return False
if provider.status == ProviderStatus.UNHEALTHY:
if self._can_close_circuit(provider):
provider.circuit_state = CircuitState.HALF_OPEN
provider.half_open_calls = 0
logger.info("Circuit breaker half-open for %s", provider.name)
else:
logger.debug("Skipping %s (circuit open)", provider.name)
return False
return True
async def complete(
self,
messages: list[dict],
@@ -420,7 +500,6 @@ class CascadeRouter:
Raises:
RuntimeError: If all providers fail
"""
# Detect content type for multi-modal routing
content_type = self._detect_content_type(messages)
if content_type != ContentType.TEXT:
logger.debug("Detected %s content, selecting appropriate model", content_type.value)
@@ -428,93 +507,34 @@ class CascadeRouter:
errors = []
for provider in self.providers:
# Skip disabled providers
if not provider.enabled:
logger.debug("Skipping %s (disabled)", provider.name)
if not self._is_provider_available(provider):
continue
# Skip unhealthy providers (circuit breaker)
if provider.status == ProviderStatus.UNHEALTHY:
# Check if circuit breaker can close
if self._can_close_circuit(provider):
provider.circuit_state = CircuitState.HALF_OPEN
provider.half_open_calls = 0
logger.info("Circuit breaker half-open for %s", provider.name)
else:
logger.debug("Skipping %s (circuit open)", provider.name)
continue
selected_model, is_fallback_model = self._select_model(provider, model, content_type)
# Determine which model to use
selected_model = model or provider.get_default_model()
is_fallback_model = False
try:
result = await self._attempt_with_retry(
provider,
messages,
selected_model,
temperature,
max_tokens,
content_type,
)
except RuntimeError as exc:
errors.append(str(exc))
self._record_failure(provider)
continue
# For non-text content, check if model supports it
if content_type != ContentType.TEXT and selected_model:
if provider.type == "ollama" and self._mm_manager:
from infrastructure.models.multimodal import ModelCapability
self._record_success(provider, result.get("latency_ms", 0))
return {
"content": result["content"],
"provider": provider.name,
"model": result.get("model", selected_model or provider.get_default_model()),
"latency_ms": result.get("latency_ms", 0),
"is_fallback_model": is_fallback_model,
}
# Check if selected model supports the required capability
if content_type == ContentType.VISION:
supports = self._mm_manager.model_supports(
selected_model, ModelCapability.VISION
)
if not supports:
# Find fallback model
fallback = self._get_fallback_model(
provider, selected_model, content_type
)
if fallback:
logger.info(
"Model %s doesn't support vision, falling back to %s",
selected_model,
fallback,
)
selected_model = fallback
is_fallback_model = True
else:
logger.warning(
"No vision-capable model found on %s, trying anyway",
provider.name,
)
# Try this provider
for attempt in range(self.config.max_retries_per_provider):
try:
result = await self._try_provider(
provider=provider,
messages=messages,
model=selected_model,
temperature=temperature,
max_tokens=max_tokens,
content_type=content_type,
)
# Success! Update metrics and return
self._record_success(provider, result.get("latency_ms", 0))
return {
"content": result["content"],
"provider": provider.name,
"model": result.get(
"model", selected_model or provider.get_default_model()
),
"latency_ms": result.get("latency_ms", 0),
"is_fallback_model": is_fallback_model,
}
except Exception as exc:
error_msg = str(exc)
logger.warning(
"Provider %s attempt %d failed: %s", provider.name, attempt + 1, error_msg
)
errors.append(f"{provider.name}: {error_msg}")
if attempt < self.config.max_retries_per_provider - 1:
await asyncio.sleep(self.config.retry_delay_seconds)
# All retries failed for this provider
self._record_failure(provider)
# All providers failed
raise RuntimeError(f"All providers failed: {'; '.join(errors)}")
async def _try_provider(
@@ -535,6 +555,7 @@ class CascadeRouter:
messages=messages,
model=model or provider.get_default_model(),
temperature=temperature,
max_tokens=max_tokens,
content_type=content_type,
)
elif provider.type == "openai":
@@ -575,23 +596,26 @@ class CascadeRouter:
messages: list[dict],
model: str,
temperature: float,
max_tokens: int | None = None,
content_type: ContentType = ContentType.TEXT,
) -> dict:
"""Call Ollama API with multi-modal support."""
import aiohttp
url = f"{provider.url}/api/chat"
url = f"{provider.url or settings.ollama_url}/api/chat"
# Transform messages for Ollama format (including images)
transformed_messages = self._transform_messages_for_ollama(messages)
options = {"temperature": temperature}
if max_tokens:
options["num_predict"] = max_tokens
payload = {
"model": model,
"messages": transformed_messages,
"stream": False,
"options": {
"temperature": temperature,
},
"options": options,
}
timeout = aiohttp.ClientTimeout(total=self.config.timeout_seconds)
@@ -735,7 +759,7 @@ class CascadeRouter:
client = openai.AsyncOpenAI(
api_key=provider.api_key,
base_url=provider.base_url or "https://api.x.ai/v1",
base_url=provider.base_url or settings.xai_base_url,
timeout=httpx.Timeout(300.0),
)
@@ -814,6 +838,66 @@ class CascadeRouter:
provider.status = ProviderStatus.HEALTHY
logger.info("Circuit breaker CLOSED for %s", provider.name)
def reload_config(self) -> dict:
"""Hot-reload providers.yaml, preserving runtime state.
Re-reads the config file, rebuilds the provider list, and
preserves circuit breaker state and metrics for providers
that still exist after reload.
Returns:
Summary dict with added/removed/preserved counts.
"""
# Snapshot current runtime state keyed by provider name
old_state: dict[
str, tuple[ProviderMetrics, CircuitState, float | None, int, ProviderStatus]
] = {}
for p in self.providers:
old_state[p.name] = (
p.metrics,
p.circuit_state,
p.circuit_opened_at,
p.half_open_calls,
p.status,
)
old_names = set(old_state.keys())
# Reload from disk
self.providers = []
self._load_config()
# Restore preserved state
new_names = {p.name for p in self.providers}
preserved = 0
for p in self.providers:
if p.name in old_state:
metrics, circuit, opened_at, half_open, status = old_state[p.name]
p.metrics = metrics
p.circuit_state = circuit
p.circuit_opened_at = opened_at
p.half_open_calls = half_open
p.status = status
preserved += 1
added = new_names - old_names
removed = old_names - new_names
logger.info(
"Config reloaded: %d providers (%d preserved, %d added, %d removed)",
len(self.providers),
preserved,
len(added),
len(removed),
)
return {
"total_providers": len(self.providers),
"preserved": preserved,
"added": sorted(added),
"removed": sorted(removed),
}
def get_metrics(self) -> dict:
"""Get metrics for all providers."""
return {

View File

@@ -0,0 +1,152 @@
"""Provider health history — time-series snapshots for dashboard visualization."""
import asyncio
import logging
import sqlite3
from datetime import UTC, datetime, timedelta
from pathlib import Path
logger = logging.getLogger(__name__)
_store: "HealthHistoryStore | None" = None
class HealthHistoryStore:
"""Stores timestamped provider health snapshots in SQLite."""
def __init__(self, db_path: str = "data/router_history.db") -> None:
self.db_path = db_path
if db_path != ":memory:":
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._init_schema()
self._bg_task: asyncio.Task | None = None
def _init_schema(self) -> None:
self._conn.execute("""
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
provider_name TEXT NOT NULL,
status TEXT NOT NULL,
error_rate REAL NOT NULL,
avg_latency_ms REAL NOT NULL,
circuit_state TEXT NOT NULL,
total_requests INTEGER NOT NULL
)
""")
self._conn.execute("""
CREATE INDEX IF NOT EXISTS idx_snapshots_ts
ON snapshots(timestamp)
""")
self._conn.commit()
def record_snapshot(self, providers: list[dict]) -> None:
"""Record a health snapshot for all providers."""
ts = datetime.now(UTC).isoformat()
rows = [
(
ts,
p["name"],
p["status"],
p["error_rate"],
p["avg_latency_ms"],
p["circuit_state"],
p["total_requests"],
)
for p in providers
]
self._conn.executemany(
"""INSERT INTO snapshots
(timestamp, provider_name, status, error_rate,
avg_latency_ms, circuit_state, total_requests)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
rows,
)
self._conn.commit()
def get_history(self, hours: int = 24) -> list[dict]:
"""Return snapshots from the last N hours, grouped by timestamp."""
cutoff = (datetime.now(UTC) - timedelta(hours=hours)).isoformat()
rows = self._conn.execute(
"""SELECT timestamp, provider_name, status, error_rate,
avg_latency_ms, circuit_state, total_requests
FROM snapshots WHERE timestamp >= ? ORDER BY timestamp""",
(cutoff,),
).fetchall()
# Group by timestamp
snapshots: dict[str, list[dict]] = {}
for row in rows:
ts = row["timestamp"]
if ts not in snapshots:
snapshots[ts] = []
snapshots[ts].append(
{
"name": row["provider_name"],
"status": row["status"],
"error_rate": row["error_rate"],
"avg_latency_ms": row["avg_latency_ms"],
"circuit_state": row["circuit_state"],
"total_requests": row["total_requests"],
}
)
return [{"timestamp": ts, "providers": providers} for ts, providers in snapshots.items()]
def prune(self, keep_hours: int = 168) -> int:
"""Remove snapshots older than keep_hours. Returns rows deleted."""
cutoff = (datetime.now(UTC) - timedelta(hours=keep_hours)).isoformat()
cursor = self._conn.execute("DELETE FROM snapshots WHERE timestamp < ?", (cutoff,))
self._conn.commit()
return cursor.rowcount
def close(self) -> None:
"""Close the database connection."""
if self._bg_task and not self._bg_task.done():
self._bg_task.cancel()
self._conn.close()
def _capture_snapshot(self, cascade_router) -> None: # noqa: ANN001
"""Capture current provider state as a snapshot."""
providers = []
for p in cascade_router.providers:
providers.append(
{
"name": p.name,
"status": p.status.value,
"error_rate": round(p.metrics.error_rate, 4),
"avg_latency_ms": round(p.metrics.avg_latency_ms, 2),
"circuit_state": p.circuit_state.value,
"total_requests": p.metrics.total_requests,
}
)
self.record_snapshot(providers)
async def start_background_task(
self,
cascade_router,
interval_seconds: int = 60, # noqa: ANN001
) -> None:
"""Start periodic snapshot capture."""
async def _loop() -> None:
while True:
try:
self._capture_snapshot(cascade_router)
logger.debug("Recorded health snapshot")
except Exception:
logger.exception("Failed to record health snapshot")
await asyncio.sleep(interval_seconds)
self._bg_task = asyncio.create_task(_loop())
logger.info("Health history background task started (interval=%ds)", interval_seconds)
def get_history_store() -> HealthHistoryStore:
"""Get or create the singleton history store."""
global _store # noqa: PLW0603
if _store is None:
_store = HealthHistoryStore()
return _store

View File

@@ -54,7 +54,8 @@ class WebSocketManager:
for event in list(self._event_history)[-20:]:
try:
await websocket.send_text(event.to_json())
except Exception:
except Exception as exc:
logger.warning("WebSocket history send error: %s", exc)
break
def disconnect(self, websocket: WebSocket) -> None:
@@ -83,8 +84,8 @@ class WebSocketManager:
await ws.send_text(message)
except ConnectionError:
disconnected.append(ws)
except Exception:
logger.warning("Unexpected WebSocket send error", exc_info=True)
except Exception as exc:
logger.warning("Unexpected WebSocket send error: %s", exc)
disconnected.append(ws)
# Clean up dead connections
@@ -156,7 +157,8 @@ class WebSocketManager:
try:
await ws.send_text(message)
count += 1
except Exception:
except Exception as exc:
logger.warning("WebSocket direct send error: %s", exc)
disconnected.append(ws)
# Clean up dead connections

View File

@@ -87,7 +87,8 @@ if _DISCORD_UI_AVAILABLE:
await action["target"].send(
f"Action `{action['tool_name']}` timed out and was auto-rejected."
)
except Exception:
except Exception as exc:
logger.warning("Discord action timeout message error: %s", exc)
pass
@@ -186,7 +187,8 @@ class DiscordVendor(ChatPlatform):
if self._client and not self._client.is_closed():
try:
await self._client.close()
except Exception:
except Exception as exc:
logger.warning("Discord client close error: %s", exc)
pass
self._client = None
@@ -330,7 +332,8 @@ class DiscordVendor(ChatPlatform):
if settings.discord_token:
return settings.discord_token
except Exception:
except Exception as exc:
logger.warning("Discord token load error: %s", exc)
pass
# 2. Fall back to state file (set via /discord/setup endpoint)
@@ -458,7 +461,8 @@ class DiscordVendor(ChatPlatform):
req.reject(note="User rejected from Discord")
try:
await continue_chat(action["run_output"], action.get("session_id"))
except Exception:
except Exception as exc:
logger.warning("Discord continue chat error: %s", exc)
pass
await interaction.response.send_message(
@@ -511,25 +515,36 @@ class DiscordVendor(ChatPlatform):
async def _handle_message(self, message) -> None:
"""Process an incoming message and respond via a thread."""
# Strip the bot mention from the message content
content = message.content
if self._client.user:
content = content.replace(f"<@{self._client.user.id}>", "").strip()
content = self._extract_content(message)
if not content:
return
# Create or reuse a thread for this conversation
thread = await self._get_or_create_thread(message)
target = thread or message.channel
session_id = f"discord_{thread.id}" if thread else f"discord_{message.channel.id}"
# Derive session_id for per-conversation history via Agno's SQLite
if thread:
session_id = f"discord_{thread.id}"
else:
session_id = f"discord_{message.channel.id}"
run_output, response = await self._invoke_agent(content, session_id, target)
# Run Timmy agent with typing indicator and timeout
if run_output is not None:
await self._handle_paused_run(run_output, target, session_id)
raw_content = run_output.content if hasattr(run_output, "content") else ""
response = _clean_response(raw_content or "")
await self._send_response(response, target)
def _extract_content(self, message) -> str:
"""Strip the bot mention and return clean message text."""
content = message.content
if self._client.user:
content = content.replace(f"<@{self._client.user.id}>", "").strip()
return content
async def _invoke_agent(self, content: str, session_id: str, target):
"""Run chat_with_tools with a typing indicator and timeout.
Returns a (run_output, error_response) tuple. On success the
error_response is ``None``; on failure run_output is ``None``.
"""
run_output = None
response = None
try:
@@ -543,54 +558,58 @@ class DiscordVendor(ChatPlatform):
response = "Sorry, that took too long. Please try a simpler request."
except Exception as exc:
logger.error("Discord: chat_with_tools() failed: %s", exc)
response = (
"I'm having trouble reaching my language model right now. Please try again shortly."
response = "I'm having trouble reaching my inference backend right now. Please try again shortly."
return run_output, response
async def _handle_paused_run(self, run_output, target, session_id: str) -> None:
"""If Agno paused the run for tool confirmation, enqueue approvals."""
status = getattr(run_output, "status", None)
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
if not (is_paused and getattr(run_output, "active_requirements", None)):
return
from config import settings
if not settings.discord_confirm_actions:
return
for req in run_output.active_requirements:
if not getattr(req, "needs_confirmation", False):
continue
te = req.tool_execution
tool_name = getattr(te, "tool_name", "unknown")
tool_args = getattr(te, "tool_args", {}) or {}
from timmy.approvals import create_item
item = create_item(
title=f"Discord: {tool_name}",
description=_format_action_description(tool_name, tool_args),
proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
impact=_get_impact_level(tool_name),
)
self._pending_actions[item.id] = {
"run_output": run_output,
"requirement": req,
"tool_name": tool_name,
"tool_args": tool_args,
"target": target,
"session_id": session_id,
}
await self._send_confirmation(target, tool_name, tool_args, item.id)
# Check if Agno paused the run for tool confirmation
if run_output is not None:
status = getattr(run_output, "status", None)
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
if is_paused and getattr(run_output, "active_requirements", None):
from config import settings
if settings.discord_confirm_actions:
for req in run_output.active_requirements:
if getattr(req, "needs_confirmation", False):
te = req.tool_execution
tool_name = getattr(te, "tool_name", "unknown")
tool_args = getattr(te, "tool_args", {}) or {}
from timmy.approvals import create_item
item = create_item(
title=f"Discord: {tool_name}",
description=_format_action_description(tool_name, tool_args),
proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
impact=_get_impact_level(tool_name),
)
self._pending_actions[item.id] = {
"run_output": run_output,
"requirement": req,
"tool_name": tool_name,
"tool_args": tool_args,
"target": target,
"session_id": session_id,
}
await self._send_confirmation(target, tool_name, tool_args, item.id)
raw_content = run_output.content if hasattr(run_output, "content") else ""
response = _clean_response(raw_content or "")
# Discord has a 2000 character limit — send with error handling
if response and response.strip():
for chunk in _chunk_message(response, 2000):
try:
await target.send(chunk)
except Exception as exc:
logger.error("Discord: failed to send message chunk: %s", exc)
break
@staticmethod
async def _send_response(response: str | None, target) -> None:
"""Send a response to Discord, chunked to the 2000-char limit."""
if not response or not response.strip():
return
for chunk in _chunk_message(response, 2000):
try:
await target.send(chunk)
except Exception as exc:
logger.error("Discord: failed to send message chunk: %s", exc)
break
async def _get_or_create_thread(self, message):
"""Get the active thread for a channel, or create one.

View File

@@ -56,7 +56,8 @@ class TelegramBot:
from config import settings
return settings.telegram_token or None
except Exception:
except Exception as exc:
logger.warning("Telegram token load error: %s", exc)
return None
def save_token(self, token: str) -> None:

View File

@@ -0,0 +1 @@
"""Lightning Network integration for tool-usage micro-payments."""

69
src/lightning/factory.py Normal file
View File

@@ -0,0 +1,69 @@
"""Lightning backend factory.
Returns a mock or real LND backend based on ``settings.lightning_backend``.
"""
from __future__ import annotations
import hashlib
import logging
import secrets
from dataclasses import dataclass
from config import settings
logger = logging.getLogger(__name__)
@dataclass
class Invoice:
"""Minimal Lightning invoice representation."""
payment_hash: str
payment_request: str
amount_sats: int
memo: str
class MockBackend:
"""In-memory mock Lightning backend for development and testing."""
def create_invoice(self, amount_sats: int, memo: str = "") -> Invoice:
"""Create a fake invoice with a random payment hash."""
raw = secrets.token_bytes(32)
payment_hash = hashlib.sha256(raw).hexdigest()
payment_request = f"lnbc{amount_sats}mock{payment_hash[:20]}"
logger.debug("Mock invoice: %s sats — %s", amount_sats, payment_hash[:12])
return Invoice(
payment_hash=payment_hash,
payment_request=payment_request,
amount_sats=amount_sats,
memo=memo,
)
# Singleton — lazily created
_backend: MockBackend | None = None
def get_backend() -> MockBackend:
"""Return the configured Lightning backend (currently mock-only).
Raises ``ValueError`` if an unsupported backend is requested.
"""
global _backend # noqa: PLW0603
if _backend is not None:
return _backend
kind = settings.lightning_backend
if kind == "mock":
_backend = MockBackend()
elif kind == "lnd":
# LND gRPC integration is on the roadmap — for now fall back to mock.
logger.warning("LND backend not yet implemented — using mock")
_backend = MockBackend()
else:
raise ValueError(f"Unknown lightning_backend: {kind!r}")
logger.info("Lightning backend: %s", kind)
return _backend

146
src/lightning/ledger.py Normal file
View File

@@ -0,0 +1,146 @@
"""In-memory Lightning transaction ledger.
Tracks invoices, settlements, and balances per the schema in
``docs/adr/018-lightning-ledger.md``. Uses a simple in-memory list so the
dashboard can display real (ephemeral) data without requiring SQLite yet.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
logger = logging.getLogger(__name__)
class TxType(StrEnum):
incoming = "incoming"
outgoing = "outgoing"
class TxStatus(StrEnum):
pending = "pending"
settled = "settled"
failed = "failed"
expired = "expired"
@dataclass
class LedgerEntry:
"""Single ledger row matching the ADR-018 schema."""
id: str
tx_type: TxType
status: TxStatus
payment_hash: str
amount_sats: int
memo: str
source: str
created_at: str
invoice: str = ""
preimage: str = ""
task_id: str = ""
agent_id: str = ""
settled_at: str = ""
fee_sats: int = 0
# ── In-memory store ──────────────────────────────────────────────────
_entries: list[LedgerEntry] = []
def create_invoice_entry(
payment_hash: str,
amount_sats: int,
memo: str = "",
source: str = "tool_usage",
task_id: str = "",
agent_id: str = "",
invoice: str = "",
) -> LedgerEntry:
"""Record a new incoming invoice in the ledger."""
entry = LedgerEntry(
id=uuid.uuid4().hex[:16],
tx_type=TxType.incoming,
status=TxStatus.pending,
payment_hash=payment_hash,
amount_sats=amount_sats,
memo=memo,
source=source,
task_id=task_id,
agent_id=agent_id,
invoice=invoice,
created_at=datetime.now(UTC).isoformat(),
)
_entries.append(entry)
logger.debug("Ledger entry created: %s (%s sats)", entry.id, amount_sats)
return entry
def mark_settled(payment_hash: str, preimage: str = "") -> LedgerEntry | None:
"""Mark a pending entry as settled by payment hash."""
for entry in _entries:
if entry.payment_hash == payment_hash and entry.status == TxStatus.pending:
entry.status = TxStatus.settled
entry.preimage = preimage
entry.settled_at = datetime.now(UTC).isoformat()
logger.debug("Ledger settled: %s", payment_hash[:12])
return entry
return None
def get_balance() -> dict:
"""Compute the current balance from settled and pending entries."""
incoming_total = sum(
e.amount_sats
for e in _entries
if e.tx_type == TxType.incoming and e.status == TxStatus.settled
)
outgoing_total = sum(
e.amount_sats
for e in _entries
if e.tx_type == TxType.outgoing and e.status == TxStatus.settled
)
fees = sum(e.fee_sats for e in _entries if e.status == TxStatus.settled)
pending_in = sum(
e.amount_sats
for e in _entries
if e.tx_type == TxType.incoming and e.status == TxStatus.pending
)
pending_out = sum(
e.amount_sats
for e in _entries
if e.tx_type == TxType.outgoing and e.status == TxStatus.pending
)
net = incoming_total - outgoing_total - fees
return {
"incoming_total_sats": incoming_total,
"outgoing_total_sats": outgoing_total,
"fees_paid_sats": fees,
"net_sats": net,
"pending_incoming_sats": pending_in,
"pending_outgoing_sats": pending_out,
"available_sats": net - pending_out,
}
def get_transactions(
tx_type: str | None = None,
status: str | None = None,
limit: int = 50,
) -> list[LedgerEntry]:
"""Return ledger entries, optionally filtered."""
result = _entries
if tx_type:
result = [e for e in result if e.tx_type.value == tx_type]
if status:
result = [e for e in result if e.status.value == status]
return list(reversed(result))[:limit]
def clear() -> None:
"""Reset the ledger (for testing)."""
_entries.clear()

1
src/loop/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Three-phase agent loop: Gather → Reason → Act."""

37
src/loop/phase1_gather.py Normal file
View File

@@ -0,0 +1,37 @@
"""Phase 1 — Gather: accept raw input, produce structured context.
This is the sensory phase. It receives a raw ContextPayload and enriches
it with whatever context Timmy needs before reasoning. In the stub form,
it simply passes the payload through with a phase marker.
"""
from __future__ import annotations
import logging
from loop.schema import ContextPayload
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.
"""
logger.info(
"Phase 1 (Gather) received: source=%s content_len=%d tokens=%d",
payload.source,
len(payload.content),
payload.token_count,
)
result = payload.with_metadata(phase="gather", gathered=True)
logger.info(
"Phase 1 (Gather) produced: metadata_keys=%s",
sorted(result.metadata.keys()),
)
return result

36
src/loop/phase2_reason.py Normal file
View File

@@ -0,0 +1,36 @@
"""Phase 2 — Reason: accept gathered context, produce reasoning output.
This is the deliberation phase. It receives enriched context from Phase 1
and decides what to do. In the stub form, it passes the payload through
with a phase marker.
"""
from __future__ import annotations
import logging
from loop.schema import ContextPayload
logger = logging.getLogger(__name__)
def reason(payload: ContextPayload) -> ContextPayload:
"""Accept gathered context and return a reasoning result.
Stub: tags the payload with phase=reason and logs transit.
Timmy will flesh this out with LLM calls, confidence scoring,
plan generation, and judgment logic.
"""
logger.info(
"Phase 2 (Reason) received: source=%s gathered=%s",
payload.source,
payload.metadata.get("gathered", False),
)
result = payload.with_metadata(phase="reason", reasoned=True)
logger.info(
"Phase 2 (Reason) produced: metadata_keys=%s",
sorted(result.metadata.keys()),
)
return result

36
src/loop/phase3_act.py Normal file
View File

@@ -0,0 +1,36 @@
"""Phase 3 — Act: accept reasoning output, execute and produce feedback.
This is the command phase. It receives the reasoning result from Phase 2
and takes action. In the stub form, it passes the payload through with a
phase marker and produces feedback for the next cycle.
"""
from __future__ import annotations
import logging
from loop.schema import ContextPayload
logger = logging.getLogger(__name__)
def act(payload: ContextPayload) -> ContextPayload:
"""Accept reasoning result and return action output + feedback.
Stub: tags the payload with phase=act and logs transit.
Timmy will flesh this out with tool execution, delegation,
response generation, and feedback construction.
"""
logger.info(
"Phase 3 (Act) received: source=%s reasoned=%s",
payload.source,
payload.metadata.get("reasoned", False),
)
result = payload.with_metadata(phase="act", acted=True)
logger.info(
"Phase 3 (Act) produced: metadata_keys=%s",
sorted(result.metadata.keys()),
)
return result

40
src/loop/runner.py Normal file
View File

@@ -0,0 +1,40 @@
"""Loop runner — orchestrates the three phases in sequence.
Runs Gather → Reason → Act as a single cycle, passing output from each
phase as input to the next. The Act output feeds back as input to the
next Gather call.
"""
from __future__ import annotations
import logging
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__)
def run_cycle(payload: ContextPayload) -> ContextPayload:
"""Execute one full Gather → Reason → Act cycle.
Returns the Act phase output, which can be fed back as input
to the next cycle.
"""
logger.info("=== Loop cycle start: source=%s ===", payload.source)
gathered = gather(payload)
reasoned = reason(gathered)
acted = act(reasoned)
logger.info(
"=== Loop cycle complete: phases=%s ===",
[
gathered.metadata.get("phase"),
reasoned.metadata.get("phase"),
acted.metadata.get("phase"),
],
)
return acted

43
src/loop/schema.py Normal file
View File

@@ -0,0 +1,43 @@
"""Data schema for the three-phase loop.
Each phase passes a ContextPayload forward. The schema is intentionally
minimal — Timmy decides what fields matter as the loop matures.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
logger = logging.getLogger(__name__)
@dataclass
class ContextPayload:
"""Immutable context packet passed between loop phases.
Attributes:
source: Where this payload originated (e.g. "user", "timer", "event").
content: The raw content string to process.
timestamp: When the payload was created.
token_count: Estimated token count for budget tracking. -1 = unknown.
metadata: Arbitrary key-value pairs for phase-specific data.
"""
source: str
content: str
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
token_count: int = -1
metadata: dict = field(default_factory=dict)
def with_metadata(self, **kwargs: object) -> ContextPayload:
"""Return a new payload with additional metadata merged in."""
merged = {**self.metadata, **kwargs}
return ContextPayload(
source=self.source,
content=self.content,
timestamp=self.timestamp,
token_count=self.token_count,
metadata=merged,
)

View File

@@ -16,6 +16,8 @@ import json
import logging
import sqlite3
import uuid
from collections.abc import Generator
from contextlib import closing, contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
@@ -39,28 +41,31 @@ class Prediction:
evaluated_at: str | None
def _get_conn() -> sqlite3.Connection:
@contextmanager
def _get_conn() -> Generator[sqlite3.Connection, None, None]:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("""
CREATE TABLE IF NOT EXISTS spark_predictions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
prediction_type TEXT NOT NULL,
predicted_value TEXT NOT NULL,
actual_value TEXT,
accuracy REAL,
created_at TEXT NOT NULL,
evaluated_at TEXT
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("""
CREATE TABLE IF NOT EXISTS spark_predictions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
prediction_type TEXT NOT NULL,
predicted_value TEXT NOT NULL,
actual_value TEXT,
accuracy REAL,
created_at TEXT NOT NULL,
evaluated_at TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_pred_task ON spark_predictions(task_id)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pred_type ON spark_predictions(prediction_type)"
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_pred_task ON spark_predictions(task_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_pred_type ON spark_predictions(prediction_type)")
conn.commit()
return conn
conn.commit()
yield conn
# ── Prediction phase ────────────────────────────────────────────────────────
@@ -119,17 +124,16 @@ def predict_task_outcome(
# Store prediction
pred_id = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
conn = _get_conn()
conn.execute(
"""
INSERT INTO spark_predictions
(id, task_id, prediction_type, predicted_value, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(pred_id, task_id, "outcome", json.dumps(prediction), now),
)
conn.commit()
conn.close()
with _get_conn() as conn:
conn.execute(
"""
INSERT INTO spark_predictions
(id, task_id, prediction_type, predicted_value, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(pred_id, task_id, "outcome", json.dumps(prediction), now),
)
conn.commit()
prediction["prediction_id"] = pred_id
return prediction
@@ -148,41 +152,39 @@ def evaluate_prediction(
Returns the evaluation result or None if no prediction exists.
"""
conn = _get_conn()
row = conn.execute(
"""
SELECT * FROM spark_predictions
WHERE task_id = ? AND prediction_type = 'outcome' AND evaluated_at IS NULL
ORDER BY created_at DESC LIMIT 1
""",
(task_id,),
).fetchone()
with _get_conn() as conn:
row = conn.execute(
"""
SELECT * FROM spark_predictions
WHERE task_id = ? AND prediction_type = 'outcome' AND evaluated_at IS NULL
ORDER BY created_at DESC LIMIT 1
""",
(task_id,),
).fetchone()
if not row:
conn.close()
return None
if not row:
return None
predicted = json.loads(row["predicted_value"])
actual = {
"winner": actual_winner,
"succeeded": task_succeeded,
"winning_bid": winning_bid,
}
predicted = json.loads(row["predicted_value"])
actual = {
"winner": actual_winner,
"succeeded": task_succeeded,
"winning_bid": winning_bid,
}
# Calculate accuracy
accuracy = _compute_accuracy(predicted, actual)
now = datetime.now(UTC).isoformat()
# Calculate accuracy
accuracy = _compute_accuracy(predicted, actual)
now = datetime.now(UTC).isoformat()
conn.execute(
"""
UPDATE spark_predictions
SET actual_value = ?, accuracy = ?, evaluated_at = ?
WHERE id = ?
""",
(json.dumps(actual), accuracy, now, row["id"]),
)
conn.commit()
conn.close()
conn.execute(
"""
UPDATE spark_predictions
SET actual_value = ?, accuracy = ?, evaluated_at = ?
WHERE id = ?
""",
(json.dumps(actual), accuracy, now, row["id"]),
)
conn.commit()
return {
"prediction_id": row["id"],
@@ -243,7 +245,6 @@ def get_predictions(
limit: int = 50,
) -> list[Prediction]:
"""Query stored predictions."""
conn = _get_conn()
query = "SELECT * FROM spark_predictions WHERE 1=1"
params: list = []
@@ -256,8 +257,8 @@ def get_predictions(
query += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
rows = conn.execute(query, params).fetchall()
conn.close()
with _get_conn() as conn:
rows = conn.execute(query, params).fetchall()
return [
Prediction(
id=r["id"],
@@ -275,17 +276,16 @@ def get_predictions(
def get_accuracy_stats() -> dict:
"""Return aggregate accuracy statistics for the EIDOS loop."""
conn = _get_conn()
row = conn.execute("""
SELECT
COUNT(*) AS total_predictions,
COUNT(evaluated_at) AS evaluated,
AVG(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS avg_accuracy,
MIN(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS min_accuracy,
MAX(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS max_accuracy
FROM spark_predictions
""").fetchone()
conn.close()
with _get_conn() as conn:
row = conn.execute("""
SELECT
COUNT(*) AS total_predictions,
COUNT(evaluated_at) AS evaluated,
AVG(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS avg_accuracy,
MIN(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS min_accuracy,
MAX(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS max_accuracy
FROM spark_predictions
""").fetchone()
return {
"total_predictions": row["total_predictions"] or 0,

View File

@@ -273,6 +273,8 @@ class SparkEngine:
def _maybe_consolidate(self, agent_id: str) -> None:
"""Consolidate events into memories when enough data exists."""
from datetime import UTC, datetime, timedelta
agent_events = spark_memory.get_events(agent_id=agent_id, limit=50)
if len(agent_events) < 5:
return
@@ -286,7 +288,34 @@ class SparkEngine:
success_rate = len(completions) / total if total else 0
# Determine target memory type based on success rate
if success_rate >= 0.8:
target_memory_type = "pattern"
elif success_rate <= 0.3:
target_memory_type = "anomaly"
else:
return # No consolidation needed for neutral success rates
# Check for recent memories of the same type for this agent
existing_memories = spark_memory.get_memories(subject=agent_id, limit=5)
now = datetime.now(UTC)
one_hour_ago = now - timedelta(hours=1)
for memory in existing_memories:
if memory.memory_type == target_memory_type:
try:
created_at = datetime.fromisoformat(memory.created_at)
if created_at >= one_hour_ago:
logger.info(
"Consolidation: skipping — recent memory exists for %s",
agent_id[:8],
)
return
except (ValueError, TypeError):
continue
# Store the new memory
if target_memory_type == "pattern":
spark_memory.store_memory(
memory_type="pattern",
subject=agent_id,
@@ -295,7 +324,7 @@ class SparkEngine:
confidence=min(0.95, 0.6 + total * 0.05),
source_events=total,
)
elif success_rate <= 0.3:
else: # anomaly
spark_memory.store_memory(
memory_type="anomaly",
subject=agent_id,
@@ -358,7 +387,8 @@ def get_spark_engine() -> SparkEngine:
from config import settings
_spark_engine = SparkEngine(enabled=settings.spark_enabled)
except Exception:
except Exception as exc:
logger.debug("Spark engine settings load error: %s", exc)
_spark_engine = SparkEngine(enabled=True)
return _spark_engine

View File

@@ -10,12 +10,17 @@ spark_events — raw event log (every swarm event)
spark_memories — consolidated insights extracted from event patterns
"""
import logging
import sqlite3
import uuid
from collections.abc import Generator
from contextlib import closing, contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
logger = logging.getLogger(__name__)
DB_PATH = Path("data/spark.db")
# Importance thresholds
@@ -52,42 +57,43 @@ class SparkMemory:
expires_at: str | None
def _get_conn() -> sqlite3.Connection:
@contextmanager
def _get_conn() -> Generator[sqlite3.Connection, None, None]:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("""
CREATE TABLE IF NOT EXISTS spark_events (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
agent_id TEXT,
task_id TEXT,
description TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL DEFAULT '{}',
importance REAL NOT NULL DEFAULT 0.5,
created_at TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS spark_memories (
id TEXT PRIMARY KEY,
memory_type TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT 'system',
content TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 0.5,
source_events INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
expires_at TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON spark_events(event_type)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_agent ON spark_events(agent_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_task ON spark_events(task_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_subject ON spark_memories(subject)")
conn.commit()
return conn
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("""
CREATE TABLE IF NOT EXISTS spark_events (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
agent_id TEXT,
task_id TEXT,
description TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL DEFAULT '{}',
importance REAL NOT NULL DEFAULT 0.5,
created_at TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS spark_memories (
id TEXT PRIMARY KEY,
memory_type TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT 'system',
content TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 0.5,
source_events INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
expires_at TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON spark_events(event_type)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_agent ON spark_events(agent_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_task ON spark_events(task_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_subject ON spark_memories(subject)")
conn.commit()
yield conn
# ── Importance scoring ──────────────────────────────────────────────────────
@@ -146,17 +152,16 @@ def record_event(
parsed = {}
importance = score_importance(event_type, parsed)
conn = _get_conn()
conn.execute(
"""
INSERT INTO spark_events
(id, event_type, agent_id, task_id, description, data, importance, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(event_id, event_type, agent_id, task_id, description, data, importance, now),
)
conn.commit()
conn.close()
with _get_conn() as conn:
conn.execute(
"""
INSERT INTO spark_events
(id, event_type, agent_id, task_id, description, data, importance, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(event_id, event_type, agent_id, task_id, description, data, importance, now),
)
conn.commit()
# Bridge to unified event log so all events are queryable from one place
try:
@@ -170,7 +175,8 @@ def record_event(
task_id=task_id or "",
agent_id=agent_id or "",
)
except Exception:
except Exception as exc:
logger.debug("Spark event log error: %s", exc)
pass # Graceful — don't break spark if event_log is unavailable
return event_id
@@ -184,7 +190,6 @@ def get_events(
min_importance: float = 0.0,
) -> list[SparkEvent]:
"""Query events with optional filters."""
conn = _get_conn()
query = "SELECT * FROM spark_events WHERE importance >= ?"
params: list = [min_importance]
@@ -201,8 +206,8 @@ def get_events(
query += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
rows = conn.execute(query, params).fetchall()
conn.close()
with _get_conn() as conn:
rows = conn.execute(query, params).fetchall()
return [
SparkEvent(
id=r["id"],
@@ -220,15 +225,14 @@ def get_events(
def count_events(event_type: str | None = None) -> int:
"""Count events, optionally filtered by type."""
conn = _get_conn()
if event_type:
row = conn.execute(
"SELECT COUNT(*) FROM spark_events WHERE event_type = ?",
(event_type,),
).fetchone()
else:
row = conn.execute("SELECT COUNT(*) FROM spark_events").fetchone()
conn.close()
with _get_conn() as conn:
if event_type:
row = conn.execute(
"SELECT COUNT(*) FROM spark_events WHERE event_type = ?",
(event_type,),
).fetchone()
else:
row = conn.execute("SELECT COUNT(*) FROM spark_events").fetchone()
return row[0]
@@ -246,17 +250,16 @@ def store_memory(
"""Store a consolidated memory. Returns the memory id."""
mem_id = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
conn = _get_conn()
conn.execute(
"""
INSERT INTO spark_memories
(id, memory_type, subject, content, confidence, source_events, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(mem_id, memory_type, subject, content, confidence, source_events, now, expires_at),
)
conn.commit()
conn.close()
with _get_conn() as conn:
conn.execute(
"""
INSERT INTO spark_memories
(id, memory_type, subject, content, confidence, source_events, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(mem_id, memory_type, subject, content, confidence, source_events, now, expires_at),
)
conn.commit()
return mem_id
@@ -267,7 +270,6 @@ def get_memories(
limit: int = 50,
) -> list[SparkMemory]:
"""Query memories with optional filters."""
conn = _get_conn()
query = "SELECT * FROM spark_memories WHERE confidence >= ?"
params: list = [min_confidence]
@@ -281,8 +283,8 @@ def get_memories(
query += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
rows = conn.execute(query, params).fetchall()
conn.close()
with _get_conn() as conn:
rows = conn.execute(query, params).fetchall()
return [
SparkMemory(
id=r["id"],
@@ -300,13 +302,12 @@ def get_memories(
def count_memories(memory_type: str | None = None) -> int:
"""Count memories, optionally filtered by type."""
conn = _get_conn()
if memory_type:
row = conn.execute(
"SELECT COUNT(*) FROM spark_memories WHERE memory_type = ?",
(memory_type,),
).fetchone()
else:
row = conn.execute("SELECT COUNT(*) FROM spark_memories").fetchone()
conn.close()
with _get_conn() as conn:
if memory_type:
row = conn.execute(
"SELECT COUNT(*) FROM spark_memories WHERE memory_type = ?",
(memory_type,),
).fetchone()
else:
row = conn.execute("SELECT COUNT(*) FROM spark_memories").fetchone()
return row[0]

View File

@@ -1 +1 @@
"""Timmy — Core AI agent (Ollama/AirLLM backends, CLI, prompts)."""
"""Timmy — Core AI agent (Ollama/Grok/Claude backends, CLI, prompts)."""

View File

@@ -0,0 +1 @@
"""Adapters — normalize external data streams into sensory events."""

View File

@@ -0,0 +1,136 @@
"""Gitea webhook adapter — normalize webhook payloads to event bus events.
Receives raw Gitea webhook payloads and emits typed events via the
infrastructure event bus. Bot-only activity is filtered unless it
represents a PR merge (which is always noteworthy).
"""
import logging
from typing import Any
from infrastructure.events.bus import emit
logger = logging.getLogger(__name__)
# Gitea usernames considered "bot" accounts
BOT_USERNAMES = frozenset({"hermes", "kimi", "manus"})
# Owner username — activity from this user is always emitted
OWNER_USERNAME = "rockachopa"
# Mapping from Gitea webhook event type to our bus event type
_EVENT_TYPE_MAP = {
"push": "gitea.push",
"issues": "gitea.issue.opened",
"issue_comment": "gitea.issue.comment",
"pull_request": "gitea.pull_request",
}
def _extract_actor(payload: dict[str, Any]) -> str:
"""Extract the actor username from a webhook payload."""
# Gitea puts actor in sender.login for most events
sender = payload.get("sender", {})
return sender.get("login", "unknown")
def _is_bot(username: str) -> bool:
return username.lower() in BOT_USERNAMES
def _is_pr_merge(event_type: str, payload: dict[str, Any]) -> bool:
"""Check if this is a pull_request merge event."""
if event_type != "pull_request":
return False
action = payload.get("action", "")
pr = payload.get("pull_request", {})
return action == "closed" and pr.get("merged", False)
def _normalize_push(payload: dict[str, Any], actor: str) -> dict[str, Any]:
"""Normalize a push event payload."""
commits = payload.get("commits", [])
return {
"actor": actor,
"ref": payload.get("ref", ""),
"repo": payload.get("repository", {}).get("full_name", ""),
"num_commits": len(commits),
"head_message": commits[0].get("message", "").split("\n", 1)[0].strip() if commits else "",
}
def _normalize_issue_opened(payload: dict[str, Any], actor: str) -> dict[str, Any]:
"""Normalize an issue-opened event payload."""
issue = payload.get("issue", {})
return {
"actor": actor,
"action": payload.get("action", "opened"),
"repo": payload.get("repository", {}).get("full_name", ""),
"issue_number": issue.get("number", 0),
"title": issue.get("title", ""),
}
def _normalize_issue_comment(payload: dict[str, Any], actor: str) -> dict[str, Any]:
"""Normalize an issue-comment event payload."""
issue = payload.get("issue", {})
comment = payload.get("comment", {})
return {
"actor": actor,
"action": payload.get("action", "created"),
"repo": payload.get("repository", {}).get("full_name", ""),
"issue_number": issue.get("number", 0),
"issue_title": issue.get("title", ""),
"comment_body": (comment.get("body", "")[:200]),
}
def _normalize_pull_request(payload: dict[str, Any], actor: str) -> dict[str, Any]:
"""Normalize a pull-request event payload."""
pr = payload.get("pull_request", {})
return {
"actor": actor,
"action": payload.get("action", ""),
"repo": payload.get("repository", {}).get("full_name", ""),
"pr_number": pr.get("number", 0),
"title": pr.get("title", ""),
"merged": pr.get("merged", False),
}
_NORMALIZERS = {
"push": _normalize_push,
"issues": _normalize_issue_opened,
"issue_comment": _normalize_issue_comment,
"pull_request": _normalize_pull_request,
}
async def handle_webhook(event_type: str, payload: dict[str, Any]) -> bool:
"""Normalize a Gitea webhook payload and emit it to the event bus.
Args:
event_type: The Gitea event type header (e.g. "push", "issues").
payload: The raw JSON payload from the webhook.
Returns:
True if an event was emitted, False if filtered or unsupported.
"""
bus_event_type = _EVENT_TYPE_MAP.get(event_type)
if bus_event_type is None:
logger.debug("Unsupported Gitea event type: %s", event_type)
return False
actor = _extract_actor(payload)
# Filter bot-only activity — except PR merges
if _is_bot(actor) and not _is_pr_merge(event_type, payload):
logger.debug("Filtered bot activity from %s on %s", actor, event_type)
return False
normalizer = _NORMALIZERS[event_type]
data = normalizer(payload, actor)
await emit(bus_event_type, source="gitea", data=data)
logger.info("Emitted %s from %s", bus_event_type, actor)
return True

View File

@@ -0,0 +1,82 @@
"""Time adapter — circadian awareness for Timmy.
Emits time-of-day events so Timmy knows the current period
and tracks how long since the last user interaction.
"""
import logging
from datetime import UTC, datetime
from infrastructure.events.bus import emit
logger = logging.getLogger(__name__)
# Time-of-day periods: (event_name, start_hour, end_hour)
_PERIODS = [
("morning", 6, 9),
("afternoon", 12, 14),
("evening", 18, 20),
("late_night", 23, 24),
("late_night", 0, 3),
]
def classify_period(hour: int) -> str | None:
"""Return the circadian period name for a given hour, or None."""
for name, start, end in _PERIODS:
if start <= hour < end:
return name
return None
class TimeAdapter:
"""Emits circadian and interaction-tracking events."""
def __init__(self) -> None:
self._last_interaction: datetime | None = None
self._last_period: str | None = None
self._last_date: str | None = None
def record_interaction(self, now: datetime | None = None) -> None:
"""Record a user interaction timestamp."""
self._last_interaction = now or datetime.now(UTC)
def time_since_last_interaction(
self,
now: datetime | None = None,
) -> float | None:
"""Seconds since last user interaction, or None if no interaction."""
if self._last_interaction is None:
return None
current = now or datetime.now(UTC)
return (current - self._last_interaction).total_seconds()
async def tick(self, now: datetime | None = None) -> list[str]:
"""Check current time and emit relevant events.
Returns list of event types emitted (useful for testing).
"""
current = now or datetime.now(UTC)
emitted: list[str] = []
# --- new_day ---
date_str = current.strftime("%Y-%m-%d")
if self._last_date is not None and date_str != self._last_date:
event_type = "time.new_day"
await emit(event_type, source="time_adapter", data={"date": date_str})
emitted.append(event_type)
self._last_date = date_str
# --- circadian period ---
period = classify_period(current.hour)
if period is not None and period != self._last_period:
event_type = f"time.{period}"
await emit(
event_type,
source="time_adapter",
data={"hour": current.hour, "period": period},
)
emitted.append(event_type)
self._last_period = period
return emitted

View File

@@ -16,6 +16,7 @@ Handoff Protocol maintains continuity across sessions.
import logging
from typing import TYPE_CHECKING, Union
import httpx
from agno.agent import Agent
from agno.db.sqlite import SqliteDb
from agno.models.ollama import Ollama
@@ -25,30 +26,12 @@ from timmy.prompts import get_system_prompt
from timmy.tools import create_full_toolkit
if TYPE_CHECKING:
from timmy.backends import ClaudeBackend, GrokBackend, TimmyAirLLMAgent
from timmy.backends import ClaudeBackend, GrokBackend
logger = logging.getLogger(__name__)
# Fallback chain for text/tool models (in order of preference)
DEFAULT_MODEL_FALLBACKS = [
"llama3.1:8b-instruct",
"llama3.1",
"qwen3.5:latest",
"qwen2.5:14b",
"qwen2.5:7b",
"llama3.2:3b",
]
# Fallback chain for vision models
VISION_MODEL_FALLBACKS = [
"llama3.2:3b",
"llava:7b",
"qwen2.5-vl:3b",
"moondream:1.8b",
]
# Union type for callers that want to hint the return type.
TimmyAgent = Union[Agent, "TimmyAirLLMAgent", "GrokBackend", "ClaudeBackend"]
TimmyAgent = Union[Agent, "GrokBackend", "ClaudeBackend"]
# Models known to be too small for reliable tool calling.
# These hallucinate tool calls as text, invoke tools randomly,
@@ -80,7 +63,7 @@ def _pull_model(model_name: str) -> bool:
logger.info("Pulling model: %s", model_name)
url = settings.ollama_url.replace("localhost", "127.0.0.1")
url = settings.normalized_ollama_url
req = urllib.request.Request(
f"{url}/api/pull",
method="POST",
@@ -130,8 +113,8 @@ def _resolve_model_with_fallback(
return model, False
logger.warning("Failed to pull %s, checking fallbacks...", model)
# Use appropriate fallback chain
fallback_chain = VISION_MODEL_FALLBACKS if require_vision else DEFAULT_MODEL_FALLBACKS
# Use appropriate configurable fallback chain (from settings / env vars)
fallback_chain = settings.vision_fallback_models if require_vision else settings.fallback_models
for fallback_model in fallback_chain:
if _check_model_available(fallback_model):
@@ -162,99 +145,61 @@ def _model_supports_tools(model_name: str) -> bool:
return True
def _resolve_backend(requested: str | None) -> str:
"""Return the backend name to use, resolving 'auto' and explicit overrides.
def _warmup_model(model_name: str) -> bool:
"""Warm up an Ollama model by sending a minimal generation request.
Priority (highest → lowest):
This prevents 'Server disconnected' errors on first request after cold model load.
Cold loads can take 30-40s, so we use a 60s timeout.
Args:
model_name: Name of the Ollama model to warm up
Returns:
True if warmup succeeded, False otherwise (does not raise)
"""
try:
response = httpx.post(
f"{settings.ollama_url}/api/generate",
json={"model": model_name, "prompt": "hi", "options": {"num_predict": 1}},
timeout=60.0,
)
response.raise_for_status()
logger.info("Model %s warmed up successfully", model_name)
return True
except Exception as exc:
logger.warning("Model warmup failed: %s — first request may disconnect", exc)
return False
def _resolve_backend(requested: str | None) -> str:
"""Return the backend name to use.
Priority (highest -> lowest):
1. CLI flag passed directly to create_timmy()
2. TIMMY_MODEL_BACKEND env var / .env setting
3. 'ollama' (safe default no surprises)
'auto' triggers Apple Silicon detection: uses AirLLM if both
is_apple_silicon() and airllm_available() return True.
3. 'ollama' (safe default -- no surprises)
"""
if requested is not None:
return requested
configured = settings.timmy_model_backend # "ollama" | "airllm" | "grok" | "claude" | "auto"
if configured != "auto":
return configured
# "auto" path — lazy import to keep startup fast and tests clean.
from timmy.backends import airllm_available, is_apple_silicon
if is_apple_silicon() and airllm_available():
return "airllm"
return "ollama"
return settings.timmy_model_backend # "ollama" | "grok" | "claude"
def create_timmy(
db_file: str = "timmy.db",
backend: str | None = None,
model_size: str | None = None,
) -> TimmyAgent:
"""Instantiate the agent — Ollama or AirLLM, same public interface.
def _build_tools_list(use_tools: bool, skip_mcp: bool, model_name: str) -> list:
"""Assemble the tools list based on model capability and MCP flags.
Args:
db_file: SQLite file for Agno conversation memory (Ollama path only).
backend: "ollama" | "airllm" | "auto" | None (reads config/env).
model_size: AirLLM size — "8b" | "70b" | "405b" | None (reads config).
Returns an Agno Agent or backend-specific agent — all expose
print_response(message, stream).
Returns a list of Toolkit / MCPTools objects, or an empty list.
"""
resolved = _resolve_backend(backend)
size = model_size or settings.airllm_model_size
if resolved == "claude":
from timmy.backends import ClaudeBackend
return ClaudeBackend()
if resolved == "grok":
from timmy.backends import GrokBackend
return GrokBackend()
if resolved == "airllm":
from timmy.backends import TimmyAirLLMAgent
return TimmyAirLLMAgent(model_size=size)
# Default: Ollama via Agno.
# Resolve model with automatic pulling and fallback
model_name, is_fallback = _resolve_model_with_fallback(
requested_model=None,
require_vision=False,
auto_pull=True,
)
# If Ollama is completely unreachable, fail loudly.
# Sovereignty: never silently send data to a cloud API.
# Use --backend claude explicitly if you want cloud inference.
if not _check_model_available(model_name):
logger.error(
"Ollama unreachable and no local models available. "
"Start Ollama with 'ollama serve' or use --backend claude explicitly."
)
if is_fallback:
logger.info("Using fallback model %s (requested was unavailable)", model_name)
use_tools = _model_supports_tools(model_name)
# Conditionally include tools — small models get none
toolkit = create_full_toolkit() if use_tools else None
if not use_tools:
logger.info("Tools disabled for model %s (too small for reliable tool calling)", model_name)
return []
# Build the tools list — Agno accepts a list of Toolkit / MCPTools
tools_list: list = []
if toolkit:
tools_list.append(toolkit)
tools_list: list = [create_full_toolkit()]
# Add MCP tool servers (lazy-connected on first arun())
if use_tools:
# Add MCP tool servers (lazy-connected on first arun()).
# Skipped when skip_mcp=True — MCP's stdio transport uses anyio cancel
# scopes that conflict with asyncio background task cancellation (#72).
if not skip_mcp:
try:
from timmy.mcp_tools import create_filesystem_mcp_tools, create_gitea_mcp_tools
@@ -268,39 +213,121 @@ def create_timmy(
except Exception as exc:
logger.debug("MCP tools unavailable: %s", exc)
# Select prompt tier based on tool capability
base_prompt = get_system_prompt(tools_enabled=use_tools)
return tools_list
def _build_prompt(use_tools: bool, session_id: str) -> str:
"""Build the full system prompt with optional memory context."""
base_prompt = get_system_prompt(tools_enabled=use_tools, session_id=session_id)
# Try to load memory context
try:
from timmy.memory_system import memory_system
memory_context = memory_system.get_system_context()
if memory_context:
# Truncate if too long — smaller budget for small models
# since the expanded prompt (roster, guardrails) uses more tokens
# Smaller budget for small models — expanded prompt uses more tokens
max_context = 2000 if not use_tools else 8000
if len(memory_context) > max_context:
memory_context = memory_context[:max_context] + "\n... [truncated]"
full_prompt = f"{base_prompt}\n\n## Memory Context\n\n{memory_context}"
else:
full_prompt = base_prompt
return (
f"{base_prompt}\n\n"
f"## GROUNDED CONTEXT (verified sources — cite when using)\n\n"
f"{memory_context}"
)
except Exception as exc:
logger.warning("Failed to load memory context: %s", exc)
full_prompt = base_prompt
return Agent(
return base_prompt
def _create_ollama_agent(
*,
db_file: str,
model_name: str,
tools_list: list,
full_prompt: str,
use_tools: bool,
) -> Agent:
"""Construct the Agno Agent with Ollama backend and warm up the model."""
model_kwargs = {}
if settings.ollama_num_ctx > 0:
model_kwargs["options"] = {"num_ctx": settings.ollama_num_ctx}
agent = Agent(
name="Agent",
model=Ollama(id=model_name, host=settings.ollama_url, timeout=300),
model=Ollama(id=model_name, host=settings.ollama_url, timeout=300, **model_kwargs),
db=SqliteDb(db_file=db_file),
description=full_prompt,
add_history_to_context=True,
num_history_runs=20,
markdown=True,
markdown=False,
tools=tools_list if tools_list else None,
tool_call_limit=settings.max_agent_steps if use_tools else None,
telemetry=settings.telemetry_enabled,
)
_warmup_model(model_name)
return agent
def create_timmy(
db_file: str = "timmy.db",
backend: str | None = None,
*,
skip_mcp: bool = False,
session_id: str = "unknown",
) -> TimmyAgent:
"""Instantiate the agent — Ollama, Grok, or Claude.
Args:
db_file: SQLite file for Agno conversation memory (Ollama path only).
backend: "ollama" | "grok" | "claude" | None (reads config/env).
skip_mcp: If True, omit MCP tool servers (Gitea, filesystem).
Use for background tasks (thinking, QA) where MCP's
stdio cancel-scope lifecycle conflicts with asyncio
task cancellation.
Returns an Agno Agent or backend-specific agent — all expose
print_response(message, stream).
"""
resolved = _resolve_backend(backend)
if resolved == "claude":
from timmy.backends import ClaudeBackend
return ClaudeBackend()
if resolved == "grok":
from timmy.backends import GrokBackend
return GrokBackend()
# Default: Ollama via Agno.
model_name, is_fallback = _resolve_model_with_fallback(
requested_model=None,
require_vision=False,
auto_pull=True,
)
if not _check_model_available(model_name):
logger.error(
"Ollama unreachable and no local models available. "
"Start Ollama with 'ollama serve' or use --backend claude explicitly."
)
if is_fallback:
logger.info("Using fallback model %s (requested was unavailable)", model_name)
use_tools = _model_supports_tools(model_name)
tools_list = _build_tools_list(use_tools, skip_mcp, model_name)
full_prompt = _build_prompt(use_tools, session_id)
return _create_ollama_agent(
db_file=db_file,
model_name=model_name,
tools_list=tools_list,
full_prompt=full_prompt,
use_tools=use_tools,
)
class TimmyWithMemory:
@@ -317,15 +344,47 @@ class TimmyWithMemory:
self.initial_context = self.memory.get_system_context()
def chat(self, message: str) -> str:
"""Simple chat interface that tracks in memory."""
"""Simple chat interface that tracks in memory.
Retries on transient Ollama errors (GPU contention, timeouts)
with exponential backoff (#70).
"""
import time
# Check for user facts to extract
self._extract_and_store_facts(message)
# Run agent
result = self.agent.run(message, stream=False)
response_text = result.content if hasattr(result, "content") else str(result)
return response_text
# Retry with backoff — GPU contention causes ReadError/ReadTimeout
max_retries = 3
for attempt in range(1, max_retries + 1):
try:
result = self.agent.run(message, stream=False)
return result.content if hasattr(result, "content") else str(result)
except (
httpx.ConnectError,
httpx.ReadError,
httpx.ReadTimeout,
httpx.ConnectTimeout,
ConnectionError,
TimeoutError,
) as exc:
if attempt < max_retries:
wait = min(2**attempt, 16)
logger.warning(
"Ollama contention on attempt %d/%d: %s. Waiting %ds before retry...",
attempt,
max_retries,
type(exc).__name__,
wait,
)
time.sleep(wait)
else:
logger.error(
"Ollama unreachable after %d attempts: %s",
max_retries,
exc,
)
raise
def _extract_and_store_facts(self, message: str) -> None:
"""Extract user facts from message and store in memory."""
@@ -336,7 +395,8 @@ class TimmyWithMemory:
if name:
self.memory.update_user_fact("Name", name)
self.memory.record_decision(f"Learned user's name: {name}")
except Exception:
except Exception as exc:
logger.warning("User name extraction failed: %s", exc)
pass # Best-effort extraction
def end_session(self, summary: str = "Session completed") -> None:

View File

@@ -1 +0,0 @@
"""Agent Core — Substrate-agnostic agent interface and base classes."""

View File

@@ -1,381 +0,0 @@
"""TimAgent Interface — The substrate-agnostic agent contract.
This is the foundation for embodiment. Whether Timmy runs on:
- A server with Ollama (today)
- A Raspberry Pi with sensors
- A Boston Dynamics Spot robot
- A VR avatar
The interface remains constant. Implementation varies.
Architecture:
perceive() → reason → act()
↑ ↓
←←← remember() ←←←←←←┘
All methods return effects that can be logged, audited, and replayed.
"""
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum, auto
from typing import Any
class PerceptionType(Enum):
"""Types of sensory input an agent can receive."""
TEXT = auto() # Natural language
IMAGE = auto() # Visual input
AUDIO = auto() # Sound/speech
SENSOR = auto() # Temperature, distance, etc.
MOTION = auto() # Accelerometer, gyroscope
NETWORK = auto() # API calls, messages
INTERNAL = auto() # Self-monitoring (battery, temp)
class ActionType(Enum):
"""Types of actions an agent can perform."""
TEXT = auto() # Generate text response
SPEAK = auto() # Text-to-speech
MOVE = auto() # Physical movement
GRIP = auto() # Manipulate objects
CALL = auto() # API/network call
EMIT = auto() # Signal/light/sound
SLEEP = auto() # Power management
class AgentCapability(Enum):
"""High-level capabilities a TimAgent may possess."""
REASONING = "reasoning"
CODING = "coding"
WRITING = "writing"
ANALYSIS = "analysis"
VISION = "vision"
SPEECH = "speech"
NAVIGATION = "navigation"
MANIPULATION = "manipulation"
LEARNING = "learning"
COMMUNICATION = "communication"
@dataclass(frozen=True)
class AgentIdentity:
"""Immutable identity for an agent instance.
This persists across sessions and substrates. If Timmy moves
from cloud to robot, the identity follows.
"""
id: str
name: str
version: str
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
@classmethod
def generate(cls, name: str, version: str = "1.0.0") -> "AgentIdentity":
"""Generate a new unique identity."""
return cls(
id=str(uuid.uuid4()),
name=name,
version=version,
)
@dataclass
class Perception:
"""A sensory input to the agent.
Substrate-agnostic representation. A camera image and a
LiDAR point cloud are both Perception instances.
"""
type: PerceptionType
data: Any # Content depends on type
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
source: str = "unknown" # e.g., "camera_1", "microphone", "user_input"
metadata: dict = field(default_factory=dict)
@classmethod
def text(cls, content: str, source: str = "user") -> "Perception":
"""Factory for text perception."""
return cls(
type=PerceptionType.TEXT,
data=content,
source=source,
)
@classmethod
def sensor(cls, kind: str, value: float, unit: str = "") -> "Perception":
"""Factory for sensor readings."""
return cls(
type=PerceptionType.SENSOR,
data={"kind": kind, "value": value, "unit": unit},
source=f"sensor_{kind}",
)
@dataclass
class Action:
"""An action the agent intends to perform.
Actions are effects — they describe what should happen,
not how. The substrate implements the "how."
"""
type: ActionType
payload: Any # Action-specific data
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
confidence: float = 1.0 # 0-1, agent's certainty
deadline: str | None = None # When action must complete
@classmethod
def respond(cls, text: str, confidence: float = 1.0) -> "Action":
"""Factory for text response action."""
return cls(
type=ActionType.TEXT,
payload=text,
confidence=confidence,
)
@classmethod
def move(cls, vector: tuple[float, float, float], speed: float = 1.0) -> "Action":
"""Factory for movement action (x, y, z meters)."""
return cls(
type=ActionType.MOVE,
payload={"vector": vector, "speed": speed},
)
@dataclass
class Memory:
"""A stored experience or fact.
Memories are substrate-agnostic. A conversation history
and a video recording are both Memory instances.
"""
id: str
content: Any
created_at: str
access_count: int = 0
last_accessed: str | None = None
importance: float = 0.5 # 0-1, for pruning decisions
tags: list[str] = field(default_factory=list)
def touch(self) -> None:
"""Mark memory as accessed."""
self.access_count += 1
self.last_accessed = datetime.now(UTC).isoformat()
@dataclass
class Communication:
"""A message to/from another agent or human."""
sender: str
recipient: str
content: Any
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
protocol: str = "direct" # e.g., "http", "websocket", "speech"
encrypted: bool = False
class TimAgent(ABC):
"""Abstract base class for all Timmy agent implementations.
This is the substrate-agnostic interface. Implementations:
- OllamaAgent: LLM-based reasoning (today)
- RobotAgent: Physical embodiment (future)
- SimulationAgent: Virtual environment (future)
Usage:
agent = OllamaAgent(identity) # Today's implementation
perception = Perception.text("Hello Timmy")
memory = agent.perceive(perception)
action = agent.reason("How should I respond?")
result = agent.act(action)
agent.remember(memory) # Store for future
"""
def __init__(self, identity: AgentIdentity) -> None:
self._identity = identity
self._capabilities: set[AgentCapability] = set()
self._state: dict[str, Any] = {}
@property
def identity(self) -> AgentIdentity:
"""Return this agent's immutable identity."""
return self._identity
@property
def capabilities(self) -> set[AgentCapability]:
"""Return set of supported capabilities."""
return self._capabilities.copy()
def has_capability(self, capability: AgentCapability) -> bool:
"""Check if agent supports a capability."""
return capability in self._capabilities
@abstractmethod
def perceive(self, perception: Perception) -> Memory:
"""Process sensory input and create a memory.
This is the entry point for all agent interaction.
A text message, camera frame, or temperature reading
all enter through perceive().
Args:
perception: Sensory input
Returns:
Memory: Stored representation of the perception
"""
pass
@abstractmethod
def reason(self, query: str, context: list[Memory]) -> Action:
"""Reason about a situation and decide on action.
This is where "thinking" happens. The agent uses its
substrate-appropriate reasoning (LLM, neural net, rules)
to decide what to do.
Args:
query: What to reason about
context: Relevant memories for context
Returns:
Action: What the agent decides to do
"""
pass
@abstractmethod
def act(self, action: Action) -> Any:
"""Execute an action in the substrate.
This is where the abstract action becomes concrete:
- TEXT → Generate LLM response
- MOVE → Send motor commands
- SPEAK → Call TTS engine
Args:
action: The action to execute
Returns:
Result of the action (substrate-specific)
"""
pass
@abstractmethod
def remember(self, memory: Memory) -> None:
"""Store a memory for future retrieval.
The storage mechanism depends on substrate:
- Cloud: SQLite, vector DB
- Robot: Local flash storage
- Hybrid: Synced with conflict resolution
Args:
memory: Experience to store
"""
pass
@abstractmethod
def recall(self, query: str, limit: int = 5) -> list[Memory]:
"""Retrieve relevant memories.
Args:
query: What to search for
limit: Maximum memories to return
Returns:
List of relevant memories, sorted by relevance
"""
pass
@abstractmethod
def communicate(self, message: Communication) -> bool:
"""Send/receive communication with another agent.
Args:
message: Message to send
Returns:
True if communication succeeded
"""
pass
def get_state(self) -> dict[str, Any]:
"""Get current agent state for monitoring/debugging."""
return {
"identity": self._identity,
"capabilities": list(self._capabilities),
"state": self._state.copy(),
}
def shutdown(self) -> None: # noqa: B027
"""Graceful shutdown. Persist state, close connections."""
# Override in subclass for cleanup
class AgentEffect:
"""Log entry for agent actions — for audit and replay.
The complete history of an agent's life can be captured
as a sequence of AgentEffects. This enables:
- Debugging: What did the agent see and do?
- Audit: Why did it make that decision?
- Replay: Reconstruct agent state from log
- Training: Learn from agent experiences
"""
def __init__(self, log_path: str | None = None) -> None:
self._effects: list[dict] = []
self._log_path = log_path
def log_perceive(self, perception: Perception, memory_id: str) -> None:
"""Log a perception event."""
self._effects.append(
{
"type": "perceive",
"perception_type": perception.type.name,
"source": perception.source,
"memory_id": memory_id,
"timestamp": datetime.now(UTC).isoformat(),
}
)
def log_reason(self, query: str, action_type: ActionType) -> None:
"""Log a reasoning event."""
self._effects.append(
{
"type": "reason",
"query": query,
"action_type": action_type.name,
"timestamp": datetime.now(UTC).isoformat(),
}
)
def log_act(self, action: Action, result: Any) -> None:
"""Log an action event."""
self._effects.append(
{
"type": "act",
"action_type": action.type.name,
"confidence": action.confidence,
"result_type": type(result).__name__,
"timestamp": datetime.now(UTC).isoformat(),
}
)
def export(self) -> list[dict]:
"""Export effect log for analysis."""
return self._effects.copy()

View File

@@ -1,275 +0,0 @@
"""Ollama-based implementation of TimAgent interface.
This adapter wraps the existing Timmy Ollama agent to conform
to the substrate-agnostic TimAgent interface. It's the bridge
between the old codebase and the new embodiment-ready architecture.
Usage:
from timmy.agent_core import AgentIdentity, Perception
from timmy.agent_core.ollama_adapter import OllamaAgent
identity = AgentIdentity.generate("Timmy")
agent = OllamaAgent(identity)
perception = Perception.text("Hello!")
memory = agent.perceive(perception)
action = agent.reason("How should I respond?", [memory])
result = agent.act(action)
"""
from typing import Any
from timmy.agent import _resolve_model_with_fallback, create_timmy
from timmy.agent_core.interface import (
Action,
ActionType,
AgentCapability,
AgentEffect,
AgentIdentity,
Communication,
Memory,
Perception,
PerceptionType,
TimAgent,
)
class OllamaAgent(TimAgent):
"""TimAgent implementation using local Ollama LLM.
This is the production agent for Timmy Time v2. It uses
Ollama for reasoning and SQLite for memory persistence.
Capabilities:
- REASONING: LLM-based inference
- CODING: Code generation and analysis
- WRITING: Long-form content creation
- ANALYSIS: Data processing and insights
- COMMUNICATION: Multi-agent messaging
"""
def __init__(
self,
identity: AgentIdentity,
model: str | None = None,
effect_log: str | None = None,
require_vision: bool = False,
) -> None:
"""Initialize Ollama-based agent.
Args:
identity: Agent identity (persistent across sessions)
model: Ollama model to use (auto-resolves with fallback)
effect_log: Path to log agent effects (optional)
require_vision: Whether to select a vision-capable model
"""
super().__init__(identity)
# Resolve model with automatic pulling and fallback
resolved_model, is_fallback = _resolve_model_with_fallback(
requested_model=model,
require_vision=require_vision,
auto_pull=True,
)
if is_fallback:
import logging
logging.getLogger(__name__).info(
"OllamaAdapter using fallback model %s", resolved_model
)
# Initialize underlying Ollama agent
self._timmy = create_timmy(model=resolved_model)
# Set capabilities based on what Ollama can do
self._capabilities = {
AgentCapability.REASONING,
AgentCapability.CODING,
AgentCapability.WRITING,
AgentCapability.ANALYSIS,
AgentCapability.COMMUNICATION,
}
# Effect logging for audit/replay
self._effect_log = AgentEffect(effect_log) if effect_log else None
# Simple in-memory working memory (short term)
self._working_memory: list[Memory] = []
self._max_working_memory = 10
def perceive(self, perception: Perception) -> Memory:
"""Process perception and store in memory.
For text perceptions, we might do light preprocessing
(summarization, keyword extraction) before storage.
"""
# Create memory from perception
memory = Memory(
id=f"mem_{len(self._working_memory)}",
content={
"type": perception.type.name,
"data": perception.data,
"source": perception.source,
},
created_at=perception.timestamp,
tags=self._extract_tags(perception),
)
# Add to working memory
self._working_memory.append(memory)
if len(self._working_memory) > self._max_working_memory:
self._working_memory.pop(0) # FIFO eviction
# Log effect
if self._effect_log:
self._effect_log.log_perceive(perception, memory.id)
return memory
def reason(self, query: str, context: list[Memory]) -> Action:
"""Use LLM to reason and decide on action.
This is where the Ollama agent does its work. We construct
a prompt from the query and context, then interpret the
response as an action.
"""
# Build context string from memories
context_str = self._format_context(context)
# Construct prompt
prompt = f"""You are {self._identity.name}, an AI assistant.
Context from previous interactions:
{context_str}
Current query: {query}
Respond naturally and helpfully."""
# Run LLM inference
result = self._timmy.run(prompt, stream=False)
response_text = result.content if hasattr(result, "content") else str(result)
# Create text response action
action = Action.respond(response_text, confidence=0.9)
# Log effect
if self._effect_log:
self._effect_log.log_reason(query, action.type)
return action
def act(self, action: Action) -> Any:
"""Execute action in the Ollama substrate.
For text actions, the "execution" is just returning the
text (already generated during reasoning). For future
action types (MOVE, SPEAK), this would trigger the
appropriate Ollama tool calls.
"""
result = None
if action.type == ActionType.TEXT:
result = action.payload
elif action.type == ActionType.SPEAK:
# Would call TTS here
result = {"spoken": action.payload, "tts_engine": "pyttsx3"}
elif action.type == ActionType.CALL:
# Would make API call
result = {"status": "not_implemented", "payload": action.payload}
else:
result = {"error": f"Action type {action.type} not supported by OllamaAgent"}
# Log effect
if self._effect_log:
self._effect_log.log_act(action, result)
return result
def remember(self, memory: Memory) -> None:
"""Store memory in working memory.
Adds the memory to the sliding window and bumps its importance.
"""
memory.touch()
# Deduplicate by id
self._working_memory = [m for m in self._working_memory if m.id != memory.id]
self._working_memory.append(memory)
# Evict oldest if over capacity
if len(self._working_memory) > self._max_working_memory:
self._working_memory.pop(0)
def recall(self, query: str, limit: int = 5) -> list[Memory]:
"""Retrieve relevant memories.
Simple keyword matching for now. Future: vector similarity.
"""
query_lower = query.lower()
scored = []
for memory in self._working_memory:
score = 0
content_str = str(memory.content).lower()
# Simple keyword overlap
query_words = set(query_lower.split())
content_words = set(content_str.split())
overlap = len(query_words & content_words)
score += overlap
# Boost recent memories
score += memory.importance
scored.append((score, memory))
# Sort by score descending
scored.sort(key=lambda x: x[0], reverse=True)
# Return top N
return [m for _, m in scored[:limit]]
def communicate(self, message: Communication) -> bool:
"""Send message to another agent.
Swarm comms removed — inter-agent communication will be handled
by the unified brain memory layer.
"""
return False
def _extract_tags(self, perception: Perception) -> list[str]:
"""Extract searchable tags from perception."""
tags = [perception.type.name, perception.source]
if perception.type == PerceptionType.TEXT:
# Simple keyword extraction
text = str(perception.data).lower()
keywords = ["code", "bug", "help", "question", "task"]
for kw in keywords:
if kw in text:
tags.append(kw)
return tags
def _format_context(self, memories: list[Memory]) -> str:
"""Format memories into context string for prompt."""
if not memories:
return "No previous context."
parts = []
for mem in memories[-5:]: # Last 5 memories
if isinstance(mem.content, dict):
data = mem.content.get("data", "")
parts.append(f"- {data}")
else:
parts.append(f"- {mem.content}")
return "\n".join(parts)
def get_effect_log(self) -> list[dict] | None:
"""Export effect log if logging is enabled."""
if self._effect_log:
return self._effect_log.export()
return None

View File

@@ -18,6 +18,7 @@ from __future__ import annotations
import asyncio
import logging
import re
import threading
import time
import uuid
from collections.abc import Callable
@@ -58,6 +59,9 @@ class AgenticResult:
# Agent factory
# ---------------------------------------------------------------------------
_loop_agent = None
_loop_agent_lock = threading.Lock()
def _get_loop_agent():
"""Create a fresh agent for the agentic loop.
@@ -65,9 +69,14 @@ def _get_loop_agent():
Returns the same type of agent as `create_timmy()` but with a
dedicated session so it doesn't pollute the main chat history.
"""
from timmy.agent import create_timmy
global _loop_agent
if _loop_agent is None:
with _loop_agent_lock:
if _loop_agent is None:
from timmy.agent import create_timmy
return create_timmy()
_loop_agent = create_timmy()
return _loop_agent
# ---------------------------------------------------------------------------
@@ -86,6 +95,126 @@ def _parse_steps(plan_text: str) -> list[str]:
return [line.strip() for line in plan_text.strip().splitlines() if line.strip()]
# ---------------------------------------------------------------------------
# Extracted helpers
# ---------------------------------------------------------------------------
def _extract_content(run_result) -> str:
"""Extract text content from an agent run result."""
return run_result.content if hasattr(run_result, "content") else str(run_result)
def _clean(text: str) -> str:
"""Clean a model response using session's response cleaner."""
from timmy.session import _clean_response
return _clean_response(text)
async def _plan_task(
agent, task: str, session_id: str, max_steps: int
) -> tuple[list[str], bool] | str:
"""Run the planning phase — returns (steps, was_truncated) or error string."""
plan_prompt = (
f"Break this task into numbered steps (max {max_steps}). "
f"Return ONLY a numbered list, nothing else.\n\n"
f"Task: {task}"
)
try:
plan_run = await asyncio.to_thread(
agent.run, plan_prompt, stream=False, session_id=f"{session_id}_plan"
)
plan_text = _extract_content(plan_run)
except Exception as exc: # broad catch intentional: agent.run can raise any error
logger.error("Agentic loop: planning failed: %s", exc)
return f"Planning failed: {exc}"
steps = _parse_steps(plan_text)
if not steps:
return "Planning produced no steps."
planned_count = len(steps)
steps = steps[:max_steps]
return steps, planned_count > len(steps)
async def _execute_step(
agent,
task: str,
step_desc: str,
step_num: int,
total_steps: int,
recent_results: list[str],
session_id: str,
) -> AgenticStep:
"""Execute a single step, returning an AgenticStep."""
step_start = time.monotonic()
context = (
f"Task: {task}\n"
f"Step {step_num}/{total_steps}: {step_desc}\n"
f"Recent progress: {recent_results[-2:] if recent_results else []}\n\n"
f"Execute this step and report what you did."
)
step_run = await asyncio.to_thread(
agent.run, context, stream=False, session_id=f"{session_id}_step{step_num}"
)
step_result = _clean(_extract_content(step_run))
return AgenticStep(
step_num=step_num,
description=step_desc,
result=step_result,
status="completed",
duration_ms=int((time.monotonic() - step_start) * 1000),
)
async def _adapt_step(
agent,
step_desc: str,
step_num: int,
error: Exception,
step_start: float,
session_id: str,
) -> AgenticStep:
"""Attempt adaptation after a step failure."""
adapt_prompt = (
f"Step {step_num} failed with error: {error}\n"
f"Original step was: {step_desc}\n"
f"Adapt the plan and try an alternative approach for this step."
)
adapt_run = await asyncio.to_thread(
agent.run, adapt_prompt, stream=False, session_id=f"{session_id}_adapt{step_num}"
)
adapt_result = _clean(_extract_content(adapt_run))
return AgenticStep(
step_num=step_num,
description=f"[Adapted] {step_desc}",
result=adapt_result,
status="adapted",
duration_ms=int((time.monotonic() - step_start) * 1000),
)
def _summarize(result: AgenticResult, total_steps: int, was_truncated: bool) -> None:
"""Fill in summary and final status on the result object (mutates in place)."""
completed = sum(1 for s in result.steps if s.status == "completed")
adapted = sum(1 for s in result.steps if s.status == "adapted")
failed = sum(1 for s in result.steps if s.status == "failed")
parts = [f"Completed {completed}/{total_steps} steps"]
if adapted:
parts.append(f"{adapted} adapted")
if failed:
parts.append(f"{failed} failed")
result.summary = f"{result.task}: {', '.join(parts)}."
if was_truncated or len(result.steps) < total_steps or failed:
result.status = "partial"
else:
result.status = "completed"
# ---------------------------------------------------------------------------
# Core loop
# ---------------------------------------------------------------------------
@@ -116,88 +245,41 @@ async def run_agentic_loop(
task_id = str(uuid.uuid4())[:8]
start_time = time.monotonic()
agent = _get_loop_agent()
result = AgenticResult(task_id=task_id, task=task, summary="")
# ── Phase 1: Planning ──────────────────────────────────────────────────
plan_prompt = (
f"Break this task into numbered steps (max {max_steps}). "
f"Return ONLY a numbered list, nothing else.\n\n"
f"Task: {task}"
)
try:
plan_run = await asyncio.to_thread(
agent.run, plan_prompt, stream=False, session_id=f"{session_id}_plan"
)
plan_text = plan_run.content if hasattr(plan_run, "content") else str(plan_run)
except Exception as exc:
logger.error("Agentic loop: planning failed: %s", exc)
# Phase 1: Planning
plan = await _plan_task(agent, task, session_id, max_steps)
if isinstance(plan, str):
result.status = "failed"
result.summary = f"Planning failed: {exc}"
result.summary = plan
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
return result
steps = _parse_steps(plan_text)
if not steps:
result.status = "failed"
result.summary = "Planning produced no steps."
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
return result
# Enforce max_steps — track if we truncated
planned_steps = len(steps)
steps = steps[:max_steps]
steps, was_truncated = plan
total_steps = len(steps)
was_truncated = planned_steps > total_steps
# Broadcast plan
await _broadcast_progress(
"agentic.plan_ready",
{
"task_id": task_id,
"task": task,
"steps": steps,
"total": total_steps,
},
{"task_id": task_id, "task": task, "steps": steps, "total": total_steps},
)
# ── Phase 2: Execution ─────────────────────────────────────────────────
# Phase 2: Execution
completed_results: list[str] = []
for i, step_desc in enumerate(steps, 1):
step_start = time.monotonic()
context = (
f"Task: {task}\n"
f"Plan: {plan_text}\n"
f"Completed so far: {completed_results}\n\n"
f"Now do step {i}: {step_desc}\n"
f"Execute this step and report what you did."
)
try:
step_run = await asyncio.to_thread(
agent.run, context, stream=False, session_id=f"{session_id}_step{i}"
)
step_result = step_run.content if hasattr(step_run, "content") else str(step_run)
# Clean the response
from timmy.session import _clean_response
step_result = _clean_response(step_result)
step = AgenticStep(
step_num=i,
description=step_desc,
result=step_result,
status="completed",
duration_ms=int((time.monotonic() - step_start) * 1000),
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]}")
# Broadcast progress
completed_results.append(f"Step {i}: {step.result[:200]}")
await _broadcast_progress(
"agentic.step_complete",
{
@@ -205,46 +287,18 @@ async def run_agentic_loop(
"step": i,
"total": total_steps,
"description": step_desc,
"result": step_result[:200],
"result": step.result[:200],
},
)
if on_progress:
await on_progress(step_desc, i, total_steps)
except Exception as exc:
except Exception as exc: # broad catch intentional: agent.run can raise any error
logger.warning("Agentic loop step %d failed: %s", i, exc)
# ── Adaptation: ask model to adapt ─────────────────────────────
adapt_prompt = (
f"Step {i} failed with error: {exc}\n"
f"Original step was: {step_desc}\n"
f"Adapt the plan and try an alternative approach for this step."
)
try:
adapt_run = await asyncio.to_thread(
agent.run,
adapt_prompt,
stream=False,
session_id=f"{session_id}_adapt{i}",
)
adapt_result = (
adapt_run.content if hasattr(adapt_run, "content") else str(adapt_run)
)
from timmy.session import _clean_response
adapt_result = _clean_response(adapt_result)
step = AgenticStep(
step_num=i,
description=f"[Adapted] {step_desc}",
result=adapt_result,
status="adapted",
duration_ms=int((time.monotonic() - step_start) * 1000),
)
step = await _adapt_step(agent, step_desc, i, exc, step_start, session_id)
result.steps.append(step)
completed_results.append(f"Step {i} (adapted): {adapt_result[:200]}")
completed_results.append(f"Step {i} (adapted): {step.result[:200]}")
await _broadcast_progress(
"agentic.step_adapted",
{
@@ -253,58 +307,26 @@ async def run_agentic_loop(
"total": total_steps,
"description": step_desc,
"error": str(exc),
"adaptation": adapt_result[:200],
"adaptation": step.result[:200],
},
)
if on_progress:
await on_progress(f"[Adapted] {step_desc}", i, total_steps)
except Exception as adapt_exc:
except Exception as adapt_exc: # broad catch intentional
logger.error("Agentic loop adaptation also failed: %s", adapt_exc)
step = 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),
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),
)
)
result.steps.append(step)
completed_results.append(f"Step {i}: FAILED")
# ── Phase 3: Summary ───────────────────────────────────────────────────
summary_prompt = (
f"Task: {task}\n"
f"Results:\n" + "\n".join(completed_results) + "\n\n"
"Summarise what was accomplished in 2-3 sentences."
)
try:
summary_run = await asyncio.to_thread(
agent.run,
summary_prompt,
stream=False,
session_id=f"{session_id}_summary",
)
result.summary = (
summary_run.content if hasattr(summary_run, "content") else str(summary_run)
)
from timmy.session import _clean_response
result.summary = _clean_response(result.summary)
except Exception as exc:
logger.error("Agentic loop summary failed: %s", exc)
result.summary = f"Completed {len(result.steps)} steps."
# Determine final status
if was_truncated:
result.status = "partial"
elif len(result.steps) < total_steps:
result.status = "partial"
elif any(s.status == "failed" for s in result.steps):
result.status = "partial"
else:
result.status = "completed"
# Phase 3: Summary
_summarize(result, total_steps, was_truncated)
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
await _broadcast_progress(
@@ -332,5 +354,6 @@ async def _broadcast_progress(event: str, data: dict) -> None:
from infrastructure.ws_manager.handler import ws_manager
await ws_manager.broadcast(event, data)
except Exception:
except (ImportError, AttributeError, ConnectionError, RuntimeError) as exc:
logger.warning("Agentic loop broadcast failed: %s", exc)
logger.debug("Agentic loop: WS broadcast failed for %s", event)

View File

@@ -10,10 +10,12 @@ SubAgent is the single seed class for ALL agents. Differentiation
comes entirely from config (agents.yaml), not from Python subclasses.
"""
import asyncio
import logging
from abc import ABC, abstractmethod
from typing import Any
import httpx
from agno.agent import Agent
from agno.models.ollama import Ollama
@@ -72,14 +74,17 @@ class BaseAgent(ABC):
if handler:
tool_instances.append(handler)
ollama_kwargs = {}
if settings.ollama_num_ctx > 0:
ollama_kwargs["options"] = {"num_ctx": settings.ollama_num_ctx}
return Agent(
name=self.name,
model=Ollama(id=self.model, host=settings.ollama_url, timeout=300),
model=Ollama(id=self.model, host=settings.ollama_url, timeout=300, **ollama_kwargs),
description=system_prompt,
tools=tool_instances if tool_instances else None,
add_history_to_context=True,
num_history_runs=self.max_history,
markdown=True,
markdown=False,
telemetry=settings.telemetry_enabled,
)
@@ -114,16 +119,84 @@ class BaseAgent(ABC):
"""
pass
async def run(self, message: str) -> str:
"""Run the agent with a message.
# Transient errors that indicate Ollama contention or temporary
# unavailability — these deserve a retry with backoff.
_TRANSIENT = (
httpx.ConnectError,
httpx.ReadError,
httpx.ReadTimeout,
httpx.ConnectTimeout,
ConnectionError,
TimeoutError,
)
Returns:
Agent response
async def run(self, message: str, *, max_retries: int = 3) -> str:
"""Run the agent with a message, retrying on transient failures.
GPU contention from concurrent Ollama requests causes ReadError /
ReadTimeout — these are transient and retried with exponential
backoff (#70).
"""
result = self.agent.run(message, stream=False)
response = result.content if hasattr(result, "content") else str(result)
response = await self._run_with_retries(message, max_retries)
await self._emit_response_event(message, response)
return response
# Emit completion event
async def _run_with_retries(self, message: str, max_retries: int) -> str:
"""Execute agent.run() with retry logic for transient errors."""
for attempt in range(1, max_retries + 1):
try:
result = self.agent.run(message, stream=False)
return result.content if hasattr(result, "content") else str(result)
except self._TRANSIENT as exc:
self._handle_retry_or_raise(
exc,
attempt,
max_retries,
transient=True,
)
await asyncio.sleep(min(2**attempt, 16))
except Exception as exc:
self._handle_retry_or_raise(
exc,
attempt,
max_retries,
transient=False,
)
await asyncio.sleep(min(2 ** (attempt - 1), 8))
# Unreachable — _handle_retry_or_raise raises on last attempt.
raise RuntimeError("retry loop exited unexpectedly") # pragma: no cover
@staticmethod
def _handle_retry_or_raise(
exc: Exception,
attempt: int,
max_retries: int,
*,
transient: bool,
) -> None:
"""Log a retry warning or raise after exhausting attempts."""
if attempt < max_retries:
if transient:
logger.warning(
"Ollama contention on attempt %d/%d: %s. Waiting before retry...",
attempt,
max_retries,
type(exc).__name__,
)
else:
logger.warning(
"Agent run failed on attempt %d/%d: %s. Retrying...",
attempt,
max_retries,
exc,
)
else:
label = "Ollama unreachable" if transient else "Agent run failed"
logger.error("%s after %d attempts: %s", label, max_retries, exc)
raise exc
async def _emit_response_event(self, message: str, response: str) -> None:
"""Publish a completion event to the event bus if connected."""
if self.event_bus:
await self.event_bus.publish(
Event(
@@ -133,8 +206,6 @@ class BaseAgent(ABC):
)
)
return response
def get_capabilities(self) -> list[str]:
"""Get list of capabilities this agent provides."""
return self.tools

View File

@@ -16,6 +16,7 @@ Usage:
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any
@@ -181,6 +182,23 @@ def get_routing_config() -> dict[str, Any]:
return config.get("routing", {"method": "pattern", "patterns": {}})
def _matches_pattern(pattern: str, message: str) -> bool:
"""Check if a pattern matches using word-boundary matching.
For single-word patterns, uses \b word boundaries.
For multi-word patterns, all words must appear as whole words (in any order).
"""
pattern_lower = pattern.lower()
message_lower = message.lower()
words = pattern_lower.split()
for word in words:
# Use word boundary regex to match whole words only
if not re.search(rf"\b{re.escape(word)}\b", message_lower):
return False
return True
def route_request(user_message: str) -> str | None:
"""Route a user request to an agent using pattern matching.
@@ -193,17 +211,36 @@ def route_request(user_message: str) -> str | None:
return None
patterns = routing.get("patterns", {})
message_lower = user_message.lower()
for agent_id, keywords in patterns.items():
for keyword in keywords:
if keyword.lower() in message_lower:
if _matches_pattern(keyword, user_message):
logger.debug("Routed to %s (matched: %r)", agent_id, keyword)
return agent_id
return None
def route_request_with_match(user_message: str) -> tuple[str | None, str | None]:
"""Route a user request and return both the agent and the matched pattern.
Returns a tuple of (agent_id, matched_pattern). If no match, returns (None, None).
"""
routing = get_routing_config()
if routing.get("method") != "pattern":
return None, None
patterns = routing.get("patterns", {})
for agent_id, keywords in patterns.items():
for keyword in keywords:
if _matches_pattern(keyword, user_message):
return agent_id, keyword
return None, None
def reload_agents() -> dict[str, Any]:
"""Force reload agents from YAML. Call after editing agents.yaml."""
global _agents, _config

View File

@@ -13,6 +13,8 @@ Default is always True. The owner changes this intentionally.
import sqlite3
import uuid
from collections.abc import Generator
from contextlib import closing, contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -43,23 +45,24 @@ class ApprovalItem:
status: str # "pending" | "approved" | "rejected"
def _get_conn(db_path: Path = _DEFAULT_DB) -> sqlite3.Connection:
@contextmanager
def _get_conn(db_path: Path = _DEFAULT_DB) -> Generator[sqlite3.Connection, None, None]:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS approval_items (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
proposed_action TEXT NOT NULL,
impact TEXT NOT NULL DEFAULT 'low',
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
)
""")
conn.commit()
return conn
with closing(sqlite3.connect(str(db_path))) as conn:
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS approval_items (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
proposed_action TEXT NOT NULL,
impact TEXT NOT NULL DEFAULT 'low',
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
)
""")
conn.commit()
yield conn
def _row_to_item(row: sqlite3.Row) -> ApprovalItem:
@@ -96,80 +99,73 @@ def create_item(
created_at=datetime.now(UTC),
status="pending",
)
conn = _get_conn(db_path)
conn.execute(
"""
INSERT INTO approval_items
(id, title, description, proposed_action, impact, created_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
item.id,
item.title,
item.description,
item.proposed_action,
item.impact,
item.created_at.isoformat(),
item.status,
),
)
conn.commit()
conn.close()
with _get_conn(db_path) as conn:
conn.execute(
"""
INSERT INTO approval_items
(id, title, description, proposed_action, impact, created_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
item.id,
item.title,
item.description,
item.proposed_action,
item.impact,
item.created_at.isoformat(),
item.status,
),
)
conn.commit()
return item
def list_pending(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
"""Return all pending approval items, newest first."""
conn = _get_conn(db_path)
rows = conn.execute(
"SELECT * FROM approval_items WHERE status = 'pending' ORDER BY created_at DESC"
).fetchall()
conn.close()
with _get_conn(db_path) as conn:
rows = conn.execute(
"SELECT * FROM approval_items WHERE status = 'pending' ORDER BY created_at DESC"
).fetchall()
return [_row_to_item(r) for r in rows]
def list_all(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
"""Return all approval items regardless of status, newest first."""
conn = _get_conn(db_path)
rows = conn.execute("SELECT * FROM approval_items ORDER BY created_at DESC").fetchall()
conn.close()
with _get_conn(db_path) as conn:
rows = conn.execute("SELECT * FROM approval_items ORDER BY created_at DESC").fetchall()
return [_row_to_item(r) for r in rows]
def get_item(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
conn = _get_conn(db_path)
row = conn.execute("SELECT * FROM approval_items WHERE id = ?", (item_id,)).fetchone()
conn.close()
with _get_conn(db_path) as conn:
row = conn.execute("SELECT * FROM approval_items WHERE id = ?", (item_id,)).fetchone()
return _row_to_item(row) if row else None
def approve(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
"""Mark an approval item as approved."""
conn = _get_conn(db_path)
conn.execute("UPDATE approval_items SET status = 'approved' WHERE id = ?", (item_id,))
conn.commit()
conn.close()
with _get_conn(db_path) as conn:
conn.execute("UPDATE approval_items SET status = 'approved' WHERE id = ?", (item_id,))
conn.commit()
return get_item(item_id, db_path)
def reject(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
"""Mark an approval item as rejected."""
conn = _get_conn(db_path)
conn.execute("UPDATE approval_items SET status = 'rejected' WHERE id = ?", (item_id,))
conn.commit()
conn.close()
with _get_conn(db_path) as conn:
conn.execute("UPDATE approval_items SET status = 'rejected' WHERE id = ?", (item_id,))
conn.commit()
return get_item(item_id, db_path)
def expire_old(db_path: Path = _DEFAULT_DB) -> int:
"""Auto-expire pending items older than EXPIRY_DAYS. Returns count removed."""
cutoff = (datetime.now(UTC) - timedelta(days=_EXPIRY_DAYS)).isoformat()
conn = _get_conn(db_path)
cursor = conn.execute(
"DELETE FROM approval_items WHERE status = 'pending' AND created_at < ?",
(cutoff,),
)
conn.commit()
count = cursor.rowcount
conn.close()
with _get_conn(db_path) as conn:
cursor = conn.execute(
"DELETE FROM approval_items WHERE status = 'pending' AND created_at < ?",
(cutoff,),
)
conn.commit()
count = cursor.rowcount
return count

View File

@@ -1,11 +1,10 @@
"""LLM backends — AirLLM (local big models), Grok (xAI), and Claude (Anthropic).
"""LLM backends — Grok (xAI) and Claude (Anthropic).
Provides drop-in replacements for the Agno Agent that expose the same
run(message, stream) → RunResult interface used by the dashboard and the
print_response(message, stream) interface used by the CLI.
Backends:
- TimmyAirLLMAgent: Local 8B/70B/405B via AirLLM (Apple Silicon or PyTorch)
- GrokBackend: xAI Grok API via OpenAI-compatible SDK (opt-in premium)
- ClaudeBackend: Anthropic Claude API — lightweight cloud fallback
@@ -16,27 +15,18 @@ import logging
import platform
import time
from dataclasses import dataclass
from typing import Literal
from timmy.prompts import SYSTEM_PROMPT
from timmy.prompts import get_system_prompt
logger = logging.getLogger(__name__)
# HuggingFace model IDs for each supported size.
_AIRLLM_MODELS: dict[str, str] = {
"8b": "meta-llama/Meta-Llama-3.1-8B-Instruct",
"70b": "meta-llama/Meta-Llama-3.1-70B-Instruct",
"405b": "meta-llama/Meta-Llama-3.1-405B-Instruct",
}
ModelSize = Literal["8b", "70b", "405b"]
@dataclass
class RunResult:
"""Minimal Agno-compatible run result — carries the model's response text."""
content: str
confidence: float | None = None
def is_apple_silicon() -> bool:
@@ -44,108 +34,6 @@ def is_apple_silicon() -> bool:
return platform.system() == "Darwin" and platform.machine() == "arm64"
def airllm_available() -> bool:
"""Return True when the airllm package is importable."""
try:
import airllm # noqa: F401
return True
except ImportError:
return False
class TimmyAirLLMAgent:
"""Thin AirLLM wrapper compatible with both dashboard and CLI call sites.
Exposes:
run(message, stream) → RunResult(content=...) [dashboard]
print_response(message, stream) → None [CLI]
Maintains a rolling 10-turn in-memory history so Timmy remembers the
conversation within a session — no SQLite needed at this layer.
"""
def __init__(self, model_size: str = "70b") -> None:
model_id = _AIRLLM_MODELS.get(model_size)
if model_id is None:
raise ValueError(
f"Unknown model size {model_size!r}. Choose from: {list(_AIRLLM_MODELS)}"
)
if is_apple_silicon():
from airllm import AirLLMMLX # type: ignore[import]
self._model = AirLLMMLX(model_id)
else:
from airllm import AutoModel # type: ignore[import]
self._model = AutoModel.from_pretrained(model_id)
self._history: list[str] = []
self._model_size = model_size
# ── public interface (mirrors Agno Agent) ────────────────────────────────
def run(self, message: str, *, stream: bool = False) -> RunResult:
"""Run inference and return a structured result (matches Agno Agent.run()).
`stream` is accepted for API compatibility; AirLLM always generates
the full output in one pass.
"""
prompt = self._build_prompt(message)
input_tokens = self._model.tokenizer(
[prompt],
return_tensors="pt",
padding=True,
truncation=True,
max_length=2048,
)
output = self._model.generate(
**input_tokens,
max_new_tokens=512,
use_cache=True,
do_sample=True,
temperature=0.7,
)
# Decode only the newly generated tokens, not the prompt.
input_len = input_tokens["input_ids"].shape[1]
response = self._model.tokenizer.decode(
output[0][input_len:], skip_special_tokens=True
).strip()
self._history.append(f"User: {message}")
self._history.append(f"Timmy: {response}")
return RunResult(content=response)
def print_response(self, message: str, *, stream: bool = True) -> None:
"""Run inference and render the response to stdout (CLI interface)."""
result = self.run(message, stream=stream)
self._render(result.content)
# ── private helpers ──────────────────────────────────────────────────────
def _build_prompt(self, message: str) -> str:
context = SYSTEM_PROMPT + "\n\n"
# Include the last 10 turns (5 exchanges) for continuity.
if self._history:
context += "\n".join(self._history[-10:]) + "\n\n"
return context + f"User: {message}\nTimmy:"
@staticmethod
def _render(text: str) -> None:
"""Print response with rich markdown when available, plain text otherwise."""
try:
from rich.console import Console
from rich.markdown import Markdown
Console().print(Markdown(text))
except ImportError:
print(text)
# ── Grok (xAI) Backend ─────────────────────────────────────────────────────
# Premium cloud augmentation — opt-in only, never the default path.
@@ -186,7 +74,7 @@ class GrokBackend:
Uses the OpenAI-compatible SDK to connect to xAI's API.
Only activated when GROK_ENABLED=true and XAI_API_KEY is set.
Exposes the same interface as TimmyAirLLMAgent and Agno Agent:
Exposes the same interface as Agno Agent:
run(message, stream) → RunResult [dashboard]
print_response(message, stream) → None [CLI]
health_check() → dict [monitoring]
@@ -211,23 +99,27 @@ class GrokBackend:
def _get_client(self):
"""Create OpenAI client configured for xAI endpoint."""
from config import settings
import httpx
from openai import OpenAI
return OpenAI(
api_key=self._api_key,
base_url="https://api.x.ai/v1",
base_url=settings.xai_base_url,
timeout=httpx.Timeout(300.0),
)
async def _get_async_client(self):
"""Create async OpenAI client configured for xAI endpoint."""
from config import settings
import httpx
from openai import AsyncOpenAI
return AsyncOpenAI(
api_key=self._api_key,
base_url="https://api.x.ai/v1",
base_url=settings.xai_base_url,
timeout=httpx.Timeout(300.0),
)
@@ -388,7 +280,9 @@ class GrokBackend:
def _build_messages(self, message: str) -> list[dict[str, str]]:
"""Build the messages array for the API call."""
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
messages = [
{"role": "system", "content": get_system_prompt(tools_enabled=True, session_id="grok")}
]
# Include conversation history for context
messages.extend(self._history[-10:])
messages.append({"role": "user", "content": message})
@@ -414,7 +308,8 @@ def grok_available() -> bool:
from config import settings
return settings.grok_enabled and bool(settings.xai_api_key)
except Exception:
except Exception as exc:
logger.warning("Backend check failed (grok_available): %s", exc)
return False
@@ -433,8 +328,7 @@ CLAUDE_MODELS: dict[str, str] = {
class ClaudeBackend:
"""Anthropic Claude backend — cloud fallback when local models are offline.
Uses the official Anthropic SDK. Same interface as GrokBackend and
TimmyAirLLMAgent:
Uses the official Anthropic SDK. Same interface as GrokBackend:
run(message, stream) → RunResult [dashboard]
print_response(message, stream) → None [CLI]
health_check() → dict [monitoring]
@@ -480,7 +374,7 @@ class ClaudeBackend:
response = client.messages.create(
model=self._model,
max_tokens=1024,
system=SYSTEM_PROMPT,
system=get_system_prompt(tools_enabled=True, session_id="claude"),
messages=messages,
)
@@ -566,5 +460,6 @@ def claude_available() -> bool:
from config import settings
return bool(settings.anthropic_api_key)
except Exception:
except Exception as exc:
logger.warning("Backend check failed (claude_available): %s", exc)
return False

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