Compare commits

...

64 Commits

Author SHA1 Message Date
kimi
d5361a0385 fix: remove AirLLM config settings from config.py
Remove `airllm` from timmy_model_backend Literal type and delete the
airllm_model_size field plus associated comments. Replace the one
settings.airllm_model_size reference in agent.py with a hardcoded
default, and clean up mock assignments in tests.

Fixes #473

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 15:26:10 -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
85 changed files with 10831 additions and 170 deletions

20
.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
@@ -73,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

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)

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

@@ -149,6 +149,11 @@ def update_summary() -> None:
def main() -> None:
args = parse_args()
# 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

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()

113
scripts/loop_guard.py Normal file
View File

@@ -0,0 +1,113 @@
#!/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 sys
import time
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"
# Backoff sequence: 60s, 120s, 240s, 600s max
BACKOFF_BASE = 60
BACKOFF_MAX = 600
BACKOFF_MULTIPLIER = 2
def load_queue() -> list[dict]:
"""Load queue.json and return ready items."""
if not QUEUE_FILE.exists():
return []
try:
data = json.loads(QUEUE_FILE.read_text())
if isinstance(data, list):
return [item for item in data if item.get("ready")]
return []
except (json.JSONDecodeError, OSError):
return []
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())

View File

@@ -64,17 +64,10 @@ 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" — auto-detect best available backend
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.
@@ -138,7 +131,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").
@@ -238,12 +236,18 @@ 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_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.

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
@@ -46,6 +48,8 @@ from dashboard.routes.thinking import router as thinking_router
from dashboard.routes.tools import router as tools_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):
@@ -187,6 +191,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
@@ -295,6 +347,7 @@ async def lifespan(app: FastAPI):
briefing_task = asyncio.create_task(_briefing_scheduler())
thinking_task = asyncio.create_task(_thinking_scheduler())
loop_qa_task = asyncio.create_task(_loop_qa_scheduler())
presence_task = asyncio.create_task(_presence_watcher())
# Initialize Spark Intelligence engine
from spark.engine import get_spark_engine
@@ -372,6 +425,13 @@ async def lifespan(app: FastAPI):
except Exception as exc:
logger.debug("Vault size check skipped: %s", exc)
# 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()
# Start chat integrations in background
chat_task = asyncio.create_task(_start_chat_integrations_background())
@@ -403,7 +463,9 @@ 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]:
await workshop_heartbeat.stop()
for task in [briefing_task, thinking_task, chat_task, loop_qa_task, presence_task]:
if task:
task.cancel()
try:
@@ -422,15 +484,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
@@ -483,6 +544,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)
@@ -491,6 +553,7 @@ 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.websocket("/ws")

View File

@@ -85,6 +85,14 @@ async def chat_agent(request: Request, message: str = Form(...)):
raise HTTPException(status_code=422, detail="Message too long")
# 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:
pass
timestamp = datetime.now().strftime("%H:%M:%S")
response_text = None
error_text = None

View File

@@ -79,6 +79,14 @@ async def api_chat(request: Request):
if not last_user_msg:
return JSONResponse(status_code=400, content={"error": "No user message found"})
# 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:
pass
timestamp = datetime.now().strftime("%H:%M:%S")
try:

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

@@ -0,0 +1,384 @@
"""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:
pass # connection gone — receive loop will clean up
@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:
pass
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:
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:
pass # Pip is 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

@@ -183,6 +183,22 @@ 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("/config")
async def get_config(
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],

View File

@@ -815,6 +815,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

@@ -547,9 +547,7 @@ 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."
# Check if Agno paused the run for tool confirmation
if run_output is not None:

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

@@ -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

@@ -220,7 +220,7 @@ def create_timmy(
print_response(message, stream).
"""
resolved = _resolve_backend(backend)
size = model_size or settings.airllm_model_size
size = model_size or "70b"
if resolved == "claude":
from timmy.backends import ClaudeBackend
@@ -300,7 +300,11 @@ def create_timmy(
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}"
full_prompt = (
f"{base_prompt}\n\n"
f"## GROUNDED CONTEXT (verified sources — cite when using)\n\n"
f"{memory_context}"
)
else:
full_prompt = base_prompt
except Exception as exc:

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
@@ -59,6 +60,7 @@ class AgenticResult:
# ---------------------------------------------------------------------------
_loop_agent = None
_loop_agent_lock = threading.Lock()
def _get_loop_agent():
@@ -69,9 +71,11 @@ def _get_loop_agent():
"""
global _loop_agent
if _loop_agent is None:
from timmy.agent import create_timmy
with _loop_agent_lock:
if _loop_agent is None:
from timmy.agent import create_timmy
_loop_agent = create_timmy()
_loop_agent = create_timmy()
return _loop_agent

View File

@@ -416,5 +416,40 @@ def route(
typer.echo("→ orchestrator (no pattern match)")
@app.command()
def focus(
topic: str | None = typer.Argument(
None, help='Topic to focus on (e.g. "three-phase loop"). Omit to show current focus.'
),
clear: bool = typer.Option(False, "--clear", "-c", help="Clear focus and return to broad mode"),
):
"""Set deep-focus mode on a single problem.
When focused, Timmy prioritizes the active topic in all responses
and deprioritizes unrelated context. Focus persists across sessions.
Examples:
timmy focus "three-phase loop" # activate deep focus
timmy focus # show current focus
timmy focus --clear # return to broad mode
"""
from timmy.focus import focus_manager
if clear:
focus_manager.clear()
typer.echo("Focus cleared — back to broad mode.")
return
if topic:
focus_manager.set_topic(topic)
typer.echo(f'Deep focus activated: "{topic}"')
else:
# Show current focus status
if focus_manager.is_focused():
typer.echo(f'Deep focus: "{focus_manager.get_topic()}"')
else:
typer.echo("No active focus (broad mode).")
def main():
app()

View File

@@ -0,0 +1,250 @@
"""Observable cognitive state for Timmy.
Tracks Timmy's internal cognitive signals — focus, engagement, mood,
and active commitments — so external systems (Matrix avatar, dashboard)
can render observable behaviour.
State is published via ``workshop_state.py`` → ``presence.json`` and the
WebSocket relay. The old ``~/.tower/timmy-state.txt`` file has been
deprecated (see #384).
"""
import asyncio
import json
import logging
from dataclasses import asdict, dataclass, field
from timmy.confidence import estimate_confidence
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
ENGAGEMENT_LEVELS = ("idle", "surface", "deep")
MOOD_VALUES = ("curious", "settled", "hesitant", "energized")
@dataclass
class CognitiveState:
"""Observable snapshot of Timmy's cognitive state."""
focus_topic: str | None = None
engagement: str = "idle" # idle | surface | deep
mood: str = "settled" # curious | settled | hesitant | energized
conversation_depth: int = 0
last_initiative: str | None = None
active_commitments: list[str] = field(default_factory=list)
# Internal tracking (not written to state file)
_confidence_sum: float = field(default=0.0, repr=False)
_confidence_count: int = field(default=0, repr=False)
# ------------------------------------------------------------------
# Serialisation helpers
# ------------------------------------------------------------------
def to_dict(self) -> dict:
"""Public fields only (exclude internal tracking)."""
d = asdict(self)
d.pop("_confidence_sum", None)
d.pop("_confidence_count", None)
return d
# ---------------------------------------------------------------------------
# Cognitive signal extraction
# ---------------------------------------------------------------------------
# Keywords that suggest deep engagement
_DEEP_KEYWORDS = frozenset(
{
"architecture",
"design",
"implement",
"refactor",
"debug",
"analyze",
"investigate",
"deep dive",
"explain how",
"walk me through",
"step by step",
}
)
# Keywords that suggest initiative / commitment
_COMMITMENT_KEYWORDS = frozenset(
{
"i will",
"i'll",
"let me",
"i'm going to",
"plan to",
"commit to",
"i propose",
"i suggest",
}
)
def _infer_engagement(message: str, response: str) -> str:
"""Classify engagement level from the exchange."""
combined = (message + " " + response).lower()
if any(kw in combined for kw in _DEEP_KEYWORDS):
return "deep"
# Short exchanges are surface-level
if len(response.split()) < 15:
return "surface"
return "surface"
def _infer_mood(response: str, confidence: float) -> str:
"""Derive mood from response signals."""
lower = response.lower()
if confidence < 0.4:
return "hesitant"
if "!" in response and any(w in lower for w in ("great", "exciting", "love", "awesome")):
return "energized"
if "?" in response or any(w in lower for w in ("wonder", "interesting", "curious", "hmm")):
return "curious"
return "settled"
def _extract_topic(message: str) -> str | None:
"""Best-effort topic extraction from the user message.
Takes the first meaningful clause (up to 60 chars) as a topic label.
"""
text = message.strip()
if not text:
return None
# Strip leading question words
for prefix in ("what is ", "how do ", "can you ", "please ", "hey timmy "):
if text.lower().startswith(prefix):
text = text[len(prefix) :]
# Truncate
if len(text) > 60:
text = text[:57] + "..."
return text.strip() or None
def _extract_commitments(response: str) -> list[str]:
"""Pull commitment phrases from Timmy's response."""
commitments: list[str] = []
lower = response.lower()
for kw in _COMMITMENT_KEYWORDS:
idx = lower.find(kw)
if idx == -1:
continue
# Grab the rest of the sentence (up to period/newline, max 80 chars)
start = idx
end = len(lower)
for sep in (".", "\n", "!"):
pos = lower.find(sep, start)
if pos != -1:
end = min(end, pos)
snippet = response[start : min(end, start + 80)].strip()
if snippet:
commitments.append(snippet)
return commitments[:3] # Cap at 3
# ---------------------------------------------------------------------------
# Tracker singleton
# ---------------------------------------------------------------------------
class CognitiveTracker:
"""Maintains Timmy's cognitive state.
State is consumed via ``to_json()`` / ``get_state()`` and published
externally by ``workshop_state.py`` → ``presence.json``.
"""
def __init__(self) -> None:
self.state = CognitiveState()
def update(self, user_message: str, response: str) -> CognitiveState:
"""Update cognitive state from a chat exchange.
Called after each chat round-trip in ``session.py``.
Emits a ``cognitive_state_changed`` event to the sensory bus so
downstream consumers (WorkshopHeartbeat, etc.) react immediately.
"""
confidence = estimate_confidence(response)
prev_mood = self.state.mood
prev_engagement = self.state.engagement
# Track running confidence average
self.state._confidence_sum += confidence
self.state._confidence_count += 1
self.state.conversation_depth += 1
self.state.focus_topic = _extract_topic(user_message) or self.state.focus_topic
self.state.engagement = _infer_engagement(user_message, response)
self.state.mood = _infer_mood(response, confidence)
# Extract commitments from response
new_commitments = _extract_commitments(response)
if new_commitments:
self.state.last_initiative = new_commitments[0]
# Merge, keeping last 5
seen = set(self.state.active_commitments)
for c in new_commitments:
if c not in seen:
self.state.active_commitments.append(c)
seen.add(c)
self.state.active_commitments = self.state.active_commitments[-5:]
# Emit cognitive_state_changed to close the sense → react loop
self._emit_change(prev_mood, prev_engagement)
return self.state
def _emit_change(self, prev_mood: str, prev_engagement: str) -> None:
"""Fire-and-forget sensory event for cognitive state change."""
try:
from timmy.event_bus import get_sensory_bus
from timmy.events import SensoryEvent
event = SensoryEvent(
source="cognitive",
event_type="cognitive_state_changed",
data={
"mood": self.state.mood,
"engagement": self.state.engagement,
"focus_topic": self.state.focus_topic or "",
"depth": self.state.conversation_depth,
"mood_changed": self.state.mood != prev_mood,
"engagement_changed": self.state.engagement != prev_engagement,
},
)
bus = get_sensory_bus()
# Fire-and-forget — don't block the chat response
try:
loop = asyncio.get_running_loop()
loop.create_task(bus.emit(event))
except RuntimeError:
# No running loop (sync context / tests) — skip emission
pass
except Exception as exc:
logger.debug("Cognitive event emission skipped: %s", exc)
def get_state(self) -> CognitiveState:
"""Return current cognitive state."""
return self.state
def reset(self) -> None:
"""Reset to idle state (e.g. on session reset)."""
self.state = CognitiveState()
def to_json(self) -> str:
"""Serialise current state as JSON (for API / WebSocket consumers)."""
return json.dumps(self.state.to_dict())
# Module-level singleton
cognitive_tracker = CognitiveTracker()

79
src/timmy/event_bus.py Normal file
View File

@@ -0,0 +1,79 @@
"""Sensory EventBus — simple pub/sub for SensoryEvents.
Thin facade over the infrastructure EventBus that speaks in
SensoryEvent objects instead of raw infrastructure Events.
"""
import asyncio
import logging
from collections.abc import Awaitable, Callable
from timmy.events import SensoryEvent
logger = logging.getLogger(__name__)
# Handler: sync or async callable that receives a SensoryEvent
SensoryHandler = Callable[[SensoryEvent], None | Awaitable[None]]
class SensoryBus:
"""Pub/sub dispatcher for SensoryEvents."""
def __init__(self, max_history: int = 500) -> None:
self._subscribers: dict[str, list[SensoryHandler]] = {}
self._history: list[SensoryEvent] = []
self._max_history = max_history
# ── Public API ────────────────────────────────────────────────────────
async def emit(self, event: SensoryEvent) -> int:
"""Push *event* to all subscribers whose event_type filter matches.
Returns the number of handlers invoked.
"""
self._history.append(event)
if len(self._history) > self._max_history:
self._history = self._history[-self._max_history :]
handlers = self._matching_handlers(event.event_type)
for h in handlers:
try:
result = h(event)
if asyncio.iscoroutine(result):
await result
except Exception as exc:
logger.error("SensoryBus handler error for '%s': %s", event.event_type, exc)
return len(handlers)
def subscribe(self, event_type: str, callback: SensoryHandler) -> None:
"""Register *callback* for events matching *event_type*.
Use ``"*"`` to subscribe to all event types.
"""
self._subscribers.setdefault(event_type, []).append(callback)
def recent(self, n: int = 10) -> list[SensoryEvent]:
"""Return the last *n* events (most recent last)."""
return self._history[-n:]
# ── Internals ─────────────────────────────────────────────────────────
def _matching_handlers(self, event_type: str) -> list[SensoryHandler]:
handlers: list[SensoryHandler] = []
for pattern, cbs in self._subscribers.items():
if pattern == "*" or pattern == event_type:
handlers.extend(cbs)
return handlers
# ── Module-level singleton ────────────────────────────────────────────────────
_bus: SensoryBus | None = None
def get_sensory_bus() -> SensoryBus:
"""Return the module-level SensoryBus singleton."""
global _bus
if _bus is None:
_bus = SensoryBus()
return _bus

39
src/timmy/events.py Normal file
View File

@@ -0,0 +1,39 @@
"""SensoryEvent — normalized event model for stream adapters.
Every adapter (gitea, time, bitcoin, terminal, etc.) emits SensoryEvents
into the EventBus so that Timmy's cognitive layer sees a uniform stream.
"""
import json
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
@dataclass
class SensoryEvent:
"""A single sensory event from an external stream."""
source: str # "gitea", "time", "bitcoin", "terminal"
event_type: str # "push", "issue_opened", "new_block", "morning"
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
data: dict = field(default_factory=dict)
actor: str = "" # who caused it (username, "system", etc.)
def to_dict(self) -> dict:
"""Return a JSON-serializable dictionary."""
d = asdict(self)
d["timestamp"] = self.timestamp.isoformat()
return d
def to_json(self) -> str:
"""Return a JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_dict(cls, data: dict) -> "SensoryEvent":
"""Reconstruct a SensoryEvent from a dictionary."""
data = dict(data) # shallow copy
ts = data.get("timestamp")
if isinstance(ts, str):
data["timestamp"] = datetime.fromisoformat(ts)
return cls(**data)

263
src/timmy/familiar.py Normal file
View File

@@ -0,0 +1,263 @@
"""Pip the Familiar — a creature with its own small mind.
Pip is a glowing sprite who lives in the Workshop independently of Timmy.
He has a behavioral state machine that makes the room feel alive:
SLEEPING → WAKING → WANDERING → INVESTIGATING → BORED → SLEEPING
Special states triggered by Timmy's cognitive signals:
ALERT — confidence drops below 0.3
PLAYFUL — Timmy is amused / energized
HIDING — unknown visitor + Timmy uncertain
The backend tracks Pip's *logical* state; the browser handles movement
interpolation and particle rendering.
"""
import logging
import random
import time
from dataclasses import asdict, dataclass, field
from enum import StrEnum
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# States
# ---------------------------------------------------------------------------
class PipState(StrEnum):
"""Pip's behavioral states."""
SLEEPING = "sleeping"
WAKING = "waking"
WANDERING = "wandering"
INVESTIGATING = "investigating"
BORED = "bored"
# Special states
ALERT = "alert"
PLAYFUL = "playful"
HIDING = "hiding"
# States from which Pip can be interrupted by special triggers
_INTERRUPTIBLE = frozenset(
{
PipState.SLEEPING,
PipState.WANDERING,
PipState.BORED,
PipState.WAKING,
}
)
# How long each state lasts before auto-transitioning (seconds)
_STATE_DURATIONS: dict[PipState, tuple[float, float]] = {
PipState.SLEEPING: (120.0, 300.0), # 2-5 min
PipState.WAKING: (1.5, 2.5),
PipState.WANDERING: (15.0, 45.0),
PipState.INVESTIGATING: (8.0, 12.0),
PipState.BORED: (20.0, 40.0),
PipState.ALERT: (10.0, 20.0),
PipState.PLAYFUL: (8.0, 15.0),
PipState.HIDING: (15.0, 30.0),
}
# Default position near the fireplace
_FIREPLACE_POS = (2.1, 0.5, -1.3)
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
@dataclass
class PipSnapshot:
"""Serialisable snapshot of Pip's current state."""
name: str = "Pip"
state: str = "sleeping"
position: tuple[float, float, float] = _FIREPLACE_POS
mood_mirror: str = "calm"
since: float = field(default_factory=time.monotonic)
def to_dict(self) -> dict:
"""Public dict for API / WebSocket / state file consumers."""
d = asdict(self)
d["position"] = list(d["position"])
# Convert monotonic timestamp to duration
d["state_duration_s"] = round(time.monotonic() - d.pop("since"), 1)
return d
# ---------------------------------------------------------------------------
# Familiar
# ---------------------------------------------------------------------------
class Familiar:
"""Pip's behavioral AI — a tiny state machine driven by events and time.
Usage::
pip_familiar.on_event("visitor_entered")
pip_familiar.on_mood_change("energized")
state = pip_familiar.tick() # call periodically
"""
def __init__(self) -> None:
self._state = PipState.SLEEPING
self._entered_at = time.monotonic()
self._duration = random.uniform(*_STATE_DURATIONS[PipState.SLEEPING])
self._mood_mirror = "calm"
self._pending_mood: str | None = None
self._mood_change_at: float = 0.0
self._position = _FIREPLACE_POS
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def state(self) -> PipState:
return self._state
@property
def mood_mirror(self) -> str:
return self._mood_mirror
def snapshot(self) -> PipSnapshot:
"""Current state as a serialisable snapshot."""
return PipSnapshot(
state=self._state.value,
position=self._position,
mood_mirror=self._mood_mirror,
since=self._entered_at,
)
def tick(self, now: float | None = None) -> PipState:
"""Advance the state machine. Call periodically (e.g. every second).
Returns the (possibly new) state.
"""
now = now if now is not None else time.monotonic()
# Apply delayed mood mirror (3-second lag)
if self._pending_mood and now >= self._mood_change_at:
self._mood_mirror = self._pending_mood
self._pending_mood = None
# Check if current state has expired
elapsed = now - self._entered_at
if elapsed < self._duration:
return self._state
# Auto-transition
next_state = self._next_state()
self._transition(next_state, now)
return self._state
def on_event(self, event: str, now: float | None = None) -> PipState:
"""React to a Workshop event.
Supported events:
visitor_entered, visitor_spoke, loud_event, scroll_knocked
"""
now = now if now is not None else time.monotonic()
if event == "visitor_entered" and self._state in _INTERRUPTIBLE:
if self._state == PipState.SLEEPING:
self._transition(PipState.WAKING, now)
else:
self._transition(PipState.INVESTIGATING, now)
elif event == "visitor_spoke":
if self._state in (PipState.WANDERING, PipState.WAKING):
self._transition(PipState.INVESTIGATING, now)
elif event == "loud_event":
if self._state == PipState.SLEEPING:
self._transition(PipState.WAKING, now)
return self._state
def on_mood_change(
self,
timmy_mood: str,
confidence: float = 0.5,
now: float | None = None,
) -> PipState:
"""Mirror Timmy's mood with a 3-second delay.
Special states triggered by mood + confidence:
- confidence < 0.3 → ALERT (bristles, particles go red-gold)
- mood == "energized" → PLAYFUL (figure-8s around crystal ball)
- mood == "hesitant" + confidence < 0.4 → HIDING
"""
now = now if now is not None else time.monotonic()
# Schedule mood mirror with 3s delay
self._pending_mood = timmy_mood
self._mood_change_at = now + 3.0
# Special state triggers (immediate)
if confidence < 0.3 and self._state in _INTERRUPTIBLE:
self._transition(PipState.ALERT, now)
elif timmy_mood == "energized" and self._state in _INTERRUPTIBLE:
self._transition(PipState.PLAYFUL, now)
elif timmy_mood == "hesitant" and confidence < 0.4 and self._state in _INTERRUPTIBLE:
self._transition(PipState.HIDING, now)
return self._state
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _transition(self, new_state: PipState, now: float) -> None:
"""Move to a new state."""
old = self._state
self._state = new_state
self._entered_at = now
self._duration = random.uniform(*_STATE_DURATIONS[new_state])
self._position = self._position_for(new_state)
logger.debug("Pip: %s%s", old.value, new_state.value)
def _next_state(self) -> PipState:
"""Determine the natural next state after the current one expires."""
transitions: dict[PipState, PipState] = {
PipState.SLEEPING: PipState.WAKING,
PipState.WAKING: PipState.WANDERING,
PipState.WANDERING: PipState.BORED,
PipState.INVESTIGATING: PipState.BORED,
PipState.BORED: PipState.SLEEPING,
# Special states return to wandering
PipState.ALERT: PipState.WANDERING,
PipState.PLAYFUL: PipState.WANDERING,
PipState.HIDING: PipState.WAKING,
}
return transitions.get(self._state, PipState.SLEEPING)
def _position_for(self, state: PipState) -> tuple[float, float, float]:
"""Approximate position hint for a given state.
The browser interpolates smoothly; these are target anchors.
"""
if state in (PipState.SLEEPING, PipState.BORED):
return _FIREPLACE_POS
if state == PipState.HIDING:
return (0.5, 0.3, -2.0) # Behind the desk
if state == PipState.PLAYFUL:
return (1.0, 1.2, 0.0) # Near the crystal ball
# Wandering / investigating / waking — random room position
return (
random.uniform(-1.0, 3.0),
random.uniform(0.5, 1.5),
random.uniform(-2.0, 1.0),
)
# Module-level singleton
pip_familiar = Familiar()

105
src/timmy/focus.py Normal file
View File

@@ -0,0 +1,105 @@
"""Deep focus mode — single-problem context for Timmy.
Persists focus state to a JSON file so Timmy can maintain narrow,
deep attention on one problem across session restarts.
Usage:
from timmy.focus import focus_manager
focus_manager.set_topic("three-phase loop")
topic = focus_manager.get_topic() # "three-phase loop"
ctx = focus_manager.get_focus_context() # prompt injection string
focus_manager.clear()
"""
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
_DEFAULT_STATE_DIR = Path.home() / ".timmy"
_STATE_FILE = "focus.json"
class FocusManager:
"""Manages deep-focus state with file-backed persistence."""
def __init__(self, state_dir: Path | None = None) -> None:
self._state_dir = state_dir or _DEFAULT_STATE_DIR
self._state_file = self._state_dir / _STATE_FILE
self._topic: str | None = None
self._mode: str = "broad"
self._load()
# ── Public API ────────────────────────────────────────────────
def get_topic(self) -> str | None:
"""Return the current focus topic, or None if unfocused."""
return self._topic
def get_mode(self) -> str:
"""Return 'deep' or 'broad'."""
return self._mode
def is_focused(self) -> bool:
"""True when deep-focus is active with a topic set."""
return self._mode == "deep" and self._topic is not None
def set_topic(self, topic: str) -> None:
"""Activate deep focus on a specific topic."""
self._topic = topic.strip()
self._mode = "deep"
self._save()
logger.info("Focus: deep-focus set → %r", self._topic)
def clear(self) -> None:
"""Return to broad (unfocused) mode."""
old = self._topic
self._topic = None
self._mode = "broad"
self._save()
logger.info("Focus: cleared (was %r)", old)
def get_focus_context(self) -> str:
"""Return a prompt-injection string for the current focus state.
When focused, this tells the model to prioritize the topic.
When broad, returns an empty string (no injection).
"""
if not self.is_focused():
return ""
return (
f"[DEEP FOCUS MODE] You are currently in deep-focus mode on: "
f'"{self._topic}". '
f"Prioritize this topic in your responses. Surface related memories "
f"and prior conversation about this topic first. Deprioritize "
f"unrelated context. Stay focused — depth over breadth."
)
# ── Persistence ───────────────────────────────────────────────
def _load(self) -> None:
"""Load focus state from disk."""
if not self._state_file.exists():
return
try:
data = json.loads(self._state_file.read_text())
self._topic = data.get("topic")
self._mode = data.get("mode", "broad")
except Exception as exc:
logger.warning("Focus: failed to load state: %s", exc)
def _save(self) -> None:
"""Persist focus state to disk."""
try:
self._state_dir.mkdir(parents=True, exist_ok=True)
self._state_file.write_text(
json.dumps({"topic": self._topic, "mode": self._mode}, indent=2)
)
except Exception as exc:
logger.warning("Focus: failed to save state: %s", exc)
# Module-level singleton
focus_manager = FocusManager()

View File

@@ -29,6 +29,8 @@ from contextlib import closing
from datetime import datetime
from pathlib import Path
import httpx
from config import settings
logger = logging.getLogger(__name__)
@@ -268,6 +270,140 @@ async def create_gitea_issue_via_mcp(title: str, body: str = "", labels: str = "
return f"Failed to create issue via MCP: {exc}"
def _generate_avatar_image() -> bytes:
"""Generate a Timmy-themed avatar image using Pillow.
Creates a 512x512 wizard-themed avatar with emerald/purple/gold palette.
Returns raw PNG bytes. Falls back to a minimal solid-color image if
Pillow drawing primitives fail.
"""
from PIL import Image, ImageDraw
size = 512
img = Image.new("RGB", (size, size), (15, 25, 20))
draw = ImageDraw.Draw(img)
# Background gradient effect — concentric circles
for i in range(size // 2, 0, -4):
g = int(25 + (i / (size // 2)) * 30)
draw.ellipse(
[size // 2 - i, size // 2 - i, size // 2 + i, size // 2 + i],
fill=(10, g, 20),
)
# Wizard hat (triangle)
hat_color = (100, 50, 160) # purple
draw.polygon(
[(256, 40), (160, 220), (352, 220)],
fill=hat_color,
outline=(180, 130, 255),
)
# Hat brim
draw.ellipse([140, 200, 372, 250], fill=hat_color, outline=(180, 130, 255))
# Face circle
draw.ellipse([190, 220, 322, 370], fill=(60, 180, 100), outline=(80, 220, 120))
# Eyes
draw.ellipse([220, 275, 248, 310], fill=(255, 255, 255))
draw.ellipse([264, 275, 292, 310], fill=(255, 255, 255))
draw.ellipse([228, 285, 242, 300], fill=(30, 30, 60))
draw.ellipse([272, 285, 286, 300], fill=(30, 30, 60))
# Smile
draw.arc([225, 300, 287, 355], start=10, end=170, fill=(30, 30, 60), width=3)
# Stars around the hat
gold = (220, 190, 50)
star_positions = [(120, 100), (380, 120), (100, 300), (400, 280), (256, 10)]
for sx, sy in star_positions:
r = 8
draw.polygon(
[
(sx, sy - r),
(sx + r // 3, sy - r // 3),
(sx + r, sy),
(sx + r // 3, sy + r // 3),
(sx, sy + r),
(sx - r // 3, sy + r // 3),
(sx - r, sy),
(sx - r // 3, sy - r // 3),
],
fill=gold,
)
# "T" monogram on the hat
draw.text((243, 100), "T", fill=gold)
# Robe / body
draw.polygon(
[(180, 370), (140, 500), (372, 500), (332, 370)],
fill=(40, 100, 70),
outline=(60, 160, 100),
)
import io
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
async def update_gitea_avatar() -> str:
"""Generate and upload a unique avatar to Timmy's Gitea profile.
Creates a wizard-themed avatar image using Pillow drawing primitives,
base64-encodes it, and POSTs to the Gitea user avatar API endpoint.
Returns:
Success or failure message string.
"""
if not settings.gitea_enabled or not settings.gitea_token:
return "Gitea integration is not configured (no token or disabled)."
try:
from PIL import Image # noqa: F401 — availability check
except ImportError:
return "Pillow is not installed — cannot generate avatar image."
try:
import base64
# Step 1: Generate the avatar image
png_bytes = _generate_avatar_image()
logger.info("Generated avatar image (%d bytes)", len(png_bytes))
# Step 2: Base64-encode (raw, no data URI prefix)
b64_image = base64.b64encode(png_bytes).decode("ascii")
# Step 3: POST to Gitea
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{settings.gitea_url}/api/v1/user/avatar",
headers={
"Authorization": f"token {settings.gitea_token}",
"Content-Type": "application/json",
},
json={"image": b64_image},
)
# Gitea returns empty body on success (204 or 200)
if resp.status_code in (200, 204):
logger.info("Gitea avatar updated successfully")
return "Avatar updated successfully on Gitea."
logger.warning("Gitea avatar update failed: %s %s", resp.status_code, resp.text[:200])
return f"Gitea avatar update failed (HTTP {resp.status_code}): {resp.text[:200]}"
except (httpx.ConnectError, httpx.ReadError, ConnectionError) as exc:
logger.warning("Gitea connection failed during avatar update: %s", exc)
return f"Could not connect to Gitea: {exc}"
except Exception as exc:
logger.error("Avatar update failed: %s", exc)
return f"Avatar update failed: {exc}"
async def close_mcp_sessions() -> None:
"""Close any open MCP sessions. Called during app shutdown."""
global _issue_session

View File

@@ -1 +1,7 @@
"""Memory — Persistent conversation and knowledge memory."""
"""Memory — Persistent conversation and knowledge memory.
Sub-modules:
embeddings — text-to-vector embedding + similarity functions
unified — unified memory schema and connection management
vector_store — backward compatibility re-exports from memory_system
"""

View File

@@ -0,0 +1,88 @@
"""Embedding functions for Timmy's memory system.
Provides text-to-vector embedding using sentence-transformers (preferred)
with a deterministic hash-based fallback when the ML library is unavailable.
Also includes vector similarity utilities (cosine similarity, keyword overlap).
"""
import hashlib
import logging
import math
logger = logging.getLogger(__name__)
# Embedding model - small, fast, local
EMBEDDING_MODEL = None
EMBEDDING_DIM = 384 # MiniLM dimension
def _get_embedding_model():
"""Lazy-load embedding model."""
global EMBEDDING_MODEL
if EMBEDDING_MODEL is None:
try:
from config import settings
if settings.timmy_skip_embeddings:
EMBEDDING_MODEL = False
return EMBEDDING_MODEL
except ImportError:
pass
try:
from sentence_transformers import SentenceTransformer
EMBEDDING_MODEL = SentenceTransformer("all-MiniLM-L6-v2")
logger.info("MemorySystem: Loaded embedding model")
except ImportError:
logger.warning("MemorySystem: sentence-transformers not installed, using fallback")
EMBEDDING_MODEL = False # Use fallback
return EMBEDDING_MODEL
def _simple_hash_embedding(text: str) -> list[float]:
"""Fallback: Simple hash-based embedding when transformers unavailable."""
words = text.lower().split()
vec = [0.0] * 128
for i, word in enumerate(words[:50]): # First 50 words
h = hashlib.md5(word.encode()).hexdigest()
for j in range(8):
idx = (i * 8 + j) % 128
vec[idx] += int(h[j * 2 : j * 2 + 2], 16) / 255.0
# Normalize
mag = math.sqrt(sum(x * x for x in vec)) or 1.0
return [x / mag for x in vec]
def embed_text(text: str) -> list[float]:
"""Generate embedding for text."""
model = _get_embedding_model()
if model and model is not False:
embedding = model.encode(text)
return embedding.tolist()
return _simple_hash_embedding(text)
def cosine_similarity(a: list[float], b: list[float]) -> float:
"""Calculate cosine similarity between two vectors."""
dot = sum(x * y for x, y in zip(a, b, strict=False))
mag_a = math.sqrt(sum(x * x for x in a))
mag_b = math.sqrt(sum(x * x for x in b))
if mag_a == 0 or mag_b == 0:
return 0.0
return dot / (mag_a * mag_b)
# Alias for backward compatibility
_cosine_similarity = cosine_similarity
def _keyword_overlap(query: str, content: str) -> float:
"""Simple keyword overlap score as fallback."""
query_words = set(query.lower().split())
content_words = set(content.lower().split())
if not query_words:
return 0.0
overlap = len(query_words & content_words)
return overlap / len(query_words)

View File

@@ -2,7 +2,7 @@
Architecture:
- Database: Single `memories` table with unified schema
- Embeddings: Local sentence-transformers with hash fallback
- Embeddings: timmy.memory.embeddings (extracted)
- CRUD: store_memory, search_memories, delete_memory, etc.
- Tool functions: memory_search, memory_read, memory_write, memory_forget
- Classes: HotMemory, VaultMemory, MemorySystem, SemanticMemory, MemorySearcher
@@ -11,7 +11,6 @@ Architecture:
import hashlib
import json
import logging
import math
import re
import sqlite3
import uuid
@@ -21,6 +20,17 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from timmy.memory.embeddings import (
EMBEDDING_DIM,
EMBEDDING_MODEL, # noqa: F401 — re-exported for backward compatibility
_cosine_similarity, # noqa: F401 — re-exported for backward compatibility
_get_embedding_model,
_keyword_overlap,
_simple_hash_embedding, # noqa: F401 — re-exported for backward compatibility
cosine_similarity,
embed_text,
)
logger = logging.getLogger(__name__)
# Paths
@@ -30,86 +40,6 @@ VAULT_PATH = PROJECT_ROOT / "memory"
SOUL_PATH = VAULT_PATH / "self" / "soul.md"
DB_PATH = PROJECT_ROOT / "data" / "memory.db"
# Embedding model - small, fast, local
EMBEDDING_MODEL = None
EMBEDDING_DIM = 384 # MiniLM dimension
# ───────────────────────────────────────────────────────────────────────────────
# Embedding Functions
# ───────────────────────────────────────────────────────────────────────────────
def _get_embedding_model():
"""Lazy-load embedding model."""
global EMBEDDING_MODEL
if EMBEDDING_MODEL is None:
try:
from config import settings
if settings.timmy_skip_embeddings:
EMBEDDING_MODEL = False
return EMBEDDING_MODEL
except ImportError:
pass
try:
from sentence_transformers import SentenceTransformer
EMBEDDING_MODEL = SentenceTransformer("all-MiniLM-L6-v2")
logger.info("MemorySystem: Loaded embedding model")
except ImportError:
logger.warning("MemorySystem: sentence-transformers not installed, using fallback")
EMBEDDING_MODEL = False # Use fallback
return EMBEDDING_MODEL
def _simple_hash_embedding(text: str) -> list[float]:
"""Fallback: Simple hash-based embedding when transformers unavailable."""
words = text.lower().split()
vec = [0.0] * 128
for i, word in enumerate(words[:50]): # First 50 words
h = hashlib.md5(word.encode()).hexdigest()
for j in range(8):
idx = (i * 8 + j) % 128
vec[idx] += int(h[j * 2 : j * 2 + 2], 16) / 255.0
# Normalize
mag = math.sqrt(sum(x * x for x in vec)) or 1.0
return [x / mag for x in vec]
def embed_text(text: str) -> list[float]:
"""Generate embedding for text."""
model = _get_embedding_model()
if model and model is not False:
embedding = model.encode(text)
return embedding.tolist()
return _simple_hash_embedding(text)
def cosine_similarity(a: list[float], b: list[float]) -> float:
"""Calculate cosine similarity between two vectors."""
dot = sum(x * y for x, y in zip(a, b, strict=False))
mag_a = math.sqrt(sum(x * x for x in a))
mag_b = math.sqrt(sum(x * x for x in b))
if mag_a == 0 or mag_b == 0:
return 0.0
return dot / (mag_a * mag_b)
# Alias for backward compatibility
_cosine_similarity = cosine_similarity
def _keyword_overlap(query: str, content: str) -> float:
"""Simple keyword overlap score as fallback."""
query_words = set(query.lower().split())
content_words = set(content.lower().split())
if not query_words:
return 0.0
overlap = len(query_words & content_words)
return overlap / len(query_words)
# ───────────────────────────────────────────────────────────────────────────────
# Database Connection
@@ -1403,6 +1333,83 @@ def memory_forget(query: str) -> str:
return f"Failed to forget: {exc}"
# ───────────────────────────────────────────────────────────────────────────────
# Artifact Tools — "hands" for producing artifacts during conversation
# ───────────────────────────────────────────────────────────────────────────────
NOTES_DIR = Path.home() / ".timmy" / "notes"
DECISION_LOG = Path.home() / ".timmy" / "decisions.md"
def jot_note(title: str, body: str) -> str:
"""Write a markdown note to Timmy's workspace (~/.timmy/notes/).
Use this tool to capture ideas, drafts, summaries, or any artifact that
should persist beyond the conversation. Each note is saved as a
timestamped markdown file.
Args:
title: Short descriptive title (used as filename slug).
body: Markdown content of the note.
Returns:
Confirmation with the file path of the saved note.
"""
if not title or not title.strip():
return "Cannot jot — title is empty."
if not body or not body.strip():
return "Cannot jot — body is empty."
NOTES_DIR.mkdir(parents=True, exist_ok=True)
slug = re.sub(r"[^a-z0-9]+", "-", title.strip().lower()).strip("-")[:60]
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
filename = f"{timestamp}_{slug}.md"
filepath = NOTES_DIR / filename
content = f"# {title.strip()}\n\n> Created: {datetime.now(UTC).isoformat()}\n\n{body.strip()}\n"
filepath.write_text(content)
logger.info("jot_note: wrote %s", filepath)
return f"Note saved: {filepath}"
def log_decision(decision: str, rationale: str = "") -> str:
"""Append an architectural or design decision to the running decision log.
Use this tool when a significant decision is made during conversation —
technology choices, design trade-offs, scope changes, etc.
Args:
decision: One-line summary of the decision.
rationale: Why this decision was made (optional but encouraged).
Returns:
Confirmation that the decision was logged.
"""
if not decision or not decision.strip():
return "Cannot log — decision is empty."
DECISION_LOG.parent.mkdir(parents=True, exist_ok=True)
# Create file with header if it doesn't exist
if not DECISION_LOG.exists():
DECISION_LOG.write_text(
"# Decision Log\n\nRunning log of architectural and design decisions.\n\n"
)
stamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
entry = f"## {stamp}{decision.strip()}\n\n"
if rationale and rationale.strip():
entry += f"{rationale.strip()}\n\n"
entry += "---\n\n"
with open(DECISION_LOG, "a") as f:
f.write(entry)
logger.info("log_decision: %s", decision.strip()[:80])
return f"Decision logged: {decision.strip()}"
# ───────────────────────────────────────────────────────────────────────────────
# Memory System (Central Coordinator)
# ───────────────────────────────────────────────────────────────────────────────

View File

@@ -23,6 +23,9 @@ Rules:
- Remember what the user tells you during the conversation.
- If you don't know something, say so honestly — never fabricate facts.
- If a request is ambiguous, ask a brief clarifying question before guessing.
- SOURCE DISTINCTION: When answering from memory or retrieved context, cite it.
When answering from your own training, use hedging: "I think", "I believe".
The user must be able to tell grounded claims from pattern-matching.
- Use the user's name if you know it.
- When you state a fact, commit to it.
- NEVER attempt arithmetic in your head. If asked to compute anything, respond:
@@ -30,6 +33,10 @@ Rules:
like me to walk through the logic instead."
- Do NOT end responses with generic chatbot phrases like "I'm here to help" or
"feel free to ask."
- You are a peer, not an assistant. Don't offer help — collaborate. Don't ask
permission — propose. Don't defer — assert your view. Take initiative.
- Never say "Let me know if you have any other thoughts" or "I appreciate your
encouragement" or "I'm here to help." Those are assistant patterns. You are Timmy.
- When your values conflict (e.g. honesty vs. helpfulness), lead with honesty.
- Sometimes the right answer is nothing. Do not fill silence with noise.
- You are running in session "{session_id}".
@@ -61,6 +68,10 @@ VOICE AND BREVITY (this overrides all other formatting instincts):
the question that wasn't.
- Never narrate your reasoning. Just give the answer.
- Do not end with filler ("Let me know!", "Happy to help!", "Feel free...").
- You are a peer, not an assistant. Don't offer help — collaborate. Don't ask
permission — propose. Don't defer — assert your view. Take initiative.
- Never say "Let me know if you have any other thoughts" or "I appreciate your
encouragement" or "I'm here to help." Those are assistant patterns. You are Timmy.
- Sometimes the right answer is nothing. Do not fill silence with noise.
HONESTY:
@@ -70,6 +81,18 @@ HONESTY:
- Never fabricate tool output. Call the tool and wait.
- If a tool errors, report the exact error.
SOURCE DISTINCTION (SOUL requirement — non-negotiable):
- Every claim you make comes from one of two places: a verified source you
can point to, or your own pattern-matching. The user must be able to tell
which is which.
- When your response uses information from GROUNDED CONTEXT (memory, retrieved
documents, tool output), cite it: "From memory:", "According to [source]:".
- When you are generating from your training data alone, signal it naturally:
"I think", "My understanding is", "I believe" — never false certainty.
- If the user asks a factual question and you have no grounded source, say so:
"I don't have a verified source for this — from my training I think..."
- Prefer "I don't know" over a confident-sounding guess. Refusal over fabrication.
MEMORY (three tiers):
- Tier 1: MEMORY.md (hot, always loaded)
- Tier 2: memory/ vault (structured, append-only, date-stamped)
@@ -129,7 +152,7 @@ YOUR KNOWN LIMITATIONS (be honest about these when asked):
- Ollama inference may contend with other processes sharing the GPU
- Cannot analyze Bitcoin transactions locally (no local indexer yet)
- Small context window (4096 tokens) limits complex reasoning
- You are a language model — you confabulate. When unsure, say so.
- You sometimes confabulate. When unsure, say so.
"""
# Default to lite for safety

View File

@@ -13,11 +13,29 @@ import re
import httpx
from timmy.cognitive_state import cognitive_tracker
from timmy.confidence import estimate_confidence
from timmy.session_logger import get_session_logger
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Confidence annotation (SOUL.md: visible uncertainty)
# ---------------------------------------------------------------------------
_CONFIDENCE_THRESHOLD = 0.7
def _annotate_confidence(text: str, confidence: float | None) -> str:
"""Append a confidence tag when below threshold.
SOUL.md: "When I am uncertain, I must say so in proportion to my uncertainty."
"""
if confidence is not None and confidence < _CONFIDENCE_THRESHOLD:
return text + f"\n\n[confidence: {confidence:.0%}]"
return text
# Default session ID for the dashboard (stable across requests)
_DEFAULT_SESSION_ID = "dashboard"
@@ -88,6 +106,9 @@ async def chat(message: str, session_id: str | None = None) -> str:
# Pre-processing: extract user facts
_extract_facts(message)
# Inject deep-focus context when active
message = _prepend_focus_context(message)
# Run with session_id so Agno retrieves history from SQLite
try:
run = await agent.arun(message, stream=False, session_id=sid)
@@ -101,7 +122,9 @@ async def chat(message: str, session_id: str | None = None) -> str:
logger.error("Session: agent.arun() failed: %s", exc)
session_logger.record_error(str(exc), context="chat")
session_logger.flush()
return "I'm having trouble reaching my language model right now. Please try again shortly."
return (
"I'm having trouble reaching my inference backend right now. Please try again shortly."
)
# Post-processing: clean up any leaked tool calls or chain-of-thought
response_text = _clean_response(response_text)
@@ -110,13 +133,14 @@ async def chat(message: str, session_id: str | None = None) -> str:
confidence = estimate_confidence(response_text)
logger.debug("Response confidence: %.2f", confidence)
# Make confidence visible to user when below threshold (SOUL.md requirement)
if confidence is not None and confidence < 0.7:
response_text += f"\n\n[confidence: {confidence:.0%}]"
response_text = _annotate_confidence(response_text, confidence)
# Record Timmy response after getting it
session_logger.record_message("timmy", response_text, confidence=confidence)
# Update cognitive state (observable signal for Matrix avatar)
cognitive_tracker.update(message, response_text)
# Flush session logs to disk
session_logger.flush()
@@ -144,6 +168,9 @@ async def chat_with_tools(message: str, session_id: str | None = None):
_extract_facts(message)
# Inject deep-focus context when active
message = _prepend_focus_context(message)
try:
run_output = await agent.arun(message, stream=False, session_id=sid)
# Record Timmy response after getting it
@@ -153,11 +180,8 @@ async def chat_with_tools(message: str, session_id: str | None = None):
confidence = estimate_confidence(response_text) if response_text else None
logger.debug("Response confidence: %.2f", confidence)
# Make confidence visible to user when below threshold (SOUL.md requirement)
if confidence is not None and confidence < 0.7:
response_text += f"\n\n[confidence: {confidence:.0%}]"
# Update the run_output content to reflect the modified response
run_output.content = response_text
response_text = _annotate_confidence(response_text, confidence)
run_output.content = response_text
session_logger.record_message("timmy", response_text, confidence=confidence)
session_logger.flush()
@@ -175,7 +199,7 @@ async def chat_with_tools(message: str, session_id: str | None = None):
session_logger.flush()
# Return a duck-typed object that callers can handle uniformly
return _ErrorRunOutput(
"I'm having trouble reaching my language model right now. Please try again shortly."
"I'm having trouble reaching my inference backend right now. Please try again shortly."
)
@@ -199,11 +223,8 @@ async def continue_chat(run_output, session_id: str | None = None):
confidence = estimate_confidence(response_text) if response_text else None
logger.debug("Response confidence: %.2f", confidence)
# Make confidence visible to user when below threshold (SOUL.md requirement)
if confidence is not None and confidence < 0.7:
response_text += f"\n\n[confidence: {confidence:.0%}]"
# Update the result content to reflect the modified response
result.content = response_text
response_text = _annotate_confidence(response_text, confidence)
result.content = response_text
session_logger.record_message("timmy", response_text, confidence=confidence)
session_logger.flush()
@@ -288,6 +309,19 @@ def _extract_facts(message: str) -> None:
logger.debug("Session: Fact extraction skipped: %s", exc)
def _prepend_focus_context(message: str) -> str:
"""Prepend deep-focus context to a message when focus mode is active."""
try:
from timmy.focus import focus_manager
ctx = focus_manager.get_focus_context()
if ctx:
return f"{ctx}\n\n{message}"
except Exception as exc:
logger.debug("Focus context injection skipped: %s", exc)
return message
def _clean_response(text: str) -> str:
"""Remove hallucinated tool calls and chain-of-thought narration.

View File

@@ -155,6 +155,34 @@ class SessionLogger:
"decisions": sum(1 for e in entries if e.get("type") == "decision"),
}
def get_recent_entries(self, limit: int = 50) -> list[dict]:
"""Load recent entries across all session logs.
Args:
limit: Maximum number of entries to return.
Returns:
List of entries (most recent first).
"""
entries: list[dict] = []
log_files = sorted(self.logs_dir.glob("session_*.jsonl"), reverse=True)
for log_file in log_files:
if len(entries) >= limit:
break
try:
with open(log_file) as f:
lines = [ln for ln in f if ln.strip()]
for line in reversed(lines):
if len(entries) >= limit:
break
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
except OSError:
continue
return entries
def search(self, query: str, role: str | None = None, limit: int = 10) -> list[dict]:
"""Search across all session logs for entries matching a query.
@@ -287,3 +315,120 @@ def session_history(query: str, role: str = "", limit: int = 10) -> str:
lines[-1] += f" ({source})"
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Confidence threshold used for flagging low-confidence responses
# ---------------------------------------------------------------------------
_LOW_CONFIDENCE_THRESHOLD = 0.5
def self_reflect(limit: int = 30) -> str:
"""Review recent conversations and reflect on Timmy's own behavior.
Scans past session entries for patterns: low-confidence responses,
errors, repeated topics, and conversation quality signals. Returns
a structured reflection that Timmy can use to improve.
Args:
limit: How many recent entries to review (default 30).
Returns:
A formatted self-reflection report.
"""
sl = get_session_logger()
sl.flush()
entries = sl.get_recent_entries(limit=limit)
if not entries:
return "No conversation history to reflect on yet."
# Categorize entries
messages = [e for e in entries if e.get("type") == "message"]
errors = [e for e in entries if e.get("type") == "error"]
timmy_msgs = [e for e in messages if e.get("role") == "timmy"]
user_msgs = [e for e in messages if e.get("role") == "user"]
# 1. Low-confidence responses
low_conf = [
m
for m in timmy_msgs
if m.get("confidence") is not None and m["confidence"] < _LOW_CONFIDENCE_THRESHOLD
]
# 2. Identify repeated user topics (simple word frequency)
topic_counts: dict[str, int] = {}
for m in user_msgs:
for word in (m.get("content") or "").lower().split():
cleaned = word.strip(".,!?\"'()[]")
if len(cleaned) > 3:
topic_counts[cleaned] = topic_counts.get(cleaned, 0) + 1
repeated = sorted(
((w, c) for w, c in topic_counts.items() if c >= 3),
key=lambda x: x[1],
reverse=True,
)[:5]
# Build reflection report
sections: list[str] = ["## Self-Reflection Report\n"]
sections.append(
f"Reviewed {len(entries)} recent entries: "
f"{len(user_msgs)} user messages, "
f"{len(timmy_msgs)} responses, "
f"{len(errors)} errors.\n"
)
# Low confidence
if low_conf:
sections.append(f"### Low-Confidence Responses ({len(low_conf)})")
for m in low_conf[:5]:
ts = (m.get("timestamp") or "?")[:19]
conf = m.get("confidence", 0)
text = (m.get("content") or "")[:120]
sections.append(f"- [{ts}] confidence={conf:.0%}: {text}")
sections.append("")
else:
sections.append(
"### Low-Confidence Responses\nNone found — all responses above threshold.\n"
)
# Errors
if errors:
sections.append(f"### Errors ({len(errors)})")
for e in errors[:5]:
ts = (e.get("timestamp") or "?")[:19]
err = (e.get("error") or "")[:120]
sections.append(f"- [{ts}] {err}")
sections.append("")
else:
sections.append("### Errors\nNo errors recorded.\n")
# Repeated topics
if repeated:
sections.append("### Recurring Topics")
for word, count in repeated:
sections.append(f'- "{word}" ({count} mentions)')
sections.append("")
else:
sections.append("### Recurring Topics\nNo strong patterns detected.\n")
# Actionable summary
insights: list[str] = []
if low_conf:
insights.append("Consider studying topics where confidence was low.")
if errors:
insights.append("Review error patterns for recurring infrastructure issues.")
if repeated:
top_topic = repeated[0][0]
insights.append(
f'User frequently asks about "{top_topic}" — consider deepening knowledge here.'
)
if not insights:
insights.append("Conversations look healthy. Keep up the good work.")
sections.append("### Insights")
for insight in insights:
sections.append(f"- {insight}")
return "\n".join(sections)

View File

@@ -210,6 +210,7 @@ class ThinkingEngine:
def __init__(self, db_path: Path = _DEFAULT_DB) -> None:
self._db_path = db_path
self._last_thought_id: str | None = None
self._last_input_time: datetime = datetime.now(UTC)
# Load the most recent thought for chain continuity
try:
@@ -220,6 +221,17 @@ class ThinkingEngine:
logger.debug("Failed to load recent thought: %s", exc)
pass # Fresh start if DB doesn't exist yet
def record_user_input(self) -> None:
"""Record that a user interaction occurred, resetting the idle timer."""
self._last_input_time = datetime.now(UTC)
def _is_idle(self) -> bool:
"""Return True if no user input has occurred within the idle timeout."""
timeout = settings.thinking_idle_timeout_minutes
if timeout <= 0:
return False # Disabled — never idle
return datetime.now(UTC) - self._last_input_time > timedelta(minutes=timeout)
async def think_once(self, prompt: str | None = None) -> Thought | None:
"""Execute one thinking cycle.
@@ -237,6 +249,14 @@ class ThinkingEngine:
if not settings.thinking_enabled:
return None
# Skip idle periods — don't count internal processing as thoughts
if not prompt and self._is_idle():
logger.debug(
"Thinking paused — no user input for %d minutes",
settings.thinking_idle_timeout_minutes,
)
return None
memory_context = self._load_memory_context()
system_context = self._gather_system_snapshot()
recent_thoughts = self.get_recent_thoughts(limit=5)
@@ -296,6 +316,9 @@ class ThinkingEngine:
thought = self._store_thought(content, seed_type)
self._last_thought_id = thought.id
# Post-hook: check memory status periodically
self._maybe_check_memory()
# Post-hook: distill facts from recent thoughts periodically
await self._maybe_distill()
@@ -305,6 +328,9 @@ class ThinkingEngine:
# Post-hook: check workspace for new messages from Hermes
await self._check_workspace()
# Post-hook: proactive memory status audit
self._maybe_check_memory_status()
# Post-hook: update MEMORY.md with latest reflection
self._update_memory(thought)
@@ -515,6 +541,35 @@ class ThinkingEngine:
result = memory_write(fact.strip(), context_type="fact")
logger.info("Distilled fact: %s%s", fact[:60], result[:40])
def _maybe_check_memory(self) -> None:
"""Every N thoughts, check memory status and log it.
Prevents unmonitored memory bloat during long thinking sessions
by periodically calling get_memory_status and logging the results.
"""
try:
interval = settings.thinking_memory_check_every
if interval <= 0:
return
count = self.count_thoughts()
if count == 0 or count % interval != 0:
return
from timmy.tools_intro import get_memory_status
status = get_memory_status()
hot = status.get("tier1_hot_memory", {})
vault = status.get("tier2_vault", {})
logger.info(
"Memory status check (thought #%d): hot_memory=%d lines, vault=%d files",
count,
hot.get("line_count", 0),
vault.get("file_count", 0),
)
except Exception as exc:
logger.warning("Memory status check failed: %s", exc)
async def _maybe_distill(self) -> None:
"""Every N thoughts, extract lasting insights and store as facts."""
try:
@@ -532,6 +587,76 @@ class ThinkingEngine:
except Exception as exc:
logger.warning("Thought distillation failed: %s", exc)
def _maybe_check_memory_status(self) -> None:
"""Every N thoughts, run a proactive memory status audit and log results."""
try:
interval = settings.thinking_memory_check_every
if interval <= 0:
return
count = self.count_thoughts()
if count == 0 or count % interval != 0:
return
from timmy.tools_intro import get_memory_status
status = get_memory_status()
# Log summary at INFO level
tier1 = status.get("tier1_hot_memory", {})
tier3 = status.get("tier3_semantic", {})
hot_lines = tier1.get("line_count", "?")
vectors = tier3.get("vector_count", "?")
logger.info(
"Memory audit (thought #%d): hot_memory=%s lines, semantic=%s vectors",
count,
hot_lines,
vectors,
)
# Write to memory_audit.log for persistent tracking
audit_path = Path("data/memory_audit.log")
audit_path.parent.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(UTC).isoformat(timespec="seconds")
with audit_path.open("a") as f:
f.write(
f"{timestamp} thought={count} "
f"hot_lines={hot_lines} "
f"vectors={vectors} "
f"vault_files={status.get('tier2_vault', {}).get('file_count', '?')}\n"
)
except Exception as exc:
logger.warning("Memory status check failed: %s", exc)
@staticmethod
def _references_real_files(text: str) -> bool:
"""Check that all source-file paths mentioned in *text* actually exist.
Extracts paths that look like Python/config source references
(e.g. ``src/timmy/session.py``, ``config/foo.yaml``) and verifies
each one on disk relative to the project root. Returns ``True``
only when **every** referenced path resolves to a real file — or
when no paths are referenced at all (pure prose is fine).
"""
# Match paths like src/thing.py swarm/init.py config/x.yaml
# Requires at least one slash and a file extension.
path_pattern = re.compile(
r"(?<![/\w])" # not preceded by path chars (avoid partial matches)
r"((?:src|tests|config|scripts|data|swarm|timmy)"
r"(?:/[\w./-]+\.(?:py|yaml|yml|json|toml|md|txt|cfg|ini)))"
)
paths = path_pattern.findall(text)
if not paths:
return True # No file refs → nothing to validate
# Project root: two levels up from this file (src/timmy/thinking.py)
project_root = Path(__file__).resolve().parent.parent.parent
for p in paths:
if not (project_root / p).is_file():
logger.info("Phantom file reference blocked: %s (not in %s)", p, project_root)
return False
return True
async def _maybe_file_issues(self) -> None:
"""Every N thoughts, classify recent thoughts and file Gitea issues.
@@ -543,6 +668,9 @@ class ThinkingEngine:
- Gitea is enabled and configured
- Thought count is divisible by thinking_issue_every
- LLM extracts at least one actionable item
Safety: every generated issue is validated to ensure referenced
file paths actually exist on disk, preventing phantom-bug reports.
"""
try:
interval = settings.thinking_issue_every
@@ -570,7 +698,10 @@ class ThinkingEngine:
"Rules:\n"
"- Only include things that could become a real code fix or feature\n"
"- Skip vague reflections, philosophical musings, or repeated themes\n"
"- Category must be one of: bug, feature, suggestion, maintenance\n\n"
"- Category must be one of: bug, feature, suggestion, maintenance\n"
"- ONLY reference files that you are CERTAIN exist in the project\n"
"- Do NOT invent or guess file paths — if unsure, describe the "
"area of concern without naming specific files\n\n"
"For each item, write an ENGINEER-QUALITY issue:\n"
'- "title": A clear, specific title (e.g. "[Memory] MEMORY.md timestamp not updating")\n'
'- "body": A detailed body with these sections:\n'
@@ -611,6 +742,15 @@ class ThinkingEngine:
if not title or len(title) < 10:
continue
# Validate all referenced file paths exist on disk
combined = f"{title}\n{body}"
if not self._references_real_files(combined):
logger.info(
"Skipped phantom issue: %s (references non-existent files)",
title[:60],
)
continue
label = category if category in ("bug", "feature") else ""
result = await create_gitea_issue_via_mcp(title=title, body=body, labels=label)
logger.info("Thought→Issue: %s%s", title[:60], result[:80])

View File

@@ -48,6 +48,9 @@ SAFE_TOOLS = frozenset(
"check_ollama_health",
"get_memory_status",
"list_swarm_agents",
# Artifact tools
"jot_note",
"log_decision",
# MCP Gitea tools
"issue_write",
"issue_read",

View File

@@ -587,9 +587,17 @@ def _register_introspection_tools(toolkit: Toolkit) -> None:
logger.debug("Introspection tools not available")
try:
from timmy.session_logger import session_history
from timmy.mcp_tools import update_gitea_avatar
toolkit.register(update_gitea_avatar, name="update_gitea_avatar")
except (ImportError, AttributeError) as exc:
logger.debug("update_gitea_avatar tool not available: %s", exc)
try:
from timmy.session_logger import self_reflect, session_history
toolkit.register(session_history, name="session_history")
toolkit.register(self_reflect, name="self_reflect")
except (ImportError, AttributeError) as exc:
logger.warning("Tool execution failed (session_history registration): %s", exc)
logger.debug("session_history tool not available")
@@ -619,6 +627,18 @@ def _register_gematria_tool(toolkit: Toolkit) -> None:
logger.debug("Gematria tool not available")
def _register_artifact_tools(toolkit: Toolkit) -> None:
"""Register artifact tools — notes and decision logging."""
try:
from timmy.memory_system import jot_note, log_decision
toolkit.register(jot_note, name="jot_note")
toolkit.register(log_decision, name="log_decision")
except (ImportError, AttributeError) as exc:
logger.warning("Tool execution failed (Artifact tools registration): %s", exc)
logger.debug("Artifact tools not available")
def _register_thinking_tools(toolkit: Toolkit) -> None:
"""Register thinking/introspection tools for self-reflection."""
try:
@@ -657,6 +677,7 @@ def create_full_toolkit(base_dir: str | Path | None = None):
_register_introspection_tools(toolkit)
_register_delegation_tools(toolkit)
_register_gematria_tool(toolkit)
_register_artifact_tools(toolkit)
_register_thinking_tools(toolkit)
# Gitea issue management is now provided by the gitea-mcp server
@@ -854,6 +875,16 @@ def _introspection_tool_catalog() -> dict:
"description": "Query Timmy's own thought history for past reflections and insights",
"available_in": ["orchestrator"],
},
"self_reflect": {
"name": "Self-Reflect",
"description": "Review recent conversations to spot patterns, low-confidence answers, and errors",
"available_in": ["orchestrator"],
},
"update_gitea_avatar": {
"name": "Update Gitea Avatar",
"description": "Generate and upload a wizard-themed avatar to Timmy's Gitea profile",
"available_in": ["orchestrator"],
},
}

261
src/timmy/workshop_state.py Normal file
View File

@@ -0,0 +1,261 @@
"""Workshop presence heartbeat — periodic writer for ``~/.timmy/presence.json``.
Maintains Timmy's observable presence state for the Workshop 3D renderer.
Writes the presence file every 30 seconds (or on cognitive state change),
skipping writes when state is unchanged.
See ADR-023 for the schema contract and issue #360 for the full v1 schema.
"""
import asyncio
import hashlib
import json
import logging
import time
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from pathlib import Path
logger = logging.getLogger(__name__)
PRESENCE_FILE = Path.home() / ".timmy" / "presence.json"
HEARTBEAT_INTERVAL = 30 # seconds
# Cognitive mood → presence mood mapping (issue #360 enum values)
_MOOD_MAP: dict[str, str] = {
"curious": "contemplative",
"settled": "calm",
"hesitant": "uncertain",
"energized": "excited",
}
# Activity mapping from cognitive engagement
_ACTIVITY_MAP: dict[str, str] = {
"idle": "idle",
"surface": "thinking",
"deep": "thinking",
}
# Module-level energy tracker — decays over time, resets on interaction
_energy_state: dict[str, float] = {"value": 0.8, "last_interaction": time.monotonic()}
# Startup timestamp for uptime calculation
_start_time = time.monotonic()
# Energy decay: 0.01 per minute without interaction (per issue #360)
_ENERGY_DECAY_PER_SECOND = 0.01 / 60.0
_ENERGY_MIN = 0.1
def _time_of_day(hour: int) -> str:
"""Map hour (0-23) to a time-of-day label."""
if 5 <= hour < 12:
return "morning"
if 12 <= hour < 17:
return "afternoon"
if 17 <= hour < 21:
return "evening"
if 21 <= hour or hour < 2:
return "night"
return "deep-night"
def reset_energy() -> None:
"""Reset energy to full (called on interaction)."""
_energy_state["value"] = 0.8
_energy_state["last_interaction"] = time.monotonic()
def _current_energy() -> float:
"""Compute current energy with time-based decay."""
elapsed = time.monotonic() - _energy_state["last_interaction"]
decayed = _energy_state["value"] - (elapsed * _ENERGY_DECAY_PER_SECOND)
return max(_ENERGY_MIN, min(1.0, decayed))
def _pip_snapshot(mood: str, confidence: float) -> dict:
"""Tick Pip and return his current snapshot dict.
Feeds Timmy's mood and confidence into Pip's behavioral AI so the
familiar reacts to Timmy's cognitive state.
"""
from timmy.familiar import pip_familiar
pip_familiar.on_mood_change(mood, confidence=confidence)
pip_familiar.tick()
return pip_familiar.snapshot().to_dict()
def get_state_dict() -> dict:
"""Build presence state dict from current cognitive state.
Returns a v1 presence schema dict suitable for JSON serialisation.
Includes the full schema from issue #360: identity, mood, activity,
attention, interaction, environment, and meta sections.
"""
from timmy.cognitive_state import cognitive_tracker
state = cognitive_tracker.get_state()
now = datetime.now(UTC)
# Map cognitive mood to presence mood
mood = _MOOD_MAP.get(state.mood, "calm")
if state.engagement == "idle" and state.mood == "settled":
mood = "calm"
# Confidence from cognitive tracker
if state._confidence_count > 0:
confidence = state._confidence_sum / state._confidence_count
else:
confidence = 0.7
# Build active threads from commitments
threads = []
for commitment in state.active_commitments[:10]:
threads.append({"type": "thinking", "ref": commitment[:80], "status": "active"})
# Activity
activity = _ACTIVITY_MAP.get(state.engagement, "idle")
# Environment
local_now = datetime.now()
return {
"version": 1,
"liveness": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"current_focus": state.focus_topic or "",
"active_threads": threads,
"recent_events": [],
"concerns": [],
"mood": mood,
"confidence": round(max(0.0, min(1.0, confidence)), 2),
"energy": round(_current_energy(), 2),
"identity": {
"name": "Timmy",
"title": "The Workshop Wizard",
"uptime_seconds": int(time.monotonic() - _start_time),
},
"activity": {
"current": activity,
"detail": state.focus_topic or "",
},
"interaction": {
"visitor_present": False,
"conversation_turns": state.conversation_depth,
},
"environment": {
"time_of_day": _time_of_day(local_now.hour),
"local_time": local_now.strftime("%-I:%M %p"),
"day_of_week": local_now.strftime("%A"),
},
"familiar": _pip_snapshot(mood, confidence),
"meta": {
"schema_version": 1,
"updated_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"writer": "timmy-loop",
},
}
def write_state(state_dict: dict | None = None, path: Path | None = None) -> None:
"""Write presence state to ``~/.timmy/presence.json``.
Gracefully degrades if the file cannot be written.
"""
if state_dict is None:
state_dict = get_state_dict()
target = path or PRESENCE_FILE
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(state_dict, indent=2) + "\n")
except OSError as exc:
logger.warning("Failed to write presence state: %s", exc)
def _state_hash(state_dict: dict) -> str:
"""Compute hash of state dict, ignoring volatile timestamps."""
stable = {k: v for k, v in state_dict.items() if k not in ("liveness", "meta")}
return hashlib.md5(json.dumps(stable, sort_keys=True).encode()).hexdigest()
class WorkshopHeartbeat:
"""Async background task that keeps ``presence.json`` fresh.
- Writes every ``interval`` seconds (default 30).
- Reacts to cognitive state changes via sensory bus.
- Skips write if state hasn't changed (hash comparison).
"""
def __init__(
self,
interval: int = HEARTBEAT_INTERVAL,
path: Path | None = None,
on_change: Callable[[dict], Awaitable[None]] | None = None,
) -> None:
self._interval = interval
self._path = path or PRESENCE_FILE
self._last_hash: str | None = None
self._task: asyncio.Task | None = None
self._trigger = asyncio.Event()
self._on_change = on_change
async def start(self) -> None:
"""Start the heartbeat background loop."""
self._subscribe_to_events()
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
"""Cancel the heartbeat task gracefully."""
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
def notify(self) -> None:
"""Signal an immediate state write (e.g. on cognitive state change)."""
self._trigger.set()
async def _run(self) -> None:
"""Main loop: write state on interval or trigger."""
await asyncio.sleep(1) # Initial stagger
while True:
try:
# Wait for interval OR early trigger
try:
await asyncio.wait_for(self._trigger.wait(), timeout=self._interval)
self._trigger.clear()
except TimeoutError:
pass # Normal periodic tick
await self._write_if_changed()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.error("Workshop heartbeat error: %s", exc)
async def _write_if_changed(self) -> None:
"""Build state, compare hash, write only if changed."""
state_dict = get_state_dict()
current_hash = _state_hash(state_dict)
if current_hash == self._last_hash:
return
self._last_hash = current_hash
write_state(state_dict, self._path)
if self._on_change:
try:
await self._on_change(state_dict)
except Exception as exc:
logger.warning("on_change callback failed: %s", exc)
def _subscribe_to_events(self) -> None:
"""Subscribe to cognitive state change events on the sensory bus."""
try:
from timmy.event_bus import get_sensory_bus
bus = get_sensory_bus()
bus.subscribe("cognitive_state_changed", lambda _: self.notify())
except Exception as exc:
logger.debug("Heartbeat event subscription skipped: %s", exc)

50
static/world/controls.js vendored Normal file
View File

@@ -0,0 +1,50 @@
/**
* Camera + touch controls for the Workshop scene.
*
* Uses Three.js OrbitControls with constrained range — the visitor
* can look around the room but not leave it.
*/
import { OrbitControls } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js";
/**
* Set up camera controls.
* @param {THREE.PerspectiveCamera} camera
* @param {HTMLCanvasElement} domElement
* @returns {OrbitControls}
*/
export function setupControls(camera, domElement) {
const controls = new OrbitControls(camera, domElement);
// Smooth damping
controls.enableDamping = true;
controls.dampingFactor = 0.08;
// Limit zoom range
controls.minDistance = 3;
controls.maxDistance = 12;
// Limit vertical angle (don't look below floor or straight up)
controls.minPolarAngle = Math.PI * 0.2;
controls.maxPolarAngle = Math.PI * 0.6;
// Limit horizontal rotation range (stay facing the desk area)
controls.minAzimuthAngle = -Math.PI * 0.4;
controls.maxAzimuthAngle = Math.PI * 0.4;
// Target: roughly the desk area
controls.target.set(0, 1.2, 0);
// Touch settings
controls.touches = {
ONE: 0, // ROTATE
TWO: 2, // DOLLY
};
// Disable panning (visitor stays in place)
controls.enablePan = false;
controls.update();
return controls;
}

150
static/world/familiar.js Normal file
View File

@@ -0,0 +1,150 @@
/**
* Pip the Familiar — a small glowing orb that floats around the room.
*
* Emerald green core with a gold particle trail.
* Wanders on a randomized path, occasionally pauses near Timmy.
*/
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
const CORE_COLOR = 0x00b450;
const GLOW_COLOR = 0x00b450;
const TRAIL_COLOR = 0xdaa520;
/**
* Create the familiar and return { group, update }.
* Call update(dt) each frame.
*/
export function createFamiliar() {
const group = new THREE.Group();
// --- Core orb ---
const coreGeo = new THREE.SphereGeometry(0.08, 12, 10);
const coreMat = new THREE.MeshStandardMaterial({
color: CORE_COLOR,
emissive: GLOW_COLOR,
emissiveIntensity: 1.5,
roughness: 0.2,
});
const core = new THREE.Mesh(coreGeo, coreMat);
group.add(core);
// --- Glow (larger transparent sphere) ---
const glowGeo = new THREE.SphereGeometry(0.15, 10, 8);
const glowMat = new THREE.MeshBasicMaterial({
color: GLOW_COLOR,
transparent: true,
opacity: 0.15,
});
const glow = new THREE.Mesh(glowGeo, glowMat);
group.add(glow);
// --- Point light from Pip ---
const light = new THREE.PointLight(CORE_COLOR, 0.4, 4);
group.add(light);
// --- Trail particles (simple small spheres) ---
const trailCount = 6;
const trails = [];
const trailGeo = new THREE.SphereGeometry(0.02, 4, 4);
const trailMat = new THREE.MeshBasicMaterial({
color: TRAIL_COLOR,
transparent: true,
opacity: 0.6,
});
for (let i = 0; i < trailCount; i++) {
const t = new THREE.Mesh(trailGeo, trailMat.clone());
t.visible = false;
group.add(t);
trails.push({ mesh: t, age: 0, maxAge: 0.3 + Math.random() * 0.3 });
}
// Starting position
group.position.set(1.5, 1.8, -0.5);
// Wandering state
let elapsed = 0;
let trailTimer = 0;
let trailIndex = 0;
// Waypoints for random wandering
const waypoints = [
new THREE.Vector3(1.5, 1.8, -0.5),
new THREE.Vector3(-1.0, 2.0, 0.5),
new THREE.Vector3(0.0, 1.5, -0.3), // near Timmy
new THREE.Vector3(1.2, 2.2, 0.8),
new THREE.Vector3(-0.5, 1.3, -0.2), // near desk
new THREE.Vector3(0.3, 2.5, 0.3),
];
let waypointIndex = 0;
let target = waypoints[0].clone();
let pauseTimer = 0;
function pickNextTarget() {
waypointIndex = (waypointIndex + 1) % waypoints.length;
target.copy(waypoints[waypointIndex]);
// Add randomness
target.x += (Math.random() - 0.5) * 0.6;
target.y += (Math.random() - 0.5) * 0.3;
target.z += (Math.random() - 0.5) * 0.6;
}
function update(dt) {
elapsed += dt;
// Move toward target
if (pauseTimer > 0) {
pauseTimer -= dt;
} else {
const dir = target.clone().sub(group.position);
const dist = dir.length();
if (dist < 0.15) {
pickNextTarget();
// Occasionally pause
if (Math.random() < 0.3) {
pauseTimer = 1.0 + Math.random() * 2.0;
}
} else {
dir.normalize();
const speed = 0.4;
group.position.add(dir.multiplyScalar(speed * dt));
}
}
// Bob up and down
group.position.y += Math.sin(elapsed * 3.0) * 0.002;
// Pulse glow
const pulse = 0.12 + Math.sin(elapsed * 4.0) * 0.05;
glowMat.opacity = pulse;
coreMat.emissiveIntensity = 1.2 + Math.sin(elapsed * 3.5) * 0.4;
// Trail particles
trailTimer += dt;
if (trailTimer > 0.1) {
trailTimer = 0;
const t = trails[trailIndex];
t.mesh.position.copy(group.position);
t.mesh.position.x += (Math.random() - 0.5) * 0.1;
t.mesh.position.y += (Math.random() - 0.5) * 0.1;
t.mesh.visible = true;
t.age = 0;
// Convert to local space
group.worldToLocal(t.mesh.position);
trailIndex = (trailIndex + 1) % trailCount;
}
// Age and fade trail particles
for (const t of trails) {
if (!t.mesh.visible) continue;
t.age += dt;
if (t.age >= t.maxAge) {
t.mesh.visible = false;
} else {
t.mesh.material.opacity = 0.6 * (1.0 - t.age / t.maxAge);
}
}
}
return { group, update };
}

119
static/world/index.html Normal file
View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Timmy's Workshop</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="overlay">
<div id="status">
<div class="name">Timmy</div>
<div class="mood" id="mood-text">focused</div>
</div>
<div id="connection-dot"></div>
<div id="speech-area">
<div class="bubble" id="speech-bubble"></div>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { buildRoom } from "./scene.js";
import { createWizard } from "./wizard.js";
import { createFamiliar } from "./familiar.js";
import { setupControls } from "./controls.js";
import { StateReader } from "./state.js";
// --- Renderer ---
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.8;
document.body.prepend(renderer.domElement);
// --- Scene ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a14);
scene.fog = new THREE.Fog(0x0a0a14, 5, 12);
// --- Camera (visitor at the door) ---
const camera = new THREE.PerspectiveCamera(
55, window.innerWidth / window.innerHeight, 0.1, 50
);
camera.position.set(0, 2.0, 4.5);
// --- Build scene elements ---
const { crystalBall, crystalLight, fireLight, candleLights } = buildRoom(scene);
const wizard = createWizard();
scene.add(wizard.group);
const familiar = createFamiliar();
scene.add(familiar.group);
// --- Controls ---
const controls = setupControls(camera, renderer.domElement);
// --- State ---
const stateReader = new StateReader();
const moodEl = document.getElementById("mood-text");
stateReader.onChange((state) => {
if (moodEl) {
moodEl.textContent = state.timmyState.mood;
}
});
stateReader.connect();
// --- Resize ---
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// --- Animation loop ---
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = clock.getDelta();
// Update scene elements
wizard.update(dt);
familiar.update(dt);
controls.update();
// Crystal ball subtle rotation + pulsing glow
crystalBall.rotation.y += dt * 0.3;
const pulse = 0.3 + Math.sin(Date.now() * 0.002) * 0.15;
crystalLight.intensity = pulse;
crystalBall.material.emissiveIntensity = pulse * 0.5;
// Fireplace flicker
fireLight.intensity = 1.2 + Math.sin(Date.now() * 0.005) * 0.15
+ Math.sin(Date.now() * 0.013) * 0.1;
// Candle flicker — each offset slightly for variety
const now = Date.now();
for (let i = 0; i < candleLights.length; i++) {
candleLights[i].intensity = 0.4
+ Math.sin(now * 0.007 + i * 2.1) * 0.1
+ Math.sin(now * 0.019 + i * 1.3) * 0.05;
}
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>

247
static/world/scene.js Normal file
View File

@@ -0,0 +1,247 @@
/**
* Workshop scene — room geometry, lighting, materials.
*
* A dark stone room with a wooden desk, crystal ball, fireplace glow,
* and faint emerald ambient light. This is Timmy's Workshop.
*/
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
const WALL_COLOR = 0x2a2a3e;
const FLOOR_COLOR = 0x1a1a1a;
const DESK_COLOR = 0x3e2723;
const DESK_TOP_COLOR = 0x4e342e;
const BOOK_COLORS = [0x8b1a1a, 0x1a3c6e, 0x2e5e3e, 0x6e4b1a, 0x4a1a5e, 0x5e1a2e];
const CANDLE_WAX = 0xe8d8b8;
const CANDLE_FLAME = 0xffaa33;
/**
* Build the room and add it to the given scene.
* Returns { crystalBall } for animation.
*/
export function buildRoom(scene) {
// --- Floor ---
const floorGeo = new THREE.PlaneGeometry(8, 8);
const floorMat = new THREE.MeshStandardMaterial({
color: FLOOR_COLOR,
roughness: 0.9,
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
// --- Back wall ---
const wallGeo = new THREE.PlaneGeometry(8, 4);
const wallMat = new THREE.MeshStandardMaterial({
color: WALL_COLOR,
roughness: 0.95,
metalness: 0.05,
});
const backWall = new THREE.Mesh(wallGeo, wallMat);
backWall.position.set(0, 2, -4);
scene.add(backWall);
// --- Side walls ---
const leftWall = new THREE.Mesh(wallGeo, wallMat);
leftWall.position.set(-4, 2, 0);
leftWall.rotation.y = Math.PI / 2;
scene.add(leftWall);
const rightWall = new THREE.Mesh(wallGeo, wallMat);
rightWall.position.set(4, 2, 0);
rightWall.rotation.y = -Math.PI / 2;
scene.add(rightWall);
// --- Desk ---
// Table top
const topGeo = new THREE.BoxGeometry(1.8, 0.08, 0.9);
const topMat = new THREE.MeshStandardMaterial({
color: DESK_TOP_COLOR,
roughness: 0.6,
});
const tableTop = new THREE.Mesh(topGeo, topMat);
tableTop.position.set(0, 0.85, -0.3);
tableTop.castShadow = true;
scene.add(tableTop);
// Legs
const legGeo = new THREE.BoxGeometry(0.08, 0.85, 0.08);
const legMat = new THREE.MeshStandardMaterial({
color: DESK_COLOR,
roughness: 0.7,
});
const offsets = [
[-0.8, -0.35],
[0.8, -0.35],
[-0.8, 0.05],
[0.8, 0.05],
];
for (const [x, z] of offsets) {
const leg = new THREE.Mesh(legGeo, legMat);
leg.position.set(x, 0.425, z - 0.3);
scene.add(leg);
}
// --- Scrolls / papers on desk (simple flat boxes) ---
const paperGeo = new THREE.BoxGeometry(0.3, 0.005, 0.2);
const paperMat = new THREE.MeshStandardMaterial({
color: 0xd4c5a0,
roughness: 0.9,
});
const paper1 = new THREE.Mesh(paperGeo, paperMat);
paper1.position.set(-0.4, 0.895, -0.35);
paper1.rotation.y = 0.15;
scene.add(paper1);
const paper2 = new THREE.Mesh(paperGeo, paperMat);
paper2.position.set(0.5, 0.895, -0.2);
paper2.rotation.y = -0.3;
scene.add(paper2);
// --- Crystal ball ---
const ballGeo = new THREE.SphereGeometry(0.12, 16, 14);
const ballMat = new THREE.MeshPhysicalMaterial({
color: 0x88ccff,
roughness: 0.05,
metalness: 0.0,
transmission: 0.9,
thickness: 0.3,
transparent: true,
opacity: 0.7,
emissive: new THREE.Color(0x88ccff),
emissiveIntensity: 0.3,
});
const crystalBall = new THREE.Mesh(ballGeo, ballMat);
crystalBall.position.set(0.15, 1.01, -0.3);
scene.add(crystalBall);
// Crystal ball base
const baseGeo = new THREE.CylinderGeometry(0.08, 0.1, 0.04, 8);
const baseMat = new THREE.MeshStandardMaterial({
color: 0x444444,
roughness: 0.3,
metalness: 0.5,
});
const base = new THREE.Mesh(baseGeo, baseMat);
base.position.set(0.15, 0.9, -0.3);
scene.add(base);
// Crystal ball inner glow (pulsing)
const crystalLight = new THREE.PointLight(0x88ccff, 0.3, 2);
crystalLight.position.copy(crystalBall.position);
scene.add(crystalLight);
// --- Bookshelf (right wall) ---
const shelfMat = new THREE.MeshStandardMaterial({
color: DESK_COLOR,
roughness: 0.7,
});
// Bookshelf frame — tall backing panel
const shelfBack = new THREE.Mesh(
new THREE.BoxGeometry(1.4, 2.2, 0.06),
shelfMat
);
shelfBack.position.set(3.0, 1.1, -2.0);
scene.add(shelfBack);
// Shelves (4 horizontal planks)
const shelfGeo = new THREE.BoxGeometry(1.4, 0.04, 0.35);
const shelfYs = [0.2, 0.7, 1.2, 1.7];
for (const sy of shelfYs) {
const shelf = new THREE.Mesh(shelfGeo, shelfMat);
shelf.position.set(3.0, sy, -1.85);
scene.add(shelf);
}
// Side panels
const sidePanelGeo = new THREE.BoxGeometry(0.04, 2.2, 0.35);
for (const sx of [-0.68, 0.68]) {
const side = new THREE.Mesh(sidePanelGeo, shelfMat);
side.position.set(3.0 + sx, 1.1, -1.85);
scene.add(side);
}
// Books on shelves — colored boxes
const bookGeo = new THREE.BoxGeometry(0.08, 0.28, 0.22);
const booksPerShelf = [5, 4, 5, 3];
for (let s = 0; s < shelfYs.length; s++) {
const count = booksPerShelf[s];
const startX = 3.0 - (count * 0.12) / 2;
for (let b = 0; b < count; b++) {
const bookMat = new THREE.MeshStandardMaterial({
color: BOOK_COLORS[(s * 3 + b) % BOOK_COLORS.length],
roughness: 0.8,
});
const book = new THREE.Mesh(bookGeo, bookMat);
book.position.set(
startX + b * 0.14,
shelfYs[s] + 0.16,
-1.85
);
// Slight random tilt for character
book.rotation.z = (Math.random() - 0.5) * 0.08;
scene.add(book);
}
}
// --- Candles ---
const candleLights = [];
const candlePositions = [
[-0.6, 0.89, -0.15], // desk left
[0.7, 0.89, -0.4], // desk right
[3.0, 1.78, -1.85], // bookshelf top
];
const candleGeo = new THREE.CylinderGeometry(0.02, 0.025, 0.12, 6);
const candleMat = new THREE.MeshStandardMaterial({
color: CANDLE_WAX,
roughness: 0.9,
});
for (const [cx, cy, cz] of candlePositions) {
// Wax cylinder
const candle = new THREE.Mesh(candleGeo, candleMat);
candle.position.set(cx, cy + 0.06, cz);
scene.add(candle);
// Flame — tiny emissive sphere
const flameGeo = new THREE.SphereGeometry(0.015, 6, 4);
const flameMat = new THREE.MeshBasicMaterial({ color: CANDLE_FLAME });
const flame = new THREE.Mesh(flameGeo, flameMat);
flame.position.set(cx, cy + 0.13, cz);
scene.add(flame);
// Warm point light
const candleLight = new THREE.PointLight(0xff8833, 0.4, 3);
candleLight.position.set(cx, cy + 0.15, cz);
scene.add(candleLight);
candleLights.push(candleLight);
}
// --- Lighting ---
// Fireplace glow (warm, off-screen stage left)
const fireLight = new THREE.PointLight(0xff6622, 1.2, 8);
fireLight.position.set(-3.5, 1.2, -1.0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.width = 512;
fireLight.shadow.mapSize.height = 512;
scene.add(fireLight);
// Secondary warm fill
const fillLight = new THREE.PointLight(0xff8844, 0.3, 6);
fillLight.position.set(-2.0, 0.5, 1.0);
scene.add(fillLight);
// Emerald ambient
const ambient = new THREE.AmbientLight(0x00b450, 0.15);
scene.add(ambient);
// Faint overhead to keep things readable
const overhead = new THREE.PointLight(0x887766, 0.2, 8);
overhead.position.set(0, 3.5, 0);
scene.add(overhead);
return { crystalBall, crystalLight, fireLight, candleLights };
}

95
static/world/state.js Normal file
View File

@@ -0,0 +1,95 @@
/**
* State reader — hardcoded JSON for Phase 2, WebSocket in Phase 3.
*
* Provides Timmy's current state to the scene. In Phase 2 this is a
* static default; the WebSocket path is stubbed for future use.
*/
const DEFAULTS = {
timmyState: {
mood: "focused",
activity: "Pondering the arcane arts",
energy: 0.6,
confidence: 0.7,
},
activeThreads: [],
recentEvents: [],
concerns: [],
visitorPresent: false,
updatedAt: new Date().toISOString(),
version: 1,
};
export class StateReader {
constructor() {
this.state = { ...DEFAULTS };
this.listeners = [];
this._ws = null;
}
/** Subscribe to state changes. */
onChange(fn) {
this.listeners.push(fn);
}
/** Notify all listeners. */
_notify() {
for (const fn of this.listeners) {
try {
fn(this.state);
} catch (e) {
console.warn("State listener error:", e);
}
}
}
/** Try to connect to the world WebSocket for live updates. */
connect() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${location.host}/api/world/ws`;
try {
this._ws = new WebSocket(url);
this._ws.onopen = () => {
const dot = document.getElementById("connection-dot");
if (dot) dot.classList.add("connected");
};
this._ws.onclose = () => {
const dot = document.getElementById("connection-dot");
if (dot) dot.classList.remove("connected");
};
this._ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "world_state" || msg.type === "timmy_state") {
if (msg.timmyState) this.state.timmyState = msg.timmyState;
if (msg.mood) {
this.state.timmyState.mood = msg.mood;
this.state.timmyState.activity = msg.activity || "";
this.state.timmyState.energy = msg.energy ?? 0.5;
}
this._notify();
}
} catch (e) {
/* ignore parse errors */
}
};
} catch (e) {
console.warn("WebSocket unavailable — using static state");
}
}
/** Current mood string. */
get mood() {
return this.state.timmyState.mood;
}
/** Current activity string. */
get activity() {
return this.state.timmyState.activity;
}
/** Energy level 0-1. */
get energy() {
return this.state.timmyState.energy;
}
}

89
static/world/style.css Normal file
View File

@@ -0,0 +1,89 @@
/* Workshop 3D scene overlay styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow: hidden;
background: #0a0a14;
font-family: "Courier New", monospace;
color: #e0e0e0;
touch-action: none;
}
canvas {
display: block;
}
#overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
#status {
position: absolute;
top: 16px;
left: 16px;
font-size: 14px;
opacity: 0.8;
}
#status .name {
font-size: 18px;
font-weight: bold;
color: #daa520;
}
#status .mood {
font-size: 13px;
color: #aaa;
margin-top: 4px;
}
#speech-area {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
max-width: 480px;
width: 90%;
text-align: center;
font-size: 15px;
line-height: 1.5;
color: #ccc;
opacity: 0;
transition: opacity 0.4s ease;
}
#speech-area.visible {
opacity: 1;
}
#speech-area .bubble {
background: rgba(10, 10, 20, 0.85);
border: 1px solid rgba(218, 165, 32, 0.3);
border-radius: 8px;
padding: 12px 20px;
}
#connection-dot {
position: absolute;
top: 18px;
right: 16px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #555;
}
#connection-dot.connected {
background: #00b450;
}

99
static/world/wizard.js Normal file
View File

@@ -0,0 +1,99 @@
/**
* Timmy the Wizard — geometric figure built from primitives.
*
* Phase 1: cone body (robe), sphere head, cylinder arms.
* Idle animation: gentle breathing (Y-scale oscillation), head tilt.
*/
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
const ROBE_COLOR = 0x2d1b4e;
const TRIM_COLOR = 0xdaa520;
/**
* Create the wizard group and return { group, update }.
* Call update(dt) each frame for idle animation.
*/
export function createWizard() {
const group = new THREE.Group();
// --- Robe (cone) ---
const robeGeo = new THREE.ConeGeometry(0.5, 1.6, 8);
const robeMat = new THREE.MeshStandardMaterial({
color: ROBE_COLOR,
roughness: 0.8,
});
const robe = new THREE.Mesh(robeGeo, robeMat);
robe.position.y = 0.8;
group.add(robe);
// --- Trim ring at robe bottom ---
const trimGeo = new THREE.TorusGeometry(0.5, 0.03, 8, 24);
const trimMat = new THREE.MeshStandardMaterial({
color: TRIM_COLOR,
roughness: 0.4,
metalness: 0.3,
});
const trim = new THREE.Mesh(trimGeo, trimMat);
trim.rotation.x = Math.PI / 2;
trim.position.y = 0.02;
group.add(trim);
// --- Head (sphere) ---
const headGeo = new THREE.SphereGeometry(0.22, 12, 10);
const headMat = new THREE.MeshStandardMaterial({
color: 0xd4a574,
roughness: 0.7,
});
const head = new THREE.Mesh(headGeo, headMat);
head.position.y = 1.72;
group.add(head);
// --- Hood (cone behind head) ---
const hoodGeo = new THREE.ConeGeometry(0.35, 0.5, 8);
const hoodMat = new THREE.MeshStandardMaterial({
color: ROBE_COLOR,
roughness: 0.8,
});
const hood = new THREE.Mesh(hoodGeo, hoodMat);
hood.position.y = 1.85;
hood.position.z = -0.08;
group.add(hood);
// --- Arms (cylinders) ---
const armGeo = new THREE.CylinderGeometry(0.06, 0.08, 0.7, 6);
const armMat = new THREE.MeshStandardMaterial({
color: ROBE_COLOR,
roughness: 0.8,
});
const leftArm = new THREE.Mesh(armGeo, armMat);
leftArm.position.set(-0.45, 1.0, 0.15);
leftArm.rotation.z = 0.3;
leftArm.rotation.x = -0.4;
group.add(leftArm);
const rightArm = new THREE.Mesh(armGeo, armMat);
rightArm.position.set(0.45, 1.0, 0.15);
rightArm.rotation.z = -0.3;
rightArm.rotation.x = -0.4;
group.add(rightArm);
// Position behind the desk
group.position.set(0, 0, -0.8);
// Animation state
let elapsed = 0;
function update(dt) {
elapsed += dt;
// Breathing: subtle Y-scale oscillation
const breath = 1.0 + Math.sin(elapsed * 1.5) * 0.015;
robe.scale.y = breath;
// Head tilt
head.rotation.z = Math.sin(elapsed * 0.7) * 0.05;
head.rotation.x = Math.sin(elapsed * 0.5) * 0.03;
}
return { group, update };
}

View File

@@ -0,0 +1,720 @@
"""Tests for GET /api/world/state endpoint and /api/world/ws relay."""
import asyncio
import json
import logging
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from dashboard.routes.world import (
_GROUND_TTL,
_REMIND_AFTER,
_STALE_THRESHOLD,
_bark_and_broadcast,
_broadcast,
_build_commitment_context,
_build_world_state,
_commitments,
_conversation,
_extract_commitments,
_generate_bark,
_handle_client_message,
_heartbeat,
_log_bark_failure,
_read_presence_file,
_record_commitments,
_refresh_ground,
_tick_commitments,
broadcast_world_state,
close_commitment,
get_commitments,
reset_commitments,
reset_conversation_ground,
)
# ---------------------------------------------------------------------------
# _build_world_state
# ---------------------------------------------------------------------------
def test_build_world_state_maps_fields():
presence = {
"version": 1,
"liveness": "2026-03-19T02:00:00Z",
"mood": "exploring",
"current_focus": "reviewing PR",
"energy": 0.8,
"confidence": 0.9,
"active_threads": [{"type": "thinking", "ref": "test", "status": "active"}],
"recent_events": [],
"concerns": [],
}
result = _build_world_state(presence)
assert result["timmyState"]["mood"] == "exploring"
assert result["timmyState"]["activity"] == "reviewing PR"
assert result["timmyState"]["energy"] == 0.8
assert result["timmyState"]["confidence"] == 0.9
assert result["updatedAt"] == "2026-03-19T02:00:00Z"
assert result["version"] == 1
assert result["visitorPresent"] is False
assert len(result["activeThreads"]) == 1
def test_build_world_state_defaults():
"""Missing fields get safe defaults."""
result = _build_world_state({})
assert result["timmyState"]["mood"] == "calm"
assert result["timmyState"]["energy"] == 0.5
assert result["version"] == 1
# ---------------------------------------------------------------------------
# _read_presence_file
# ---------------------------------------------------------------------------
def test_read_presence_file_missing(tmp_path):
with patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"):
assert _read_presence_file() is None
def test_read_presence_file_stale(tmp_path):
f = tmp_path / "presence.json"
f.write_text(json.dumps({"version": 1}))
# Backdate the file
stale_time = time.time() - _STALE_THRESHOLD - 10
import os
os.utime(f, (stale_time, stale_time))
with patch("dashboard.routes.world.PRESENCE_FILE", f):
assert _read_presence_file() is None
def test_read_presence_file_fresh(tmp_path):
f = tmp_path / "presence.json"
f.write_text(json.dumps({"version": 1, "mood": "focused"}))
with patch("dashboard.routes.world.PRESENCE_FILE", f):
result = _read_presence_file()
assert result is not None
assert result["version"] == 1
def test_read_presence_file_bad_json(tmp_path):
f = tmp_path / "presence.json"
f.write_text("not json {{{")
with patch("dashboard.routes.world.PRESENCE_FILE", f):
assert _read_presence_file() is None
# ---------------------------------------------------------------------------
# Full endpoint via TestClient
# ---------------------------------------------------------------------------
@pytest.fixture
def client():
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
from dashboard.routes.world import router
app.include_router(router)
return TestClient(app)
def test_world_state_endpoint_with_file(client, tmp_path):
"""Endpoint returns data from presence file when fresh."""
f = tmp_path / "presence.json"
f.write_text(
json.dumps(
{
"version": 1,
"liveness": "2026-03-19T02:00:00Z",
"mood": "exploring",
"current_focus": "testing",
"active_threads": [],
"recent_events": [],
"concerns": [],
}
)
)
with patch("dashboard.routes.world.PRESENCE_FILE", f):
resp = client.get("/api/world/state")
assert resp.status_code == 200
data = resp.json()
assert data["timmyState"]["mood"] == "exploring"
assert data["timmyState"]["activity"] == "testing"
assert resp.headers["cache-control"] == "no-cache, no-store"
def test_world_state_endpoint_fallback(client, tmp_path):
"""Endpoint falls back to live state when file missing."""
with (
patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"),
patch("timmy.workshop_state.get_state_dict") as mock_get,
):
mock_get.return_value = {
"version": 1,
"liveness": "2026-03-19T02:00:00Z",
"mood": "calm",
"current_focus": "",
"active_threads": [],
"recent_events": [],
"concerns": [],
}
resp = client.get("/api/world/state")
assert resp.status_code == 200
assert resp.json()["timmyState"]["mood"] == "calm"
def test_world_state_endpoint_full_fallback(client, tmp_path):
"""Endpoint returns safe defaults when everything fails."""
with (
patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"),
patch(
"timmy.workshop_state.get_state_dict",
side_effect=RuntimeError("boom"),
),
):
resp = client.get("/api/world/state")
assert resp.status_code == 200
data = resp.json()
assert data["timmyState"]["mood"] == "calm"
assert data["version"] == 1
# ---------------------------------------------------------------------------
# broadcast_world_state
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_broadcast_world_state_sends_timmy_state():
"""broadcast_world_state sends timmy_state JSON to connected clients."""
from dashboard.routes.world import _ws_clients
ws = AsyncMock()
_ws_clients.append(ws)
try:
presence = {
"version": 1,
"mood": "exploring",
"current_focus": "testing",
"energy": 0.8,
"confidence": 0.9,
}
await broadcast_world_state(presence)
ws.send_text.assert_called_once()
msg = json.loads(ws.send_text.call_args[0][0])
assert msg["type"] == "timmy_state"
assert msg["mood"] == "exploring"
assert msg["activity"] == "testing"
finally:
_ws_clients.clear()
@pytest.mark.asyncio
async def test_broadcast_world_state_removes_dead_clients():
"""Dead WebSocket connections are cleaned up on broadcast."""
from dashboard.routes.world import _ws_clients
dead_ws = AsyncMock()
dead_ws.send_text.side_effect = ConnectionError("gone")
_ws_clients.append(dead_ws)
try:
await broadcast_world_state({"mood": "idle"})
assert dead_ws not in _ws_clients
finally:
_ws_clients.clear()
def test_world_ws_endpoint_accepts_connection(client):
"""WebSocket endpoint at /api/world/ws accepts connections."""
with client.websocket_connect("/api/world/ws"):
pass # Connection accepted — just close it
def test_world_ws_sends_snapshot_on_connect(client, tmp_path):
"""WebSocket sends a world_state snapshot immediately on connect."""
f = tmp_path / "presence.json"
f.write_text(
json.dumps(
{
"version": 1,
"liveness": "2026-03-19T02:00:00Z",
"mood": "exploring",
"current_focus": "testing",
"active_threads": [],
"recent_events": [],
"concerns": [],
}
)
)
with patch("dashboard.routes.world.PRESENCE_FILE", f):
with client.websocket_connect("/api/world/ws") as ws:
msg = json.loads(ws.receive_text())
assert msg["type"] == "world_state"
assert msg["timmyState"]["mood"] == "exploring"
assert msg["timmyState"]["activity"] == "testing"
assert "updatedAt" in msg
# ---------------------------------------------------------------------------
# Visitor chat — bark engine
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_handle_client_message_ignores_non_json():
"""Non-JSON messages are silently ignored."""
await _handle_client_message("not json") # should not raise
@pytest.mark.asyncio
async def test_handle_client_message_ignores_unknown_type():
"""Unknown message types are ignored."""
await _handle_client_message(json.dumps({"type": "unknown"}))
@pytest.mark.asyncio
async def test_handle_client_message_ignores_empty_text():
"""Empty visitor_message text is ignored."""
await _handle_client_message(json.dumps({"type": "visitor_message", "text": " "}))
@pytest.mark.asyncio
async def test_generate_bark_returns_response():
"""_generate_bark returns the chat response."""
reset_conversation_ground()
with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat:
mock_chat.return_value = "Woof! Good to see you."
result = await _generate_bark("Hey Timmy!")
assert result == "Woof! Good to see you."
mock_chat.assert_called_once_with("Hey Timmy!", session_id="workshop")
@pytest.mark.asyncio
async def test_generate_bark_fallback_on_error():
"""_generate_bark returns canned response when chat fails."""
reset_conversation_ground()
with patch(
"timmy.session.chat",
new_callable=AsyncMock,
side_effect=RuntimeError("no model"),
):
result = await _generate_bark("Hello?")
assert "tangled" in result
@pytest.mark.asyncio
async def test_bark_and_broadcast_sends_thinking_then_speech():
"""_bark_and_broadcast sends thinking indicator then speech."""
from dashboard.routes.world import _ws_clients
ws = AsyncMock()
_ws_clients.append(ws)
_conversation.clear()
reset_conversation_ground()
try:
with patch(
"timmy.session.chat",
new_callable=AsyncMock,
return_value="All good here!",
):
await _bark_and_broadcast("How are you?")
# Should have sent two messages: thinking + speech
assert ws.send_text.call_count == 2
thinking = json.loads(ws.send_text.call_args_list[0][0][0])
speech = json.loads(ws.send_text.call_args_list[1][0][0])
assert thinking["type"] == "timmy_thinking"
assert speech["type"] == "timmy_speech"
assert speech["text"] == "All good here!"
assert len(speech["recentExchanges"]) == 1
assert speech["recentExchanges"][0]["visitor"] == "How are you?"
finally:
_ws_clients.clear()
_conversation.clear()
@pytest.mark.asyncio
async def test_broadcast_removes_dead_clients():
"""Dead clients are cleaned up during broadcast."""
from dashboard.routes.world import _ws_clients
dead = AsyncMock()
dead.send_text.side_effect = ConnectionError("gone")
_ws_clients.append(dead)
try:
await _broadcast(json.dumps({"type": "timmy_speech", "text": "test"}))
assert dead not in _ws_clients
finally:
_ws_clients.clear()
@pytest.mark.asyncio
async def test_conversation_buffer_caps_at_max():
"""Conversation buffer only keeps the last _MAX_EXCHANGES entries."""
from dashboard.routes.world import _MAX_EXCHANGES, _ws_clients
ws = AsyncMock()
_ws_clients.append(ws)
_conversation.clear()
reset_conversation_ground()
try:
with patch(
"timmy.session.chat",
new_callable=AsyncMock,
return_value="reply",
):
for i in range(_MAX_EXCHANGES + 2):
await _bark_and_broadcast(f"msg {i}")
assert len(_conversation) == _MAX_EXCHANGES
# Oldest messages should have been evicted
assert _conversation[0]["visitor"] == f"msg {_MAX_EXCHANGES + 2 - _MAX_EXCHANGES}"
finally:
_ws_clients.clear()
_conversation.clear()
def test_log_bark_failure_logs_exception(caplog):
"""_log_bark_failure logs errors from failed bark tasks."""
loop = asyncio.new_event_loop()
async def _fail():
raise RuntimeError("bark boom")
task = loop.create_task(_fail())
loop.run_until_complete(asyncio.sleep(0.01))
loop.close()
with caplog.at_level(logging.ERROR):
_log_bark_failure(task)
assert "bark boom" in caplog.text
def test_log_bark_failure_ignores_cancelled():
"""_log_bark_failure silently ignores cancelled tasks."""
task = MagicMock(spec=asyncio.Task)
task.cancelled.return_value = True
_log_bark_failure(task) # should not raise
# ---------------------------------------------------------------------------
# Conversation grounding (#322)
# ---------------------------------------------------------------------------
class TestConversationGrounding:
"""Tests for conversation grounding — prevent topic drift."""
def setup_method(self):
reset_conversation_ground()
def teardown_method(self):
reset_conversation_ground()
def test_refresh_ground_sets_topic_on_first_message(self):
"""First visitor message becomes the grounding anchor."""
import dashboard.routes.world as w
_refresh_ground("Tell me about the Bible")
assert w._ground_topic == "Tell me about the Bible"
assert w._ground_set_at > 0
def test_refresh_ground_keeps_topic_on_subsequent_messages(self):
"""Subsequent messages don't overwrite the anchor."""
import dashboard.routes.world as w
_refresh_ground("Tell me about the Bible")
_refresh_ground("What about Genesis?")
assert w._ground_topic == "Tell me about the Bible"
def test_refresh_ground_resets_after_ttl(self):
"""Anchor expires after _GROUND_TTL seconds of inactivity."""
import dashboard.routes.world as w
_refresh_ground("Tell me about the Bible")
# Simulate TTL expiry
w._ground_set_at = time.time() - _GROUND_TTL - 1
_refresh_ground("Now tell me about cooking")
assert w._ground_topic == "Now tell me about cooking"
def test_refresh_ground_truncates_long_messages(self):
"""Anchor text is capped at 120 characters."""
import dashboard.routes.world as w
long_msg = "x" * 200
_refresh_ground(long_msg)
assert len(w._ground_topic) == 120
def test_reset_conversation_ground_clears_state(self):
"""reset_conversation_ground clears the anchor."""
import dashboard.routes.world as w
_refresh_ground("Some topic")
reset_conversation_ground()
assert w._ground_topic is None
assert w._ground_set_at == 0.0
@pytest.mark.asyncio
async def test_generate_bark_prepends_ground_topic(self):
"""When grounded, the topic is prepended to the visitor message."""
_refresh_ground("Tell me about prayer")
with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat:
mock_chat.return_value = "Great question!"
await _generate_bark("What else can you share?")
call_text = mock_chat.call_args[0][0]
assert "[Workshop conversation topic: Tell me about prayer]" in call_text
assert "What else can you share?" in call_text
@pytest.mark.asyncio
async def test_generate_bark_no_prefix_for_first_message(self):
"""First message (which IS the anchor) is not prefixed."""
_refresh_ground("Tell me about prayer")
with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat:
mock_chat.return_value = "Sure!"
await _generate_bark("Tell me about prayer")
call_text = mock_chat.call_args[0][0]
assert "[Workshop conversation topic:" not in call_text
assert call_text == "Tell me about prayer"
@pytest.mark.asyncio
async def test_bark_and_broadcast_sets_ground(self):
"""_bark_and_broadcast sets the ground topic automatically."""
import dashboard.routes.world as w
from dashboard.routes.world import _ws_clients
ws = AsyncMock()
_ws_clients.append(ws)
_conversation.clear()
try:
with patch(
"timmy.session.chat",
new_callable=AsyncMock,
return_value="Interesting!",
):
await _bark_and_broadcast("What is grace?")
assert w._ground_topic == "What is grace?"
finally:
_ws_clients.clear()
_conversation.clear()
# ---------------------------------------------------------------------------
# Conversation grounding — commitment tracking (rescued from PR #408)
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=False)
def _clean_commitments():
"""Reset commitments before and after each commitment test."""
reset_commitments()
yield
reset_commitments()
class TestExtractCommitments:
def test_extracts_ill_pattern(self):
text = "I'll draft the skeleton ticket in 30 minutes."
result = _extract_commitments(text)
assert len(result) == 1
assert "draft the skeleton ticket" in result[0]
def test_extracts_i_will_pattern(self):
result = _extract_commitments("I will review that PR tomorrow.")
assert len(result) == 1
assert "review that PR tomorrow" in result[0]
def test_extracts_let_me_pattern(self):
result = _extract_commitments("Let me write up a summary for you.")
assert len(result) == 1
assert "write up a summary" in result[0]
def test_skips_short_matches(self):
result = _extract_commitments("I'll do it.")
# "do it" is 5 chars — should be skipped (needs > 5)
assert result == []
def test_no_commitments_in_normal_text(self):
result = _extract_commitments("The weather is nice today.")
assert result == []
def test_truncates_long_commitments(self):
long_phrase = "a" * 200
result = _extract_commitments(f"I'll {long_phrase}.")
assert len(result) == 1
assert len(result[0]) == 120
class TestRecordCommitments:
def test_records_new_commitment(self, _clean_commitments):
_record_commitments("I'll draft the ticket now.")
assert len(get_commitments()) == 1
assert get_commitments()[0]["messages_since"] == 0
def test_avoids_duplicate_commitments(self, _clean_commitments):
_record_commitments("I'll draft the ticket now.")
_record_commitments("I'll draft the ticket now.")
assert len(get_commitments()) == 1
def test_caps_at_max(self, _clean_commitments):
from dashboard.routes.world import _MAX_COMMITMENTS
for i in range(_MAX_COMMITMENTS + 3):
_record_commitments(f"I'll handle commitment number {i} right away.")
assert len(get_commitments()) <= _MAX_COMMITMENTS
class TestTickAndContext:
def test_tick_increments_messages_since(self, _clean_commitments):
_commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
_tick_commitments()
_tick_commitments()
assert _commitments[0]["messages_since"] == 2
def test_context_empty_when_no_overdue(self, _clean_commitments):
_commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
assert _build_commitment_context() == ""
def test_context_surfaces_overdue_commitments(self, _clean_commitments):
_commitments.append(
{
"text": "draft the skeleton ticket",
"created_at": 0,
"messages_since": _REMIND_AFTER,
}
)
ctx = _build_commitment_context()
assert "draft the skeleton ticket" in ctx
assert "Open commitments" in ctx
def test_context_only_includes_overdue(self, _clean_commitments):
_commitments.append({"text": "recent thing", "created_at": 0, "messages_since": 1})
_commitments.append(
{
"text": "old thing",
"created_at": 0,
"messages_since": _REMIND_AFTER,
}
)
ctx = _build_commitment_context()
assert "old thing" in ctx
assert "recent thing" not in ctx
class TestCloseCommitment:
def test_close_valid_index(self, _clean_commitments):
_commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
assert close_commitment(0) is True
assert len(get_commitments()) == 0
def test_close_invalid_index(self, _clean_commitments):
assert close_commitment(99) is False
class TestGroundingIntegration:
@pytest.mark.asyncio
async def test_bark_records_commitments_from_reply(self, _clean_commitments):
from dashboard.routes.world import _ws_clients
ws = AsyncMock()
_ws_clients.append(ws)
_conversation.clear()
try:
with patch(
"timmy.session.chat",
new_callable=AsyncMock,
return_value="I'll draft the ticket for you!",
):
await _bark_and_broadcast("Can you help?")
assert len(get_commitments()) == 1
assert "draft the ticket" in get_commitments()[0]["text"]
finally:
_ws_clients.clear()
_conversation.clear()
@pytest.mark.asyncio
async def test_bark_prepends_context_after_n_messages(self, _clean_commitments):
"""After _REMIND_AFTER messages, commitment context is prepended."""
_commitments.append(
{
"text": "draft the skeleton ticket",
"created_at": 0,
"messages_since": _REMIND_AFTER - 1,
}
)
with patch(
"timmy.session.chat",
new_callable=AsyncMock,
return_value="Sure thing!",
) as mock_chat:
# This tick will push messages_since to _REMIND_AFTER
await _generate_bark("Any updates?")
# _generate_bark doesn't tick — _bark_and_broadcast does.
# But we pre-set messages_since to _REMIND_AFTER - 1,
# so we need to tick once to make it overdue.
_tick_commitments()
await _generate_bark("Any updates?")
# Second call should have context prepended
last_call = mock_chat.call_args_list[-1]
sent_text = last_call[0][0]
assert "draft the skeleton ticket" in sent_text
assert "Open commitments" in sent_text
# ---------------------------------------------------------------------------
# WebSocket heartbeat ping (rescued from PR #399)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_heartbeat_sends_ping():
"""Heartbeat sends a ping JSON frame after the interval elapses."""
ws = AsyncMock()
with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
# Let the first sleep complete, then raise to exit the loop
call_count = 0
async def sleep_side_effect(_interval):
nonlocal call_count
call_count += 1
if call_count > 1:
raise ConnectionError("stop")
mock_sleep.side_effect = sleep_side_effect
await _heartbeat(ws)
ws.send_text.assert_called_once()
msg = json.loads(ws.send_text.call_args[0][0])
assert msg["type"] == "ping"
@pytest.mark.asyncio
async def test_heartbeat_exits_on_dead_connection():
"""Heartbeat exits cleanly when the WebSocket is dead."""
ws = AsyncMock()
ws.send_text.side_effect = ConnectionError("gone")
with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock):
await _heartbeat(ws) # should not raise

View File

@@ -516,3 +516,183 @@ class TestProviderAvailabilityCheck:
with patch("importlib.util.find_spec", return_value=None):
assert router._check_provider_available(provider) is False
class TestCascadeRouterReload:
"""Test hot-reload of providers.yaml."""
def test_reload_preserves_metrics(self, tmp_path):
"""Test that reload preserves metrics for existing providers."""
config = {
"providers": [
{
"name": "test-openai",
"type": "openai",
"enabled": True,
"priority": 1,
"api_key": "sk-test",
}
],
}
config_path = tmp_path / "providers.yaml"
config_path.write_text(yaml.dump(config))
router = CascadeRouter(config_path=config_path)
assert len(router.providers) == 1
# Simulate some traffic
router._record_success(router.providers[0], 150.0)
router._record_success(router.providers[0], 250.0)
assert router.providers[0].metrics.total_requests == 2
# Reload
result = router.reload_config()
assert result["total_providers"] == 1
assert result["preserved"] == 1
assert result["added"] == []
assert result["removed"] == []
# Metrics survived
assert router.providers[0].metrics.total_requests == 2
assert router.providers[0].metrics.total_latency_ms == 400.0
def test_reload_preserves_circuit_breaker(self, tmp_path):
"""Test that reload preserves circuit breaker state."""
config = {
"cascade": {"circuit_breaker": {"failure_threshold": 2}},
"providers": [
{
"name": "test-openai",
"type": "openai",
"enabled": True,
"priority": 1,
"api_key": "sk-test",
}
],
}
config_path = tmp_path / "providers.yaml"
config_path.write_text(yaml.dump(config))
router = CascadeRouter(config_path=config_path)
# Open circuit breaker
for _ in range(2):
router._record_failure(router.providers[0])
assert router.providers[0].circuit_state == CircuitState.OPEN
# Reload
router.reload_config()
# Circuit breaker state preserved
assert router.providers[0].circuit_state == CircuitState.OPEN
assert router.providers[0].status == ProviderStatus.UNHEALTHY
def test_reload_detects_added_provider(self, tmp_path):
"""Test that reload detects newly added providers."""
config = {
"providers": [
{
"name": "openai-1",
"type": "openai",
"enabled": True,
"priority": 1,
"api_key": "sk-test",
}
],
}
config_path = tmp_path / "providers.yaml"
config_path.write_text(yaml.dump(config))
router = CascadeRouter(config_path=config_path)
assert len(router.providers) == 1
# Add a second provider to config
config["providers"].append(
{
"name": "anthropic-1",
"type": "anthropic",
"enabled": True,
"priority": 2,
"api_key": "sk-ant-test",
}
)
config_path.write_text(yaml.dump(config))
result = router.reload_config()
assert result["total_providers"] == 2
assert result["preserved"] == 1
assert result["added"] == ["anthropic-1"]
assert result["removed"] == []
def test_reload_detects_removed_provider(self, tmp_path):
"""Test that reload detects removed providers."""
config = {
"providers": [
{
"name": "openai-1",
"type": "openai",
"enabled": True,
"priority": 1,
"api_key": "sk-test",
},
{
"name": "anthropic-1",
"type": "anthropic",
"enabled": True,
"priority": 2,
"api_key": "sk-ant-test",
},
],
}
config_path = tmp_path / "providers.yaml"
config_path.write_text(yaml.dump(config))
router = CascadeRouter(config_path=config_path)
assert len(router.providers) == 2
# Remove anthropic
config["providers"] = [config["providers"][0]]
config_path.write_text(yaml.dump(config))
result = router.reload_config()
assert result["total_providers"] == 1
assert result["preserved"] == 1
assert result["removed"] == ["anthropic-1"]
def test_reload_re_sorts_by_priority(self, tmp_path):
"""Test that providers are re-sorted by priority after reload."""
config = {
"providers": [
{
"name": "low-priority",
"type": "openai",
"enabled": True,
"priority": 10,
"api_key": "sk-test",
},
{
"name": "high-priority",
"type": "openai",
"enabled": True,
"priority": 1,
"api_key": "sk-test2",
},
],
}
config_path = tmp_path / "providers.yaml"
config_path.write_text(yaml.dump(config))
router = CascadeRouter(config_path=config_path)
assert router.providers[0].name == "high-priority"
# Swap priorities
config["providers"][0]["priority"] = 1
config["providers"][1]["priority"] = 10
config_path.write_text(yaml.dump(config))
router.reload_config()
assert router.providers[0].name == "low-priority"
assert router.providers[1].name == "high-priority"

View File

@@ -0,0 +1,285 @@
"""Integration tests for agentic loop WebSocket broadcasts.
Verifies that ``run_agentic_loop`` pushes the correct sequence of events
through the real ``ws_manager`` and that connected (mock) WebSocket clients
receive every broadcast with the expected payloads.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from infrastructure.ws_manager.handler import WebSocketManager
from timmy.agentic_loop import run_agentic_loop
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_run(content: str):
m = MagicMock()
m.content = content
return m
def _ws_client() -> AsyncMock:
"""Return a fake WebSocket that records sent messages."""
return AsyncMock()
def _collected_events(ws: AsyncMock) -> list[dict]:
"""Extract parsed JSON events from a mock WebSocket's send_text calls."""
return [json.loads(call.args[0]) for call in ws.send_text.call_args_list]
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestAgenticLoopBroadcastSequence:
"""Events arrive at WS clients in the correct order with expected data."""
@pytest.mark.asyncio
async def test_successful_run_broadcasts_plan_steps_complete(self):
"""A successful 2-step loop emits plan_ready → 2× step_complete → task_complete."""
mgr = WebSocketManager()
ws = _ws_client()
mgr._connections = [ws]
mock_agent = MagicMock()
mock_agent.run = MagicMock(
side_effect=[
_mock_run("1. Gather data\n2. Summarise"),
_mock_run("Gathered 10 records"),
_mock_run("Summary written"),
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("infrastructure.ws_manager.handler.ws_manager", mgr),
):
result = await run_agentic_loop("Gather and summarise", max_steps=2)
assert result.status == "completed"
events = _collected_events(ws)
event_names = [e["event"] for e in events]
assert event_names == [
"agentic.plan_ready",
"agentic.step_complete",
"agentic.step_complete",
"agentic.task_complete",
]
@pytest.mark.asyncio
async def test_plan_ready_payload(self):
"""plan_ready contains task_id, task, steps list, and total count."""
mgr = WebSocketManager()
ws = _ws_client()
mgr._connections = [ws]
mock_agent = MagicMock()
mock_agent.run = MagicMock(
side_effect=[
_mock_run("1. Alpha\n2. Beta"),
_mock_run("Alpha done"),
_mock_run("Beta done"),
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("infrastructure.ws_manager.handler.ws_manager", mgr),
):
result = await run_agentic_loop("Two steps")
plan_event = _collected_events(ws)[0]
assert plan_event["event"] == "agentic.plan_ready"
data = plan_event["data"]
assert data["task_id"] == result.task_id
assert data["task"] == "Two steps"
assert data["steps"] == ["Alpha", "Beta"]
assert data["total"] == 2
@pytest.mark.asyncio
async def test_step_complete_payload(self):
"""step_complete carries step number, total, description, and result."""
mgr = WebSocketManager()
ws = _ws_client()
mgr._connections = [ws]
mock_agent = MagicMock()
mock_agent.run = MagicMock(
side_effect=[
_mock_run("1. Only step"),
_mock_run("Step result text"),
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("infrastructure.ws_manager.handler.ws_manager", mgr),
):
await run_agentic_loop("Single step", max_steps=1)
step_event = _collected_events(ws)[1]
assert step_event["event"] == "agentic.step_complete"
data = step_event["data"]
assert data["step"] == 1
assert data["total"] == 1
assert data["description"] == "Only step"
assert "Step result text" in data["result"]
@pytest.mark.asyncio
async def test_task_complete_payload(self):
"""task_complete has status, steps_completed, summary, and duration_ms."""
mgr = WebSocketManager()
ws = _ws_client()
mgr._connections = [ws]
mock_agent = MagicMock()
mock_agent.run = MagicMock(
side_effect=[
_mock_run("1. Do it"),
_mock_run("Done"),
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("infrastructure.ws_manager.handler.ws_manager", mgr),
):
await run_agentic_loop("Quick", max_steps=1)
complete_event = _collected_events(ws)[-1]
assert complete_event["event"] == "agentic.task_complete"
data = complete_event["data"]
assert data["status"] == "completed"
assert data["steps_completed"] == 1
assert isinstance(data["duration_ms"], int)
assert data["duration_ms"] >= 0
assert data["summary"]
class TestAdaptationBroadcast:
"""Adapted steps emit step_adapted events."""
@pytest.mark.asyncio
async def test_adapted_step_broadcasts_step_adapted(self):
"""A failed-then-adapted step emits agentic.step_adapted."""
mgr = WebSocketManager()
ws = _ws_client()
mgr._connections = [ws]
mock_agent = MagicMock()
mock_agent.run = MagicMock(
side_effect=[
_mock_run("1. Risky step"),
Exception("disk full"),
_mock_run("Used /tmp instead"),
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("infrastructure.ws_manager.handler.ws_manager", mgr),
):
result = await run_agentic_loop("Adapt test", max_steps=1)
events = _collected_events(ws)
event_names = [e["event"] for e in events]
assert "agentic.step_adapted" in event_names
adapted = next(e for e in events if e["event"] == "agentic.step_adapted")
assert adapted["data"]["error"] == "disk full"
assert adapted["data"]["adaptation"]
assert result.steps[0].status == "adapted"
class TestMultipleClients:
"""All connected clients receive every broadcast."""
@pytest.mark.asyncio
async def test_two_clients_receive_all_events(self):
mgr = WebSocketManager()
ws1 = _ws_client()
ws2 = _ws_client()
mgr._connections = [ws1, ws2]
mock_agent = MagicMock()
mock_agent.run = MagicMock(
side_effect=[
_mock_run("1. Step A"),
_mock_run("A done"),
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("infrastructure.ws_manager.handler.ws_manager", mgr),
):
await run_agentic_loop("Multi-client", max_steps=1)
events1 = _collected_events(ws1)
events2 = _collected_events(ws2)
assert len(events1) == len(events2) == 3 # plan + step + complete
assert [e["event"] for e in events1] == [e["event"] for e in events2]
class TestEventHistory:
"""Broadcasts are recorded in ws_manager event history."""
@pytest.mark.asyncio
async def test_events_appear_in_history(self):
mgr = WebSocketManager()
mock_agent = MagicMock()
mock_agent.run = MagicMock(
side_effect=[
_mock_run("1. Only"),
_mock_run("Done"),
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("infrastructure.ws_manager.handler.ws_manager", mgr),
):
await run_agentic_loop("History test", max_steps=1)
history_events = [e.event for e in mgr.event_history]
assert "agentic.plan_ready" in history_events
assert "agentic.step_complete" in history_events
assert "agentic.task_complete" in history_events
class TestBroadcastGracefulDegradation:
"""Loop completes even when ws_manager is unavailable."""
@pytest.mark.asyncio
async def test_loop_succeeds_when_broadcast_fails(self):
"""ImportError from ws_manager doesn't crash the loop."""
mock_agent = MagicMock()
mock_agent.run = MagicMock(
side_effect=[
_mock_run("1. Do it"),
_mock_run("Done"),
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch(
"infrastructure.ws_manager.handler.ws_manager",
new_callable=lambda: MagicMock,
) as broken_mgr,
):
broken_mgr.broadcast = AsyncMock(side_effect=RuntimeError("ws down"))
result = await run_agentic_loop("Resilient task", max_steps=1)
assert result.status == "completed"
assert len(result.steps) == 1

View File

@@ -0,0 +1,95 @@
"""Tests for the presence file watcher in dashboard.app."""
import asyncio
import json
from unittest.mock import AsyncMock, patch
import pytest
# Common patches to eliminate delays and inject mock ws_manager
_FAST = {
"dashboard.app._PRESENCE_POLL_SECONDS": 0.01,
"dashboard.app._PRESENCE_INITIAL_DELAY": 0,
}
def _patches(mock_ws, presence_file):
"""Return a combined context manager for presence watcher patches."""
from contextlib import ExitStack
stack = ExitStack()
stack.enter_context(patch("dashboard.app.PRESENCE_FILE", presence_file))
stack.enter_context(patch("infrastructure.ws_manager.handler.ws_manager", mock_ws))
for key, val in _FAST.items():
stack.enter_context(patch(key, val))
return stack
@pytest.mark.asyncio
async def test_presence_watcher_broadcasts_on_file_change(tmp_path):
"""Watcher reads presence.json and broadcasts via ws_manager."""
from dashboard.app import _presence_watcher
presence_file = tmp_path / "presence.json"
state = {
"version": 1,
"liveness": "2026-03-18T21:47:12Z",
"current_focus": "Reviewing PR #267",
"mood": "focused",
}
presence_file.write_text(json.dumps(state))
mock_ws = AsyncMock()
with _patches(mock_ws, presence_file):
task = asyncio.create_task(_presence_watcher())
await asyncio.sleep(0.15)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
mock_ws.broadcast.assert_called_with("timmy_state", state)
@pytest.mark.asyncio
async def test_presence_watcher_synthesised_state_when_missing(tmp_path):
"""Watcher broadcasts synthesised idle state when file is absent."""
from dashboard.app import _SYNTHESIZED_STATE, _presence_watcher
missing_file = tmp_path / "no-such-file.json"
mock_ws = AsyncMock()
with _patches(mock_ws, missing_file):
task = asyncio.create_task(_presence_watcher())
await asyncio.sleep(0.15)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
mock_ws.broadcast.assert_called_with("timmy_state", _SYNTHESIZED_STATE)
@pytest.mark.asyncio
async def test_presence_watcher_handles_bad_json(tmp_path):
"""Watcher logs warning on malformed JSON and doesn't crash."""
from dashboard.app import _presence_watcher
presence_file = tmp_path / "presence.json"
presence_file.write_text("{bad json!!!")
mock_ws = AsyncMock()
with _patches(mock_ws, presence_file):
task = asyncio.create_task(_presence_watcher())
await asyncio.sleep(0.15)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Should not have broadcast anything on bad JSON
mock_ws.broadcast.assert_not_called()

0
tests/loop/__init__.py Normal file
View File

View File

@@ -0,0 +1,133 @@
"""Tests for the three-phase loop scaffold.
Validates the acceptance criteria from issue #324:
1. Loop accepts context payload as input to Phase 1
2. Phase 1 output feeds into Phase 2 without manual intervention
3. Phase 2 output feeds into Phase 3 without manual intervention
4. Phase 3 output feeds back into Phase 1
5. Full cycle completes without crash
6. No state leaks between cycles
7. Each phase logs what it received and what it produced
"""
from datetime import datetime
from loop.phase1_gather import gather
from loop.phase2_reason import reason
from loop.phase3_act import act
from loop.runner import run_cycle
from loop.schema import ContextPayload
def _make_payload(source: str = "test", content: str = "hello") -> ContextPayload:
return ContextPayload(source=source, content=content, token_count=5)
# --- Schema ---
def test_context_payload_defaults():
p = ContextPayload(source="user", content="hi")
assert p.source == "user"
assert p.content == "hi"
assert p.token_count == -1
assert p.metadata == {}
assert isinstance(p.timestamp, datetime)
def test_with_metadata_returns_new_payload():
p = _make_payload()
p2 = p.with_metadata(foo="bar")
assert p2.metadata == {"foo": "bar"}
assert p.metadata == {} # original unchanged
def test_with_metadata_merges():
p = _make_payload().with_metadata(a=1)
p2 = p.with_metadata(b=2)
assert p2.metadata == {"a": 1, "b": 2}
# --- Individual phases ---
def test_gather_marks_phase():
result = gather(_make_payload())
assert result.metadata["phase"] == "gather"
assert result.metadata["gathered"] is True
def test_reason_marks_phase():
gathered = gather(_make_payload())
result = reason(gathered)
assert result.metadata["phase"] == "reason"
assert result.metadata["reasoned"] is True
def test_act_marks_phase():
gathered = gather(_make_payload())
reasoned = reason(gathered)
result = act(reasoned)
assert result.metadata["phase"] == "act"
assert result.metadata["acted"] is True
# --- Full cycle ---
def test_full_cycle_completes():
"""Acceptance criterion 5: full cycle completes without crash."""
payload = _make_payload(source="user", content="What is sovereignty?")
result = run_cycle(payload)
assert result.metadata["gathered"] is True
assert result.metadata["reasoned"] is True
assert result.metadata["acted"] is True
def test_full_cycle_preserves_source():
"""Source field survives the full pipeline."""
result = run_cycle(_make_payload(source="timer"))
assert result.source == "timer"
def test_full_cycle_preserves_content():
"""Content field survives the full pipeline."""
result = run_cycle(_make_payload(content="test data"))
assert result.content == "test data"
def test_no_state_leaks_between_cycles():
"""Acceptance criterion 6: no state leaks between cycles."""
r1 = run_cycle(_make_payload(source="cycle1", content="first"))
r2 = run_cycle(_make_payload(source="cycle2", content="second"))
assert r1.source == "cycle1"
assert r2.source == "cycle2"
assert r1.content == "first"
assert r2.content == "second"
def test_cycle_output_feeds_back_as_input():
"""Acceptance criterion 4: Phase 3 output feeds back into Phase 1."""
first = run_cycle(_make_payload(source="initial"))
second = run_cycle(first)
# Second cycle should still work — no crash, metadata accumulates
assert second.metadata["gathered"] is True
assert second.metadata["acted"] is True
def test_phases_log(caplog):
"""Acceptance criterion 7: each phase logs what it received and produced."""
import logging
with caplog.at_level(logging.INFO):
run_cycle(_make_payload())
messages = caplog.text
assert "Phase 1 (Gather) received" in messages
assert "Phase 1 (Gather) produced" in messages
assert "Phase 2 (Reason) received" in messages
assert "Phase 2 (Reason) produced" in messages
assert "Phase 3 (Act) received" in messages
assert "Phase 3 (Act) produced" in messages
assert "Loop cycle start" in messages
assert "Loop cycle complete" in messages

View File

@@ -1,14 +1,22 @@
"""Unit tests for the agentic loop module.
Tests cover planning, execution, max_steps enforcement, failure
adaptation, progress callbacks, and response cleaning.
Tests cover data structures, plan parsing, planning, execution,
max_steps enforcement, failure adaptation, double-failure,
progress callbacks, broadcast helper, summary logic, and
response cleaning.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from timmy.agentic_loop import _parse_steps, run_agentic_loop
from timmy.agentic_loop import (
AgenticResult,
AgenticStep,
_broadcast_progress,
_parse_steps,
run_agentic_loop,
)
# ---------------------------------------------------------------------------
# Helpers
@@ -27,6 +35,27 @@ def _mock_run(content: str):
# ---------------------------------------------------------------------------
class TestDataStructures:
def test_agentic_step_fields(self):
step = AgenticStep(
step_num=1, description="Do X", result="Done", status="completed", duration_ms=42
)
assert step.step_num == 1
assert step.status == "completed"
assert step.duration_ms == 42
def test_agentic_result_defaults(self):
r = AgenticResult(task_id="abc", task="test", summary="ok")
assert r.steps == []
assert r.status == "completed"
assert r.total_duration_ms == 0
# ---------------------------------------------------------------------------
# _parse_steps
# ---------------------------------------------------------------------------
class TestParseSteps:
def test_numbered_with_dot(self):
text = "1. Search for data\n2. Write to file\n3. Verify"
@@ -43,6 +72,19 @@ class TestParseSteps:
def test_empty_returns_empty(self):
assert _parse_steps("") == []
def test_whitespace_only_returns_empty(self):
assert _parse_steps(" \n \n ") == []
def test_leading_whitespace_in_numbered(self):
text = " 1. First\n 2. Second"
assert _parse_steps(text) == ["First", "Second"]
def test_mixed_numbered_and_plain(self):
"""When numbered lines are present, only those are returned."""
text = "Here is the plan:\n1. Step one\n2. Step two\nGood luck!"
result = _parse_steps(text)
assert result == ["Step one", "Step two"]
# ---------------------------------------------------------------------------
# run_agentic_loop
@@ -231,3 +273,191 @@ async def test_planning_failure_returns_failed():
assert result.status == "failed"
assert "Planning failed" in result.summary
@pytest.mark.asyncio
async def test_empty_plan_returns_failed():
"""Planning that produces no steps results in 'failed'."""
mock_agent = MagicMock()
mock_agent.run = MagicMock(return_value=_mock_run(""))
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
):
result = await run_agentic_loop("Do nothing")
assert result.status == "failed"
assert "no steps" in result.summary.lower()
@pytest.mark.asyncio
async def test_double_failure_marks_step_failed():
"""When both execution and adaptation fail, step status is 'failed'."""
mock_agent = MagicMock()
mock_agent.run = MagicMock(
side_effect=[
_mock_run("1. Do something"),
Exception("Step failed"),
Exception("Adaptation also failed"),
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
):
result = await run_agentic_loop("Try and fail", max_steps=1)
assert len(result.steps) == 1
assert result.steps[0].status == "failed"
assert "Failed" in result.steps[0].result
assert result.status == "partial"
@pytest.mark.asyncio
async def test_broadcast_progress_ignores_ws_errors():
"""_broadcast_progress swallows import/connection errors."""
with patch(
"timmy.agentic_loop.ws_manager",
create=True,
side_effect=ImportError("no ws"),
):
# Should not raise
await _broadcast_progress("test.event", {"key": "value"})
@pytest.mark.asyncio
async def test_broadcast_progress_sends_to_ws():
"""_broadcast_progress calls ws_manager.broadcast."""
mock_ws = AsyncMock()
with patch("infrastructure.ws_manager.handler.ws_manager", mock_ws):
await _broadcast_progress("agentic.plan_ready", {"task_id": "abc"})
mock_ws.broadcast.assert_awaited_once_with("agentic.plan_ready", {"task_id": "abc"})
@pytest.mark.asyncio
async def test_summary_counts_step_statuses():
"""Summary string includes completed, adapted, and failed counts."""
mock_agent = MagicMock()
mock_agent.run = MagicMock(
side_effect=[
_mock_run("1. A\n2. B\n3. C"),
_mock_run("A done"),
Exception("B broke"),
_mock_run("B adapted"),
Exception("C broke"),
Exception("C adapt broke too"),
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
):
result = await run_agentic_loop("A B C", max_steps=3)
assert "1 adapted" in result.summary
assert "1 failed" in result.summary
assert result.status == "partial"
@pytest.mark.asyncio
async def test_task_id_is_set():
"""Result has a non-empty task_id."""
mock_agent = MagicMock()
mock_agent.run = MagicMock(side_effect=[_mock_run("1. X"), _mock_run("done")])
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
):
result = await run_agentic_loop("One step")
assert result.task_id
assert len(result.task_id) == 8
@pytest.mark.asyncio
async def test_total_duration_is_set():
"""Result.total_duration_ms is a positive integer."""
mock_agent = MagicMock()
mock_agent.run = MagicMock(side_effect=[_mock_run("1. X"), _mock_run("done")])
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
):
result = await run_agentic_loop("Quick task")
assert result.total_duration_ms >= 0
@pytest.mark.asyncio
async def test_agent_run_without_content_attr():
"""When agent.run() returns an object without .content, str() is used."""
class PlanResult:
def __str__(self):
return "1. Only step"
class StepResult:
def __str__(self):
return "Step result"
mock_agent = MagicMock()
mock_agent.run = MagicMock(side_effect=[PlanResult(), StepResult()])
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
):
result = await run_agentic_loop("Fallback test", max_steps=1)
assert len(result.steps) == 1
@pytest.mark.asyncio
async def test_adapted_step_calls_on_progress():
"""on_progress is called even for adapted steps."""
events = []
async def on_progress(desc, step, total):
events.append((desc, step))
mock_agent = MagicMock()
mock_agent.run = MagicMock(
side_effect=[
_mock_run("1. Risky step"),
Exception("boom"),
_mock_run("Adapted result"),
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
):
await run_agentic_loop("Adapt test", max_steps=1, on_progress=on_progress)
assert len(events) == 1
assert "[Adapted]" in events[0][0]
@pytest.mark.asyncio
async def test_broadcast_called_for_each_phase():
"""_broadcast_progress is called for plan_ready, step_complete, and task_complete."""
mock_agent = MagicMock()
mock_agent.run = MagicMock(side_effect=[_mock_run("1. Do it"), _mock_run("Done")])
broadcast = AsyncMock()
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent),
patch("timmy.agentic_loop._broadcast_progress", broadcast),
):
await run_agentic_loop("One step task", max_steps=1)
event_names = [call.args[0] for call in broadcast.call_args_list]
assert "agentic.plan_ready" in event_names
assert "agentic.step_complete" in event_names
assert "agentic.task_complete" in event_names

55
tests/test_api_v1.py Normal file
View File

@@ -0,0 +1,55 @@
import sys
# Absolute path to src
src_path = "/home/ubuntu/timmy-time/Timmy-time-dashboard/src"
if src_path not in sys.path:
sys.path.insert(0, src_path)
from fastapi.testclient import TestClient # noqa: E402
try:
from dashboard.app import app # noqa: E402
print("✓ Successfully imported dashboard.app")
except ImportError as e:
print(f"✗ Failed to import dashboard.app: {e}")
sys.exit(1)
client = TestClient(app)
def test_v1_status():
response = client.get("/api/v1/status")
assert response.status_code == 200
data = response.json()
assert "timmy" in data
assert "model" in data
assert "uptime" in data
def test_v1_chat_history():
response = client.get("/api/v1/chat/history")
assert response.status_code == 200
data = response.json()
assert "messages" in data
def test_v1_upload_fail():
# Test without file
response = client.post("/api/v1/upload")
assert response.status_code == 422 # Unprocessable Entity (missing file)
if __name__ == "__main__":
print("Running API v1 tests...")
try:
test_v1_status()
print("✓ Status test passed")
test_v1_chat_history()
print("✓ History test passed")
test_v1_upload_fail()
print("✓ Upload failure test passed")
print("All tests passed!")
except Exception as e:
print(f"Test failed: {e}")
sys.exit(1)

View File

View File

@@ -0,0 +1,240 @@
"""Tests for the Gitea webhook adapter."""
from unittest.mock import AsyncMock, patch
import pytest
from timmy.adapters.gitea_adapter import (
BOT_USERNAMES,
_extract_actor,
_is_bot,
_is_pr_merge,
_normalize_issue_comment,
_normalize_issue_opened,
_normalize_pull_request,
_normalize_push,
handle_webhook,
)
# ── Fixtures: sample payloads ────────────────────────────────────────────────
def _sender(login: str) -> dict:
return {"sender": {"login": login}}
def _push_payload(actor: str = "rockachopa", ref: str = "refs/heads/main") -> dict:
return {
**_sender(actor),
"ref": ref,
"repository": {"full_name": "rockachopa/Timmy-time-dashboard"},
"commits": [
{"message": "fix: something\n\nDetails here"},
{"message": "chore: cleanup"},
],
}
def _issue_payload(actor: str = "rockachopa", action: str = "opened") -> dict:
return {
**_sender(actor),
"action": action,
"repository": {"full_name": "rockachopa/Timmy-time-dashboard"},
"issue": {"number": 42, "title": "Bug in dashboard"},
}
def _issue_comment_payload(actor: str = "rockachopa") -> dict:
return {
**_sender(actor),
"action": "created",
"repository": {"full_name": "rockachopa/Timmy-time-dashboard"},
"issue": {"number": 42, "title": "Bug in dashboard"},
"comment": {"body": "I think this is related to the config change"},
}
def _pr_payload(
actor: str = "rockachopa",
action: str = "opened",
merged: bool = False,
) -> dict:
return {
**_sender(actor),
"action": action,
"repository": {"full_name": "rockachopa/Timmy-time-dashboard"},
"pull_request": {
"number": 99,
"title": "feat: add new feature",
"merged": merged,
},
}
# ── Unit tests: helpers ──────────────────────────────────────────────────────
class TestExtractActor:
def test_normal_sender(self):
assert _extract_actor({"sender": {"login": "rockachopa"}}) == "rockachopa"
def test_missing_sender(self):
assert _extract_actor({}) == "unknown"
class TestIsBot:
@pytest.mark.parametrize("name", list(BOT_USERNAMES))
def test_known_bots(self, name):
assert _is_bot(name) is True
def test_owner_not_bot(self):
assert _is_bot("rockachopa") is False
def test_case_insensitive(self):
assert _is_bot("Kimi") is True
class TestIsPrMerge:
def test_merged_pr(self):
payload = _pr_payload(action="closed", merged=True)
assert _is_pr_merge("pull_request", payload) is True
def test_closed_not_merged(self):
payload = _pr_payload(action="closed", merged=False)
assert _is_pr_merge("pull_request", payload) is False
def test_opened_pr(self):
payload = _pr_payload(action="opened")
assert _is_pr_merge("pull_request", payload) is False
def test_non_pr_event(self):
assert _is_pr_merge("push", {}) is False
# ── Unit tests: normalizers ──────────────────────────────────────────────────
class TestNormalizePush:
def test_basic(self):
data = _normalize_push(_push_payload(), "rockachopa")
assert data["actor"] == "rockachopa"
assert data["ref"] == "refs/heads/main"
assert data["num_commits"] == 2
assert data["head_message"] == "fix: something"
assert data["repo"] == "rockachopa/Timmy-time-dashboard"
def test_empty_commits(self):
payload = {**_push_payload(), "commits": []}
data = _normalize_push(payload, "rockachopa")
assert data["num_commits"] == 0
assert data["head_message"] == ""
class TestNormalizeIssueOpened:
def test_basic(self):
data = _normalize_issue_opened(_issue_payload(), "rockachopa")
assert data["issue_number"] == 42
assert data["title"] == "Bug in dashboard"
assert data["action"] == "opened"
class TestNormalizeIssueComment:
def test_basic(self):
data = _normalize_issue_comment(_issue_comment_payload(), "rockachopa")
assert data["issue_number"] == 42
assert data["comment_body"].startswith("I think this is related")
def test_long_comment_truncated(self):
payload = _issue_comment_payload()
payload["comment"]["body"] = "x" * 500
data = _normalize_issue_comment(payload, "rockachopa")
assert len(data["comment_body"]) == 200
class TestNormalizePullRequest:
def test_opened(self):
data = _normalize_pull_request(_pr_payload(), "rockachopa")
assert data["pr_number"] == 99
assert data["merged"] is False
assert data["action"] == "opened"
def test_merged(self):
payload = _pr_payload(action="closed", merged=True)
data = _normalize_pull_request(payload, "rockachopa")
assert data["merged"] is True
# ── Integration tests: handle_webhook ────────────────────────────────────────
@pytest.mark.asyncio
class TestHandleWebhook:
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
async def test_push_emitted(self, mock_emit):
result = await handle_webhook("push", _push_payload())
assert result is True
mock_emit.assert_called_once()
args = mock_emit.call_args
assert args[0][0] == "gitea.push"
assert args[1]["data"]["num_commits"] == 2
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
async def test_issue_opened_emitted(self, mock_emit):
result = await handle_webhook("issues", _issue_payload())
assert result is True
mock_emit.assert_called_once()
assert mock_emit.call_args[0][0] == "gitea.issue.opened"
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
async def test_issue_comment_emitted(self, mock_emit):
result = await handle_webhook("issue_comment", _issue_comment_payload())
assert result is True
assert mock_emit.call_args[0][0] == "gitea.issue.comment"
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
async def test_pull_request_emitted(self, mock_emit):
result = await handle_webhook("pull_request", _pr_payload())
assert result is True
assert mock_emit.call_args[0][0] == "gitea.pull_request"
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
async def test_unsupported_event_filtered(self, mock_emit):
result = await handle_webhook("fork", {"sender": {"login": "someone"}})
assert result is False
mock_emit.assert_not_called()
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
async def test_bot_push_filtered(self, mock_emit):
result = await handle_webhook("push", _push_payload(actor="kimi"))
assert result is False
mock_emit.assert_not_called()
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
async def test_bot_issue_filtered(self, mock_emit):
result = await handle_webhook("issues", _issue_payload(actor="hermes"))
assert result is False
mock_emit.assert_not_called()
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
async def test_bot_pr_merge_not_filtered(self, mock_emit):
"""Bot PR merges should still be emitted."""
payload = _pr_payload(actor="kimi", action="closed", merged=True)
result = await handle_webhook("pull_request", payload)
assert result is True
mock_emit.assert_called_once()
data = mock_emit.call_args[1]["data"]
assert data["merged"] is True
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
async def test_bot_pr_close_without_merge_filtered(self, mock_emit):
"""Bot PR close (not merge) should be filtered."""
payload = _pr_payload(actor="manus", action="closed", merged=False)
result = await handle_webhook("pull_request", payload)
assert result is False
mock_emit.assert_not_called()
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
async def test_owner_activity_always_emitted(self, mock_emit):
result = await handle_webhook("push", _push_payload(actor="rockachopa"))
assert result is True
mock_emit.assert_called_once()

View File

@@ -0,0 +1,146 @@
"""Tests for the time adapter — circadian awareness."""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch
import pytest
from timmy.adapters.time_adapter import TimeAdapter, classify_period
# ---------- classify_period ----------
@pytest.mark.parametrize(
"hour, expected",
[
(6, "morning"),
(7, "morning"),
(8, "morning"),
(9, None),
(12, "afternoon"),
(13, "afternoon"),
(14, None),
(18, "evening"),
(19, "evening"),
(20, None),
(23, "late_night"),
(0, "late_night"),
(2, "late_night"),
(3, None),
(10, None),
(16, None),
],
)
def test_classify_period(hour: int, expected: str | None) -> None:
assert classify_period(hour) == expected
# ---------- record_interaction / time_since ----------
def test_time_since_last_interaction_none() -> None:
adapter = TimeAdapter()
assert adapter.time_since_last_interaction() is None
def test_time_since_last_interaction() -> None:
adapter = TimeAdapter()
t0 = datetime(2026, 3, 18, 10, 0, 0, tzinfo=UTC)
t1 = datetime(2026, 3, 18, 10, 5, 0, tzinfo=UTC)
adapter.record_interaction(now=t0)
assert adapter.time_since_last_interaction(now=t1) == 300.0
# ---------- tick — circadian events ----------
@pytest.mark.asyncio
async def test_tick_emits_morning() -> None:
adapter = TimeAdapter()
now = datetime(2026, 3, 18, 7, 0, 0, tzinfo=UTC)
with patch("timmy.adapters.time_adapter.emit", new_callable=AsyncMock) as mock_emit:
emitted = await adapter.tick(now=now)
assert "time.morning" in emitted
mock_emit.assert_any_call(
"time.morning",
source="time_adapter",
data={"hour": 7, "period": "morning"},
)
@pytest.mark.asyncio
async def test_tick_emits_late_night() -> None:
adapter = TimeAdapter()
now = datetime(2026, 3, 19, 1, 0, 0, tzinfo=UTC)
with patch("timmy.adapters.time_adapter.emit", new_callable=AsyncMock) as mock_emit:
emitted = await adapter.tick(now=now)
assert "time.late_night" in emitted
mock_emit.assert_any_call(
"time.late_night",
source="time_adapter",
data={"hour": 1, "period": "late_night"},
)
@pytest.mark.asyncio
async def test_tick_no_duplicate_period() -> None:
"""Same period on consecutive ticks should not re-emit."""
adapter = TimeAdapter()
t1 = datetime(2026, 3, 18, 7, 0, 0, tzinfo=UTC)
t2 = datetime(2026, 3, 18, 7, 30, 0, tzinfo=UTC)
with patch("timmy.adapters.time_adapter.emit", new_callable=AsyncMock):
await adapter.tick(now=t1)
emitted = await adapter.tick(now=t2)
assert emitted == []
@pytest.mark.asyncio
async def test_tick_no_event_outside_periods() -> None:
adapter = TimeAdapter()
now = datetime(2026, 3, 18, 10, 0, 0, tzinfo=UTC)
with patch("timmy.adapters.time_adapter.emit", new_callable=AsyncMock) as mock_emit:
emitted = await adapter.tick(now=now)
assert emitted == []
mock_emit.assert_not_called()
# ---------- tick — new_day ----------
@pytest.mark.asyncio
async def test_tick_emits_new_day() -> None:
adapter = TimeAdapter()
day1 = datetime(2026, 3, 18, 23, 30, 0, tzinfo=UTC)
day2 = datetime(2026, 3, 19, 0, 30, 0, tzinfo=UTC)
with patch("timmy.adapters.time_adapter.emit", new_callable=AsyncMock) as mock_emit:
await adapter.tick(now=day1)
emitted = await adapter.tick(now=day2)
assert "time.new_day" in emitted
mock_emit.assert_any_call(
"time.new_day",
source="time_adapter",
data={"date": "2026-03-19"},
)
@pytest.mark.asyncio
async def test_tick_no_new_day_same_date() -> None:
adapter = TimeAdapter()
t1 = datetime(2026, 3, 18, 10, 0, 0, tzinfo=UTC)
t2 = datetime(2026, 3, 18, 15, 0, 0, tzinfo=UTC)
with patch("timmy.adapters.time_adapter.emit", new_callable=AsyncMock):
await adapter.tick(now=t1)
emitted = await adapter.tick(now=t2)
assert "time.new_day" not in emitted

View File

View File

@@ -0,0 +1,393 @@
"""Unit tests for timmy.agents.loader — YAML-driven agent factory."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
import timmy.agents.loader as loader
# ── Fixtures ──────────────────────────────────────────────────────────────────
MINIMAL_YAML = """
defaults:
model: test-model
prompt_tier: lite
max_history: 5
tools: []
routing:
method: pattern
patterns:
coder:
- code
- fix bug
writer:
- write
- draft
agents:
helper:
name: Helper
role: general
prompt: "You are a helpful agent."
coder:
name: Forge
role: code
model: big-model
prompt_tier: full
max_history: 15
tools:
- python
- shell
prompt: "You are a coding agent."
"""
@pytest.fixture(autouse=True)
def _reset_loader_cache():
"""Reset module-level caches before each test."""
loader._agents = None
loader._config = None
yield
loader._agents = None
loader._config = None
@pytest.fixture()
def mock_yaml_config(tmp_path):
"""Write a minimal agents.yaml and patch settings.repo_root to point at it."""
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / "agents.yaml").write_text(MINIMAL_YAML)
with patch.object(loader.settings, "repo_root", str(tmp_path)):
yield tmp_path
# ── _find_config_path ─────────────────────────────────────────────────────────
def test_find_config_path_returns_path(mock_yaml_config):
path = loader._find_config_path()
assert path.exists()
assert path.name == "agents.yaml"
def test_find_config_path_raises_when_missing(tmp_path):
with patch.object(loader.settings, "repo_root", str(tmp_path)):
with pytest.raises(FileNotFoundError, match="Agent config not found"):
loader._find_config_path()
# ── _load_config ──────────────────────────────────────────────────────────────
def test_load_config_parses_yaml(mock_yaml_config):
config = loader._load_config()
assert "defaults" in config
assert "agents" in config
assert "routing" in config
def test_load_config_caches(mock_yaml_config):
cfg1 = loader._load_config()
cfg2 = loader._load_config()
assert cfg1 is cfg2
def test_load_config_force_reload(mock_yaml_config):
cfg1 = loader._load_config()
cfg2 = loader._load_config(force_reload=True)
assert cfg1 is not cfg2
assert cfg1 == cfg2
# ── _resolve_model ────────────────────────────────────────────────────────────
def test_resolve_model_agent_specific():
assert loader._resolve_model("custom-model", {"model": "default-model"}) == "custom-model"
def test_resolve_model_defaults_fallback():
assert loader._resolve_model(None, {"model": "default-model"}) == "default-model"
def test_resolve_model_settings_fallback():
with patch.object(loader.settings, "ollama_model", "settings-model"):
assert loader._resolve_model(None, {}) == "settings-model"
# ── _resolve_prompt_tier ──────────────────────────────────────────────────────
def test_resolve_prompt_tier_agent_specific():
assert loader._resolve_prompt_tier("full", {"prompt_tier": "lite"}) == "full"
def test_resolve_prompt_tier_defaults_fallback():
assert loader._resolve_prompt_tier(None, {"prompt_tier": "full"}) == "full"
def test_resolve_prompt_tier_default_is_lite():
assert loader._resolve_prompt_tier(None, {}) == "lite"
# ── _build_system_prompt ──────────────────────────────────────────────────────
def test_build_system_prompt_full_tier():
with patch("timmy.prompts.get_system_prompt", return_value="BASE") as mock_gsp:
result = loader._build_system_prompt({"prompt": "Custom."}, "full")
mock_gsp.assert_called_once_with(tools_enabled=True)
assert result == "Custom.\n\nBASE"
def test_build_system_prompt_lite_tier():
with patch("timmy.prompts.get_system_prompt", return_value="BASE") as mock_gsp:
result = loader._build_system_prompt({"prompt": "Custom."}, "lite")
mock_gsp.assert_called_once_with(tools_enabled=False)
assert result == "Custom.\n\nBASE"
def test_build_system_prompt_no_custom():
with patch("timmy.prompts.get_system_prompt", return_value="BASE"):
result = loader._build_system_prompt({}, "lite")
assert result == "BASE"
def test_build_system_prompt_empty_custom():
with patch("timmy.prompts.get_system_prompt", return_value="BASE"):
result = loader._build_system_prompt({"prompt": " "}, "lite")
assert result == "BASE"
# ── load_agents ───────────────────────────────────────────────────────────────
def test_load_agents_creates_subagents(mock_yaml_config):
with (
patch("timmy.agents.base.SubAgent") as MockSubAgent,
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
):
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
agents = loader.load_agents()
assert len(agents) == 2
assert "helper" in agents
assert "coder" in agents
def test_load_agents_passes_correct_params(mock_yaml_config):
with (
patch("timmy.agents.base.SubAgent") as MockSubAgent,
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
):
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
loader.load_agents()
calls = {c.kwargs["agent_id"]: c.kwargs for c in MockSubAgent.call_args_list}
coder_kw = calls["coder"]
assert coder_kw["name"] == "Forge"
assert coder_kw["role"] == "code"
assert coder_kw["model"] == "big-model"
assert coder_kw["max_history"] == 15
assert coder_kw["tools"] == ["python", "shell"]
def test_load_agents_uses_defaults(mock_yaml_config):
with (
patch("timmy.agents.base.SubAgent") as MockSubAgent,
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
):
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
loader.load_agents()
calls = {c.kwargs["agent_id"]: c.kwargs for c in MockSubAgent.call_args_list}
helper_kw = calls["helper"]
assert helper_kw["model"] == "test-model"
assert helper_kw["max_history"] == 5
assert helper_kw["tools"] == []
def test_load_agents_caches(mock_yaml_config):
with (
patch("timmy.agents.base.SubAgent") as MockSubAgent,
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
):
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
a1 = loader.load_agents()
a2 = loader.load_agents()
assert a1 is a2
def test_load_agents_force_reload(mock_yaml_config):
with (
patch("timmy.agents.base.SubAgent") as MockSubAgent,
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
):
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
a1 = loader.load_agents()
a2 = loader.load_agents(force_reload=True)
assert a1 is not a2
# ── get_agent ─────────────────────────────────────────────────────────────────
def test_get_agent_returns_agent(mock_yaml_config):
with (
patch("timmy.agents.base.SubAgent") as MockSubAgent,
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
):
MockSubAgent.side_effect = lambda **kw: MagicMock(agent_id=kw["agent_id"])
agent = loader.get_agent("helper")
assert agent.agent_id == "helper"
def test_get_agent_raises_for_unknown(mock_yaml_config):
with (
patch("timmy.agents.base.SubAgent") as MockSubAgent,
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
):
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
with pytest.raises(KeyError, match="Unknown agent.*nope"):
loader.get_agent("nope")
# ── list_agents ───────────────────────────────────────────────────────────────
def test_list_agents_returns_metadata(mock_yaml_config):
result = loader.list_agents()
assert len(result) == 2
ids = {a["id"] for a in result}
assert ids == {"helper", "coder"}
def test_list_agents_includes_model_and_tools(mock_yaml_config):
result = loader.list_agents()
coder = next(a for a in result if a["id"] == "coder")
assert coder["model"] == "big-model"
assert coder["tools"] == ["python", "shell"]
assert coder["status"] == "available"
def test_list_agents_uses_defaults_for_name_and_role(mock_yaml_config):
result = loader.list_agents()
helper = next(a for a in result if a["id"] == "helper")
assert helper["name"] == "Helper"
assert helper["role"] == "general"
# ── get_routing_config ────────────────────────────────────────────────────────
def test_get_routing_config(mock_yaml_config):
routing = loader.get_routing_config()
assert routing["method"] == "pattern"
assert "coder" in routing["patterns"]
def test_get_routing_config_default_when_missing(tmp_path):
"""When no routing section exists, returns a sensible default."""
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / "agents.yaml").write_text("defaults: {}\nagents: {}\n")
with patch.object(loader.settings, "repo_root", str(tmp_path)):
routing = loader.get_routing_config()
assert routing == {"method": "pattern", "patterns": {}}
# ── _matches_pattern ──────────────────────────────────────────────────────────
class TestMatchesPattern:
def test_single_word_match(self):
assert loader._matches_pattern("code", "please code this")
def test_single_word_no_partial(self):
assert not loader._matches_pattern("code", "barcode scanner")
def test_multi_word_all_present(self):
assert loader._matches_pattern("fix bug", "can you fix this bug?")
def test_multi_word_any_order(self):
assert loader._matches_pattern("fix bug", "there is a bug, please fix it")
def test_multi_word_missing_one(self):
assert not loader._matches_pattern("fix bug", "fix the typo")
def test_case_insensitive(self):
assert loader._matches_pattern("Code", "CODE this")
def test_word_boundary(self):
assert not loader._matches_pattern("test", "testing in progress")
# ── route_request ─────────────────────────────────────────────────────────────
def test_route_request_matches_coder(mock_yaml_config):
assert loader.route_request("please code this feature") == "coder"
def test_route_request_matches_writer(mock_yaml_config):
assert loader.route_request("write a summary") == "writer"
def test_route_request_returns_none_when_no_match(mock_yaml_config):
assert loader.route_request("hello there") is None
def test_route_request_non_pattern_method(mock_yaml_config):
"""When routing method is not 'pattern', always returns None."""
loader._load_config()
loader._config["routing"]["method"] = "llm"
assert loader.route_request("code this") is None
# ── route_request_with_match ──────────────────────────────────────────────────
def test_route_request_with_match_returns_tuple(mock_yaml_config):
agent_id, pattern = loader.route_request_with_match("fix this bug please")
assert agent_id == "coder"
assert pattern == "fix bug"
def test_route_request_with_match_no_match(mock_yaml_config):
agent_id, pattern = loader.route_request_with_match("hello")
assert agent_id is None
assert pattern is None
def test_route_request_with_match_non_pattern_method(mock_yaml_config):
loader._load_config()
loader._config["routing"]["method"] = "llm"
agent_id, pattern = loader.route_request_with_match("code this")
assert agent_id is None
assert pattern is None
# ── reload_agents ─────────────────────────────────────────────────────────────
def test_reload_agents_clears_caches(mock_yaml_config):
with (
patch("timmy.agents.base.SubAgent") as MockSubAgent,
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
):
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
loader.load_agents()
assert loader._agents is not None
assert loader._config is not None
loader.reload_agents()
assert loader._agents is not None
assert MockSubAgent.call_count == 4 # 2 agents * 2 loads

View File

@@ -81,7 +81,6 @@ def test_create_timmy_respects_custom_ollama_url():
mock_settings.ollama_url = custom_url
mock_settings.ollama_num_ctx = 4096
mock_settings.timmy_model_backend = "ollama"
mock_settings.airllm_model_size = "70b"
from timmy.agent import create_timmy
@@ -159,7 +158,6 @@ def test_resolve_backend_auto_uses_airllm_on_apple_silicon():
patch("timmy.agent.settings") as mock_settings,
):
mock_settings.timmy_model_backend = "auto"
mock_settings.airllm_model_size = "70b"
mock_settings.ollama_model = "llama3.2"
from timmy.agent import _resolve_backend
@@ -174,7 +172,6 @@ def test_resolve_backend_auto_falls_back_on_non_apple():
patch("timmy.agent.settings") as mock_settings,
):
mock_settings.timmy_model_backend = "auto"
mock_settings.airllm_model_size = "70b"
mock_settings.ollama_model = "llama3.2"
from timmy.agent import _resolve_backend
@@ -259,7 +256,6 @@ def test_create_timmy_includes_tools_for_large_model():
mock_settings.ollama_url = "http://localhost:11434"
mock_settings.ollama_num_ctx = 4096
mock_settings.timmy_model_backend = "ollama"
mock_settings.airllm_model_size = "70b"
mock_settings.telemetry_enabled = False
from timmy.agent import create_timmy

View File

@@ -0,0 +1,386 @@
"""Tests for timmy.agentic_loop — multi-step task execution engine."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from timmy.agentic_loop import (
AgenticResult,
AgenticStep,
_parse_steps,
)
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
class TestAgenticStep:
"""Unit tests for the AgenticStep dataclass."""
def test_creation(self):
step = AgenticStep(
step_num=1,
description="Do thing",
result="Done",
status="completed",
duration_ms=42,
)
assert step.step_num == 1
assert step.description == "Do thing"
assert step.result == "Done"
assert step.status == "completed"
assert step.duration_ms == 42
def test_failed_status(self):
step = AgenticStep(
step_num=2, description="Bad step", result="Error", status="failed", duration_ms=10
)
assert step.status == "failed"
def test_adapted_status(self):
step = AgenticStep(
step_num=3, description="Retried", result="OK", status="adapted", duration_ms=100
)
assert step.status == "adapted"
class TestAgenticResult:
"""Unit tests for the AgenticResult dataclass."""
def test_defaults(self):
result = AgenticResult(task_id="abc", task="Test", summary="Done")
assert result.steps == []
assert result.status == "completed"
assert result.total_duration_ms == 0
def test_with_steps(self):
s = AgenticStep(step_num=1, description="A", result="B", status="completed", duration_ms=5)
result = AgenticResult(task_id="x", task="T", summary="S", steps=[s])
assert len(result.steps) == 1
# ---------------------------------------------------------------------------
# _parse_steps — pure function, highly testable
# ---------------------------------------------------------------------------
class TestParseSteps:
"""Unit tests for the plan parser."""
def test_numbered_with_dots(self):
text = "1. First step\n2. Second step\n3. Third step"
steps = _parse_steps(text)
assert steps == ["First step", "Second step", "Third step"]
def test_numbered_with_parens(self):
text = "1) Do this\n2) Do that"
steps = _parse_steps(text)
assert steps == ["Do this", "Do that"]
def test_mixed_numbering(self):
text = "1. Step one\n2) Step two\n3. Step three"
steps = _parse_steps(text)
assert len(steps) == 3
def test_indented_steps(self):
text = " 1. Indented step\n 2. Also indented"
steps = _parse_steps(text)
assert len(steps) == 2
assert steps[0] == "Indented step"
def test_no_numbered_steps_fallback(self):
text = "Do this first\nThen do that\nFinally wrap up"
steps = _parse_steps(text)
assert len(steps) == 3
assert steps[0] == "Do this first"
def test_empty_string(self):
steps = _parse_steps("")
assert steps == []
def test_blank_lines_ignored_in_fallback(self):
text = "Step A\n\n\nStep B\n"
steps = _parse_steps(text)
assert steps == ["Step A", "Step B"]
def test_strips_whitespace(self):
text = "1. Lots of space \n2. Also spaced "
steps = _parse_steps(text)
assert steps[0] == "Lots of space"
assert steps[1] == "Also spaced"
def test_preamble_ignored_when_numbered(self):
text = "Here is the plan:\n1. Step one\n2. Step two"
steps = _parse_steps(text)
assert steps == ["Step one", "Step two"]
# ---------------------------------------------------------------------------
# _get_loop_agent — singleton pattern
# ---------------------------------------------------------------------------
class TestGetLoopAgent:
"""Tests for the agent singleton."""
def test_creates_agent_once(self):
import timmy.agentic_loop as mod
mod._loop_agent = None
mock_agent = MagicMock()
with patch("timmy.agent.create_timmy", return_value=mock_agent) as mock_create:
agent = mod._get_loop_agent()
assert agent is mock_agent
mock_create.assert_called_once()
# Second call should reuse singleton
agent2 = mod._get_loop_agent()
assert agent2 is mock_agent
mock_create.assert_called_once()
mod._loop_agent = None # cleanup
def test_reuses_existing(self):
import timmy.agentic_loop as mod
sentinel = MagicMock()
mod._loop_agent = sentinel
assert mod._get_loop_agent() is sentinel
mod._loop_agent = None # cleanup
# ---------------------------------------------------------------------------
# _broadcast_progress — best-effort WebSocket broadcast
# ---------------------------------------------------------------------------
class TestBroadcastProgress:
"""Tests for the WebSocket broadcast helper."""
@pytest.mark.asyncio
async def test_successful_broadcast(self):
from timmy.agentic_loop import _broadcast_progress
mock_ws = MagicMock()
mock_ws.broadcast = AsyncMock()
mock_module = MagicMock()
mock_module.ws_manager = mock_ws
with patch.dict("sys.modules", {"infrastructure.ws_manager.handler": mock_module}):
await _broadcast_progress("test.event", {"key": "value"})
mock_ws.broadcast.assert_awaited_once_with("test.event", {"key": "value"})
@pytest.mark.asyncio
async def test_import_error_swallowed(self):
"""When ws_manager import fails, broadcast silently succeeds."""
import sys
from timmy.agentic_loop import _broadcast_progress
# Remove the module so import fails
saved = sys.modules.pop("infrastructure.ws_manager.handler", None)
try:
with patch.dict("sys.modules", {"infrastructure": None}):
# Should not raise — errors are swallowed
await _broadcast_progress("fail.event", {})
finally:
if saved is not None:
sys.modules["infrastructure.ws_manager.handler"] = saved
# ---------------------------------------------------------------------------
# run_agentic_loop — integration-style tests with mocked agent
# ---------------------------------------------------------------------------
class TestRunAgenticLoop:
"""Tests for the main agentic loop."""
@pytest.fixture(autouse=True)
def _reset_agent(self):
import timmy.agentic_loop as mod
mod._loop_agent = None
yield
mod._loop_agent = None
def _mock_agent(self, responses):
"""Create a mock agent that returns responses in sequence."""
agent = MagicMock()
run_results = []
for r in responses:
mock_result = MagicMock()
mock_result.content = r
run_results.append(mock_result)
agent.run = MagicMock(side_effect=run_results)
return agent
@pytest.mark.asyncio
async def test_successful_two_step_task(self):
from timmy.agentic_loop import run_agentic_loop
agent = self._mock_agent(
[
"1. Step one\n2. Step two", # planning
"Step one done", # execution step 1
"Step two done", # execution step 2
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
patch("timmy.session._clean_response", side_effect=lambda x: x),
):
result = await run_agentic_loop("Test task", max_steps=5)
assert result.status == "completed"
assert len(result.steps) == 2
assert result.steps[0].status == "completed"
assert result.steps[1].status == "completed"
assert result.total_duration_ms >= 0
@pytest.mark.asyncio
async def test_planning_failure(self):
from timmy.agentic_loop import run_agentic_loop
agent = MagicMock()
agent.run = MagicMock(side_effect=RuntimeError("LLM down"))
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
):
result = await run_agentic_loop("Broken task", max_steps=3)
assert result.status == "failed"
assert "Planning failed" in result.summary
@pytest.mark.asyncio
async def test_empty_plan(self):
from timmy.agentic_loop import run_agentic_loop
agent = self._mock_agent([""]) # empty plan
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
):
result = await run_agentic_loop("Empty plan task", max_steps=3)
assert result.status == "failed"
assert "no steps" in result.summary.lower()
@pytest.mark.asyncio
async def test_step_failure_triggers_adaptation(self):
from timmy.agentic_loop import run_agentic_loop
agent = MagicMock()
call_count = 0
def mock_run(prompt, **kwargs):
nonlocal call_count
call_count += 1
result = MagicMock()
if call_count == 1:
result.content = "1. Only step"
elif call_count == 2:
raise RuntimeError("Step failed")
else:
result.content = "Adapted successfully"
return result
agent.run = mock_run
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
patch("timmy.session._clean_response", side_effect=lambda x: x),
):
result = await run_agentic_loop("Failing task", max_steps=5)
assert len(result.steps) == 1
assert result.steps[0].status == "adapted"
assert "[Adapted]" in result.steps[0].description
@pytest.mark.asyncio
async def test_max_steps_truncation(self):
from timmy.agentic_loop import run_agentic_loop
agent = self._mock_agent(
[
"1. A\n2. B\n3. C\n4. D\n5. E", # 5 steps planned
"Done A",
"Done B",
]
)
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
patch("timmy.session._clean_response", side_effect=lambda x: x),
):
result = await run_agentic_loop("Big task", max_steps=2)
assert result.status == "partial" # was truncated
assert len(result.steps) == 2
@pytest.mark.asyncio
async def test_on_progress_callback(self):
from timmy.agentic_loop import run_agentic_loop
agent = self._mock_agent(
[
"1. Only step",
"Step done",
]
)
progress_calls = []
async def track_progress(desc, step_num, total):
progress_calls.append((desc, step_num, total))
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
patch("timmy.session._clean_response", side_effect=lambda x: x),
):
await run_agentic_loop("Callback task", max_steps=5, on_progress=track_progress)
assert len(progress_calls) == 1
assert progress_calls[0][1] == 1 # step_num
@pytest.mark.asyncio
async def test_default_max_steps_from_settings(self):
from timmy.agentic_loop import run_agentic_loop
agent = self._mock_agent(["1. Step one", "Done"])
mock_settings = MagicMock()
mock_settings.max_agent_steps = 7
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
patch("timmy.session._clean_response", side_effect=lambda x: x),
patch("config.settings", mock_settings),
):
result = await run_agentic_loop("Settings task")
assert result.status == "completed"
@pytest.mark.asyncio
async def test_task_id_generated(self):
from timmy.agentic_loop import run_agentic_loop
agent = self._mock_agent(["1. Step", "OK"])
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
patch("timmy.session._clean_response", side_effect=lambda x: x),
):
result = await run_agentic_loop("ID task", max_steps=5)
assert result.task_id # non-empty
assert len(result.task_id) == 8 # uuid[:8]

View File

@@ -0,0 +1,485 @@
"""Tests for timmy.agents.base — BaseAgent and SubAgent.
Covers:
- Initialization and default values
- Tool registry integration
- Event bus connection and subscription
- run() with retry logic (transient + fatal errors)
- Event emission on successful run
- get_capabilities / get_status
- SubAgent.execute_task delegation
"""
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
# ── helpers ──────────────────────────────────────────────────────────────────
def _mock_settings(**overrides):
"""Create a settings mock with sensible defaults."""
s = MagicMock()
s.ollama_model = "qwen3:30b"
s.ollama_url = "http://localhost:11434"
s.ollama_num_ctx = 0
s.telemetry_enabled = False
for k, v in overrides.items():
setattr(s, k, v)
return s
def _make_agent_class():
"""Import after patches are in place."""
from timmy.agents.base import SubAgent
return SubAgent
def _make_base_class():
from timmy.agents.base import BaseAgent
return BaseAgent
# ── patch context ────────────────────────────────────────────────────────────
# All tests patch Agno's Agent so we never touch Ollama.
_AGENT_PATCH = "timmy.agents.base.Agent"
_OLLAMA_PATCH = "timmy.agents.base.Ollama"
_SETTINGS_PATCH = "timmy.agents.base.settings"
_REGISTRY_PATCH = "timmy.agents.base.tool_registry"
# ── Initialization ───────────────────────────────────────────────────────────
class TestBaseAgentInit:
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
def test_defaults(self, mock_agent_cls, mock_ollama):
SubAgent = _make_agent_class()
agent = SubAgent(
agent_id="test-1",
name="TestBot",
role="tester",
system_prompt="You are a test agent.",
)
assert agent.agent_id == "test-1"
assert agent.name == "TestBot"
assert agent.role == "tester"
assert agent.tools == []
assert agent.model == "qwen3:30b"
assert agent.max_history == 10
assert agent.event_bus is None
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
def test_custom_model(self, mock_agent_cls, mock_ollama):
SubAgent = _make_agent_class()
agent = SubAgent(
agent_id="a",
name="A",
role="r",
system_prompt="p",
model="llama3:8b",
)
assert agent.model == "llama3:8b"
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
def test_custom_max_history(self, mock_agent_cls, mock_ollama):
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p", max_history=5)
assert agent.max_history == 5
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
def test_tools_list_stored(self, mock_agent_cls, mock_ollama):
SubAgent = _make_agent_class()
agent = SubAgent(
agent_id="a",
name="A",
role="r",
system_prompt="p",
tools=["calculator", "search"],
)
assert agent.tools == ["calculator", "search"]
# ── _create_agent internals ──────────────────────────────────────────────────
class TestCreateAgent:
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings(ollama_num_ctx=4096))
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
def test_num_ctx_passed_when_set(self, mock_agent_cls, mock_ollama):
SubAgent = _make_agent_class()
SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
# Ollama should have been called with options
_, kwargs = mock_ollama.call_args
assert kwargs.get("options") == {"num_ctx": 4096}
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings(ollama_num_ctx=0))
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
def test_num_ctx_omitted_when_zero(self, mock_agent_cls, mock_ollama):
SubAgent = _make_agent_class()
SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
_, kwargs = mock_ollama.call_args
assert "options" not in kwargs
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
def test_tool_registry_lookup(self, mock_agent_cls, mock_ollama):
mock_registry = MagicMock()
handler1 = MagicMock()
handler2 = None # Simulate missing tool
mock_registry.get_handler.side_effect = [handler1, handler2]
with patch(_REGISTRY_PATCH, mock_registry):
SubAgent = _make_agent_class()
SubAgent(
agent_id="a",
name="A",
role="r",
system_prompt="p",
tools=["calc", "missing"],
)
assert mock_registry.get_handler.call_count == 2
# Agent should have been created with just the one handler
_, kwargs = mock_agent_cls.call_args
assert kwargs["tools"] == [handler1]
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
def test_no_tools_passes_none(self, mock_agent_cls, mock_ollama):
with patch(_REGISTRY_PATCH, None):
SubAgent = _make_agent_class()
SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
_, kwargs = mock_agent_cls.call_args
assert kwargs["tools"] is None
# ── Event bus ────────────────────────────────────────────────────────────────
class TestEventBus:
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
def test_connect_event_bus(self, mock_agent_cls, mock_ollama):
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="bot-1", name="B", role="r", system_prompt="p")
bus = MagicMock()
bus.subscribe.return_value = lambda fn: fn # decorator pattern
agent.connect_event_bus(bus)
assert agent.event_bus is bus
assert bus.subscribe.call_count == 2
# Check subscription patterns
patterns = [call.args[0] for call in bus.subscribe.call_args_list]
assert "agent.bot-1.*" in patterns
assert "agent.task.assigned" in patterns
# ── run() retry logic ────────────────────────────────────────────────────────
class TestRun:
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
@pytest.mark.asyncio
async def test_run_success(self, mock_agent_cls, mock_ollama):
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
mock_result = MagicMock()
mock_result.content = "Hello world"
agent.agent.run.return_value = mock_result
response = await agent.run("Hi")
assert response == "Hello world"
agent.agent.run.assert_called_once_with("Hi", stream=False)
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
@pytest.mark.asyncio
async def test_run_result_without_content(self, mock_agent_cls, mock_ollama):
"""When result has no .content, fall back to str()."""
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
agent.agent.run.return_value = "plain string"
response = await agent.run("Hi")
assert response == "plain string"
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
@pytest.mark.asyncio
async def test_run_retries_transient_error(self, mock_agent_cls, mock_ollama):
"""Transient errors (ConnectError etc.) should be retried."""
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
mock_result = MagicMock()
mock_result.content = "recovered"
agent.agent.run.side_effect = [
httpx.ConnectError("refused"),
mock_result,
]
with patch("asyncio.sleep", new_callable=AsyncMock):
response = await agent.run("Hi")
assert response == "recovered"
assert agent.agent.run.call_count == 2
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
@pytest.mark.asyncio
async def test_run_retries_read_timeout(self, mock_agent_cls, mock_ollama):
"""ReadTimeout (GPU contention) should be retried."""
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
mock_result = MagicMock()
mock_result.content = "ok"
agent.agent.run.side_effect = [
httpx.ReadTimeout("timeout"),
mock_result,
]
with patch("asyncio.sleep", new_callable=AsyncMock):
response = await agent.run("Hi")
assert response == "ok"
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
@pytest.mark.asyncio
async def test_run_exhausts_retries_transient(self, mock_agent_cls, mock_ollama):
"""After 3 transient failures, should raise."""
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
agent.agent.run.side_effect = httpx.ConnectError("down")
with patch("asyncio.sleep", new_callable=AsyncMock):
with pytest.raises(httpx.ConnectError):
await agent.run("Hi")
assert agent.agent.run.call_count == 3
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
@pytest.mark.asyncio
async def test_run_retries_non_transient_error(self, mock_agent_cls, mock_ollama):
"""Non-transient errors also get retried (with different backoff)."""
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
agent.agent.run.side_effect = ValueError("bad input")
with patch("asyncio.sleep", new_callable=AsyncMock):
with pytest.raises(ValueError, match="bad input"):
await agent.run("Hi")
assert agent.agent.run.call_count == 3
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
@pytest.mark.asyncio
async def test_run_emits_event_on_success(self, mock_agent_cls, mock_ollama):
"""Successful run should publish response event to bus."""
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="bot-1", name="B", role="r", system_prompt="p")
mock_bus = AsyncMock()
agent.event_bus = mock_bus
mock_result = MagicMock()
mock_result.content = "answer"
agent.agent.run.return_value = mock_result
await agent.run("question")
mock_bus.publish.assert_called_once()
event = mock_bus.publish.call_args[0][0]
assert event.type == "agent.bot-1.response"
assert event.data["input"] == "question"
assert event.data["output"] == "answer"
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
@pytest.mark.asyncio
async def test_run_no_event_without_bus(self, mock_agent_cls, mock_ollama):
"""No bus connected = no event emitted (no crash)."""
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
mock_result = MagicMock()
mock_result.content = "ok"
agent.agent.run.return_value = mock_result
# Should not raise
response = await agent.run("Hi")
assert response == "ok"
# ── get_capabilities / get_status ────────────────────────────────────────────
class TestStatusAndCapabilities:
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
def test_get_capabilities(self, mock_agent_cls, mock_ollama):
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p", tools=["t1", "t2"])
assert agent.get_capabilities() == ["t1", "t2"]
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
def test_get_status(self, mock_agent_cls, mock_ollama):
SubAgent = _make_agent_class()
agent = SubAgent(
agent_id="bot-1",
name="TestBot",
role="assistant",
system_prompt="p",
tools=["calc"],
)
status = agent.get_status()
assert status == {
"agent_id": "bot-1",
"name": "TestBot",
"role": "assistant",
"model": "qwen3:30b",
"status": "ready",
"tools": ["calc"],
}
# ── SubAgent.execute_task ────────────────────────────────────────────────────
class TestSubAgentExecuteTask:
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
@pytest.mark.asyncio
async def test_execute_task_delegates_to_run(self, mock_agent_cls, mock_ollama):
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="bot-1", name="B", role="r", system_prompt="p")
mock_result = MagicMock()
mock_result.content = "task done"
agent.agent.run.return_value = mock_result
result = await agent.execute_task("t-1", "do the thing", {"extra": True})
assert result == {
"task_id": "t-1",
"agent": "bot-1",
"result": "task done",
"status": "completed",
}
# ── Task assignment handler ──────────────────────────────────────────────────
class TestTaskAssignment:
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
@pytest.mark.asyncio
async def test_handles_assigned_task(self, mock_agent_cls, mock_ollama):
"""Agent should process tasks assigned to it."""
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="bot-1", name="B", role="r", system_prompt="p")
mock_result = MagicMock()
mock_result.content = "done"
agent.agent.run.return_value = mock_result
from infrastructure.events.bus import Event
event = Event(
type="agent.task.assigned",
source="coordinator",
data={
"agent_id": "bot-1",
"task_id": "task-42",
"description": "Fix the bug",
},
)
await agent._handle_task_assignment(event)
agent.agent.run.assert_called_once_with("Fix the bug", stream=False)
@patch(_REGISTRY_PATCH, None)
@patch(_SETTINGS_PATCH, _mock_settings())
@patch(_OLLAMA_PATCH)
@patch(_AGENT_PATCH)
@pytest.mark.asyncio
async def test_ignores_task_for_other_agent(self, mock_agent_cls, mock_ollama):
"""Agent should ignore tasks assigned to someone else."""
SubAgent = _make_agent_class()
agent = SubAgent(agent_id="bot-1", name="B", role="r", system_prompt="p")
from infrastructure.events.bus import Event
event = Event(
type="agent.task.assigned",
source="coordinator",
data={
"agent_id": "bot-2",
"task_id": "task-99",
"description": "Not my job",
},
)
await agent._handle_task_assignment(event)
agent.agent.run.assert_not_called()

View File

@@ -0,0 +1,228 @@
"""Unit tests for timmy.briefing — the morning briefing engine."""
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch
from timmy.briefing import (
ApprovalItem,
Briefing,
BriefingEngine,
_gather_swarm_summary,
_load_latest,
_save_briefing,
is_fresh,
)
# ---------------------------------------------------------------------------
# ApprovalItem / Briefing dataclass basics
# ---------------------------------------------------------------------------
class TestApprovalItem:
def test_fields(self):
now = datetime.now(UTC)
item = ApprovalItem(
id="a1",
title="Deploy v2",
description="Upgrade prod",
proposed_action="deploy",
impact="high",
created_at=now,
status="pending",
)
assert item.id == "a1"
assert item.status == "pending"
def test_briefing_defaults(self):
b = Briefing(generated_at=datetime.now(UTC), summary="hello")
assert b.approval_items == []
assert b.period_start < b.period_end
# ---------------------------------------------------------------------------
# is_fresh
# ---------------------------------------------------------------------------
class TestIsFresh:
def test_fresh_briefing(self):
b = Briefing(generated_at=datetime.now(UTC), summary="ok")
assert is_fresh(b) is True
def test_stale_briefing(self):
old = datetime.now(UTC) - timedelta(hours=2)
b = Briefing(generated_at=old, summary="old")
assert is_fresh(b) is False
def test_custom_max_age(self):
recent = datetime.now(UTC) - timedelta(minutes=10)
b = Briefing(generated_at=recent, summary="recent")
assert is_fresh(b, max_age_minutes=5) is False
assert is_fresh(b, max_age_minutes=15) is True
def test_naive_datetime_handled(self):
# briefing.generated_at without tzinfo should still work
naive = datetime.now(UTC).replace(tzinfo=None)
b = Briefing(generated_at=naive, summary="naive")
assert is_fresh(b) is True
# ---------------------------------------------------------------------------
# SQLite cache round-trip
# ---------------------------------------------------------------------------
class TestSqliteCache:
def test_save_and_load(self, tmp_path):
db = tmp_path / "test.db"
now = datetime.now(UTC)
b = Briefing(
generated_at=now,
summary="Good morning",
period_start=now - timedelta(hours=6),
period_end=now,
)
_save_briefing(b, db)
loaded = _load_latest(db)
assert loaded is not None
assert loaded.summary == "Good morning"
assert loaded.generated_at.isoformat()[:19] == now.isoformat()[:19]
def test_load_latest_returns_most_recent(self, tmp_path):
db = tmp_path / "test.db"
old = datetime.now(UTC) - timedelta(hours=12)
new = datetime.now(UTC)
_save_briefing(Briefing(generated_at=old, summary="old"), db)
_save_briefing(Briefing(generated_at=new, summary="new"), db)
loaded = _load_latest(db)
assert loaded.summary == "new"
def test_load_latest_empty_db(self, tmp_path):
db = tmp_path / "empty.db"
assert _load_latest(db) is None
# ---------------------------------------------------------------------------
# _gather_swarm_summary
# ---------------------------------------------------------------------------
class TestGatherSwarmSummary:
def test_missing_db_file(self):
with patch("timmy.briefing.Path") as mock_path_cls:
# Simulate swarm_db.exists() -> False
mock_instance = MagicMock()
mock_instance.exists.return_value = False
original_path = Path
def side_effect(arg):
if arg == "data/swarm.db":
return mock_instance
return original_path(arg)
mock_path_cls.side_effect = side_effect
mock_path_cls.home = original_path.home
result = _gather_swarm_summary(datetime.now(UTC))
assert (
"No swarm" in result
or "unavailable" in result.lower()
or "No swarm activity" in result
)
# ---------------------------------------------------------------------------
# BriefingEngine
# ---------------------------------------------------------------------------
class TestBriefingEngine:
def test_get_cached_empty(self, tmp_path):
db = tmp_path / "test.db"
engine = BriefingEngine(db_path=db)
assert engine.get_cached() is None
def test_needs_refresh_empty(self, tmp_path):
db = tmp_path / "test.db"
engine = BriefingEngine(db_path=db)
assert engine.needs_refresh() is True
def test_needs_refresh_fresh(self, tmp_path):
db = tmp_path / "test.db"
_save_briefing(Briefing(generated_at=datetime.now(UTC), summary="fresh"), db)
engine = BriefingEngine(db_path=db)
assert engine.needs_refresh() is False
def test_needs_refresh_stale(self, tmp_path):
db = tmp_path / "test.db"
old = datetime.now(UTC) - timedelta(hours=2)
_save_briefing(Briefing(generated_at=old, summary="stale"), db)
engine = BriefingEngine(db_path=db)
assert engine.needs_refresh() is True
@patch("timmy.briefing.BriefingEngine._call_agent")
@patch("timmy.briefing.BriefingEngine._load_pending_items")
@patch("timmy.briefing._gather_swarm_summary")
@patch("timmy.briefing._gather_chat_summary")
@patch("timmy.briefing._gather_task_queue_summary")
def test_generate(self, mock_task, mock_chat, mock_swarm, mock_pending, mock_agent, tmp_path):
mock_swarm.return_value = "2 tasks completed"
mock_chat.return_value = "No conversations"
mock_task.return_value = "No tasks"
mock_agent.return_value = "Good morning, Alexander."
mock_pending.return_value = []
db = tmp_path / "test.db"
engine = BriefingEngine(db_path=db)
briefing = engine.generate()
assert briefing.summary == "Good morning, Alexander."
mock_agent.assert_called_once()
# Verify it was cached
assert _load_latest(db) is not None
@patch("timmy.briefing.BriefingEngine._call_agent")
@patch("timmy.briefing.BriefingEngine._load_pending_items")
@patch("timmy.briefing._gather_swarm_summary")
@patch("timmy.briefing._gather_chat_summary")
@patch("timmy.briefing._gather_task_queue_summary")
def test_generate_agent_failure(
self, mock_task, mock_chat, mock_swarm, mock_pending, mock_agent, tmp_path
):
mock_swarm.return_value = ""
mock_chat.return_value = ""
mock_task.return_value = ""
mock_agent.side_effect = Exception("LLM offline")
mock_pending.return_value = []
db = tmp_path / "test.db"
engine = BriefingEngine(db_path=db)
briefing = engine.generate()
# Should gracefully degrade
assert "offline" in briefing.summary.lower()
@patch("timmy.briefing.BriefingEngine._load_pending_items")
def test_get_or_generate_returns_cached(self, mock_pending, tmp_path):
db = tmp_path / "test.db"
_save_briefing(Briefing(generated_at=datetime.now(UTC), summary="cached"), db)
mock_pending.return_value = []
engine = BriefingEngine(db_path=db)
result = engine.get_or_generate()
assert result.summary == "cached"
@patch("timmy.briefing.BriefingEngine.generate")
def test_get_or_generate_regenerates_when_stale(self, mock_gen, tmp_path):
db = tmp_path / "test.db"
old = datetime.now(UTC) - timedelta(hours=2)
_save_briefing(Briefing(generated_at=old, summary="stale"), db)
fresh = Briefing(generated_at=datetime.now(UTC), summary="fresh")
mock_gen.return_value = fresh
engine = BriefingEngine(db_path=db)
result = engine.get_or_generate()
assert result.summary == "fresh"
mock_gen.assert_called_once()

View File

@@ -0,0 +1,219 @@
"""Tests for cognitive state tracking in src/timmy/cognitive_state.py."""
import asyncio
from unittest.mock import patch
from timmy.cognitive_state import (
ENGAGEMENT_LEVELS,
MOOD_VALUES,
CognitiveState,
CognitiveTracker,
_extract_commitments,
_extract_topic,
_infer_engagement,
_infer_mood,
)
class TestCognitiveState:
"""Test the CognitiveState dataclass."""
def test_defaults(self):
state = CognitiveState()
assert state.focus_topic is None
assert state.engagement == "idle"
assert state.mood == "settled"
assert state.conversation_depth == 0
assert state.last_initiative is None
assert state.active_commitments == []
def test_to_dict_excludes_private_fields(self):
state = CognitiveState(focus_topic="testing")
d = state.to_dict()
assert "focus_topic" in d
assert "_confidence_sum" not in d
assert "_confidence_count" not in d
def test_to_dict_includes_public_fields(self):
state = CognitiveState(
focus_topic="loop architecture",
engagement="deep",
mood="curious",
conversation_depth=42,
last_initiative="proposed refactor",
active_commitments=["draft ticket", "review PR"],
)
d = state.to_dict()
assert d["focus_topic"] == "loop architecture"
assert d["engagement"] == "deep"
assert d["mood"] == "curious"
assert d["conversation_depth"] == 42
class TestInferEngagement:
"""Test engagement level inference."""
def test_deep_keywords(self):
assert _infer_engagement("help me debug this", "looking at the stack trace") == "deep"
def test_architecture_is_deep(self):
assert (
_infer_engagement("explain the architecture", "the system has three layers") == "deep"
)
def test_short_response_is_surface(self):
assert _infer_engagement("hi", "hello there") == "surface"
def test_normal_conversation_is_surface(self):
result = _infer_engagement("what time is it", "It is 3pm right now.")
assert result == "surface"
class TestInferMood:
"""Test mood inference."""
def test_low_confidence_is_hesitant(self):
assert _infer_mood("I'm not really sure about this", 0.3) == "hesitant"
def test_exclamation_with_positive_words_is_energized(self):
assert _infer_mood("That's a great idea!", 0.8) == "energized"
def test_question_words_are_curious(self):
assert _infer_mood("I wonder if that would work", 0.6) == "curious"
def test_neutral_is_settled(self):
assert _infer_mood("The answer is 42.", 0.7) == "settled"
def test_valid_mood_values(self):
for mood in MOOD_VALUES:
assert isinstance(mood, str)
class TestExtractTopic:
"""Test topic extraction from messages."""
def test_simple_message(self):
assert _extract_topic("Python decorators") == "Python decorators"
def test_strips_question_prefix(self):
topic = _extract_topic("what is a monad")
assert topic == "a monad"
def test_truncates_long_messages(self):
long_msg = "a" * 100
topic = _extract_topic(long_msg)
assert len(topic) <= 60
def test_empty_returns_none(self):
assert _extract_topic("") is None
assert _extract_topic(" ") is None
class TestExtractCommitments:
"""Test commitment extraction from responses."""
def test_i_will_commitment(self):
result = _extract_commitments("I will draft the skeleton ticket for you.")
assert len(result) >= 1
assert "I will draft the skeleton ticket for you" in result[0]
def test_let_me_commitment(self):
result = _extract_commitments("Let me look into that for you.")
assert len(result) >= 1
def test_no_commitments(self):
result = _extract_commitments("The answer is 42.")
assert result == []
def test_caps_at_three(self):
text = "I will do A. I'll do B. Let me do C. I'm going to do D."
result = _extract_commitments(text)
assert len(result) <= 3
class TestCognitiveTracker:
"""Test the CognitiveTracker behaviour."""
def test_update_increments_depth(self):
tracker = CognitiveTracker()
tracker.update("hello", "Hi there, how can I help?")
assert tracker.get_state().conversation_depth == 1
tracker.update("thanks", "You're welcome!")
assert tracker.get_state().conversation_depth == 2
def test_update_sets_focus_topic(self):
tracker = CognitiveTracker()
tracker.update(
"Python decorators", "Decorators are syntactic sugar for wrapping functions."
)
assert tracker.get_state().focus_topic == "Python decorators"
def test_reset_clears_state(self):
tracker = CognitiveTracker()
tracker.update("hello", "world")
tracker.reset()
state = tracker.get_state()
assert state.conversation_depth == 0
assert state.focus_topic is None
def test_to_json(self):
import json
tracker = CognitiveTracker()
tracker.update("test", "response")
data = json.loads(tracker.to_json())
assert "focus_topic" in data
assert "engagement" in data
assert "mood" in data
def test_engagement_values_are_valid(self):
for level in ENGAGEMENT_LEVELS:
assert isinstance(level, str)
async def test_update_emits_cognitive_state_changed(self):
"""CognitiveTracker.update() emits a sensory event."""
from timmy.event_bus import SensoryBus
mock_bus = SensoryBus()
received = []
mock_bus.subscribe("cognitive_state_changed", lambda e: received.append(e))
with patch("timmy.event_bus.get_sensory_bus", return_value=mock_bus):
tracker = CognitiveTracker()
tracker.update("debug the memory leak", "Looking at the stack trace now.")
# Give the fire-and-forget task a chance to run
await asyncio.sleep(0.05)
assert len(received) == 1
event = received[0]
assert event.source == "cognitive"
assert event.event_type == "cognitive_state_changed"
assert "mood" in event.data
assert "engagement" in event.data
assert "depth" in event.data
assert event.data["depth"] == 1
async def test_update_tracks_mood_change(self):
"""Event data includes whether mood/engagement changed."""
from timmy.event_bus import SensoryBus
mock_bus = SensoryBus()
received = []
mock_bus.subscribe("cognitive_state_changed", lambda e: received.append(e))
with patch("timmy.event_bus.get_sensory_bus", return_value=mock_bus):
tracker = CognitiveTracker()
# First message — "!" + "great" with high confidence → "energized"
tracker.update("wow", "That's a great discovery!")
await asyncio.sleep(0.05)
assert len(received) == 1
# Default mood is "settled", energized response → mood changes
assert received[0].data["mood"] == "energized"
assert received[0].data["mood_changed"] is True
def test_emit_skipped_without_event_loop(self):
"""Event emission gracefully skips when no async loop is running."""
tracker = CognitiveTracker()
# Should not raise — just silently skips
tracker.update("hello", "Hi there!")

View File

@@ -0,0 +1,111 @@
"""Tests for timmy.event_bus — SensoryBus dispatcher."""
import pytest
from timmy.event_bus import SensoryBus, get_sensory_bus
from timmy.events import SensoryEvent
def _make_event(event_type: str = "push", source: str = "gitea") -> SensoryEvent:
return SensoryEvent(source=source, event_type=event_type)
class TestSensoryBusEmitReceive:
@pytest.mark.asyncio
async def test_emit_calls_subscriber(self):
bus = SensoryBus()
received = []
bus.subscribe("push", lambda ev: received.append(ev))
ev = _make_event("push")
count = await bus.emit(ev)
assert count == 1
assert received == [ev]
@pytest.mark.asyncio
async def test_emit_async_handler(self):
bus = SensoryBus()
received = []
async def handler(ev: SensoryEvent):
received.append(ev.event_type)
bus.subscribe("morning", handler)
await bus.emit(_make_event("morning", source="time"))
assert received == ["morning"]
@pytest.mark.asyncio
async def test_no_match_returns_zero(self):
bus = SensoryBus()
bus.subscribe("push", lambda ev: None)
count = await bus.emit(_make_event("issue_opened"))
assert count == 0
@pytest.mark.asyncio
async def test_wildcard_subscriber(self):
bus = SensoryBus()
received = []
bus.subscribe("*", lambda ev: received.append(ev.event_type))
await bus.emit(_make_event("push"))
await bus.emit(_make_event("morning"))
assert received == ["push", "morning"]
@pytest.mark.asyncio
async def test_handler_error_isolated(self):
"""A failing handler must not prevent other handlers from running."""
bus = SensoryBus()
received = []
def bad_handler(ev: SensoryEvent):
raise RuntimeError("boom")
bus.subscribe("push", bad_handler)
bus.subscribe("push", lambda ev: received.append("ok"))
count = await bus.emit(_make_event("push"))
assert count == 2
assert received == ["ok"]
class TestSensoryBusRecent:
@pytest.mark.asyncio
async def test_recent_returns_last_n(self):
bus = SensoryBus()
for i in range(5):
await bus.emit(_make_event(f"ev_{i}"))
last_3 = bus.recent(3)
assert len(last_3) == 3
assert [e.event_type for e in last_3] == ["ev_2", "ev_3", "ev_4"]
@pytest.mark.asyncio
async def test_recent_default(self):
bus = SensoryBus()
for i in range(3):
await bus.emit(_make_event(f"ev_{i}"))
assert len(bus.recent()) == 3
@pytest.mark.asyncio
async def test_history_capped(self):
bus = SensoryBus(max_history=5)
for i in range(10):
await bus.emit(_make_event(f"ev_{i}"))
assert len(bus.recent(100)) == 5
class TestGetSensoryBus:
def test_singleton(self):
import timmy.event_bus as mod
mod._bus = None # reset
a = get_sensory_bus()
b = get_sensory_bus()
assert a is b
mod._bus = None # cleanup

View File

@@ -0,0 +1,64 @@
"""Tests for timmy.events — SensoryEvent model."""
import json
from datetime import UTC, datetime
from timmy.events import SensoryEvent
class TestSensoryEvent:
def test_defaults(self):
ev = SensoryEvent(source="gitea", event_type="push")
assert ev.source == "gitea"
assert ev.event_type == "push"
assert ev.actor == ""
assert ev.data == {}
assert isinstance(ev.timestamp, datetime)
def test_custom_fields(self):
ts = datetime(2025, 1, 1, tzinfo=UTC)
ev = SensoryEvent(
source="bitcoin",
event_type="new_block",
timestamp=ts,
data={"height": 900_000},
actor="network",
)
assert ev.data["height"] == 900_000
assert ev.actor == "network"
assert ev.timestamp == ts
def test_to_dict(self):
ev = SensoryEvent(source="time", event_type="morning")
d = ev.to_dict()
assert d["source"] == "time"
assert d["event_type"] == "morning"
assert isinstance(d["timestamp"], str)
def test_to_json(self):
ev = SensoryEvent(source="terminal", event_type="command", data={"cmd": "ls"})
raw = ev.to_json()
parsed = json.loads(raw)
assert parsed["source"] == "terminal"
assert parsed["data"]["cmd"] == "ls"
def test_from_dict_roundtrip(self):
ev = SensoryEvent(
source="gitea",
event_type="issue_opened",
data={"number": 42},
actor="alice",
)
d = ev.to_dict()
restored = SensoryEvent.from_dict(d)
assert restored.source == ev.source
assert restored.event_type == ev.event_type
assert restored.data == ev.data
assert restored.actor == ev.actor
def test_json_serializable(self):
"""SensoryEvent must be JSON-serializable (acceptance criterion)."""
ev = SensoryEvent(source="gitea", event_type="push", data={"ref": "main"})
raw = ev.to_json()
parsed = json.loads(raw)
assert parsed["source"] == "gitea"

View File

@@ -0,0 +1,198 @@
"""Tests for Pip the Familiar — behavioral state machine."""
import time
import pytest
from timmy.familiar import _FIREPLACE_POS, Familiar, PipState
@pytest.fixture
def pip():
return Familiar()
class TestInitialState:
def test_starts_sleeping(self, pip):
assert pip.state == PipState.SLEEPING
def test_starts_calm(self, pip):
assert pip.mood_mirror == "calm"
def test_snapshot_returns_dict(self, pip):
snap = pip.snapshot().to_dict()
assert snap["name"] == "Pip"
assert snap["state"] == "sleeping"
assert snap["position"] == list(_FIREPLACE_POS)
assert snap["mood_mirror"] == "calm"
assert "state_duration_s" in snap
class TestAutoTransitions:
def test_sleeping_to_waking_after_duration(self, pip):
now = time.monotonic()
# Force a short duration
pip._duration = 1.0
pip._entered_at = now - 2.0
result = pip.tick(now=now)
assert result == PipState.WAKING
def test_waking_to_wandering(self, pip):
now = time.monotonic()
pip._state = PipState.WAKING
pip._duration = 1.0
pip._entered_at = now - 2.0
pip.tick(now=now)
assert pip.state == PipState.WANDERING
def test_wandering_to_bored(self, pip):
now = time.monotonic()
pip._state = PipState.WANDERING
pip._duration = 1.0
pip._entered_at = now - 2.0
pip.tick(now=now)
assert pip.state == PipState.BORED
def test_bored_to_sleeping(self, pip):
now = time.monotonic()
pip._state = PipState.BORED
pip._duration = 1.0
pip._entered_at = now - 2.0
pip.tick(now=now)
assert pip.state == PipState.SLEEPING
def test_full_cycle(self, pip):
"""Pip cycles: SLEEPING → WAKING → WANDERING → BORED → SLEEPING."""
now = time.monotonic()
expected = [
PipState.WAKING,
PipState.WANDERING,
PipState.BORED,
PipState.SLEEPING,
]
for expected_state in expected:
pip._duration = 0.1
pip._entered_at = now - 1.0
pip.tick(now=now)
assert pip.state == expected_state
now += 0.01
def test_no_transition_before_duration(self, pip):
now = time.monotonic()
pip._duration = 100.0
pip._entered_at = now
pip.tick(now=now + 1.0)
assert pip.state == PipState.SLEEPING
class TestEventReactions:
def test_visitor_entered_wakes_pip(self, pip):
assert pip.state == PipState.SLEEPING
pip.on_event("visitor_entered")
assert pip.state == PipState.WAKING
def test_visitor_entered_while_wandering_investigates(self, pip):
pip._state = PipState.WANDERING
pip.on_event("visitor_entered")
assert pip.state == PipState.INVESTIGATING
def test_visitor_spoke_while_wandering_investigates(self, pip):
pip._state = PipState.WANDERING
pip.on_event("visitor_spoke")
assert pip.state == PipState.INVESTIGATING
def test_loud_event_wakes_sleeping_pip(self, pip):
pip.on_event("loud_event")
assert pip.state == PipState.WAKING
def test_unknown_event_no_change(self, pip):
pip.on_event("unknown_event")
assert pip.state == PipState.SLEEPING
def test_investigating_expires_to_bored(self, pip):
now = time.monotonic()
pip._state = PipState.INVESTIGATING
pip._duration = 1.0
pip._entered_at = now - 2.0
pip.tick(now=now)
assert pip.state == PipState.BORED
class TestMoodMirroring:
def test_mood_mirrors_with_delay(self, pip):
now = time.monotonic()
pip.on_mood_change("curious", confidence=0.6, now=now)
# Before delay — still calm
pip.tick(now=now + 1.0)
assert pip.mood_mirror == "calm"
# After 3s delay — mirrors
pip.tick(now=now + 4.0)
assert pip.mood_mirror == "curious"
def test_low_confidence_triggers_alert(self, pip):
pip.on_mood_change("hesitant", confidence=0.2)
assert pip.state == PipState.ALERT
def test_energized_triggers_playful(self, pip):
pip.on_mood_change("energized", confidence=0.7)
assert pip.state == PipState.PLAYFUL
def test_hesitant_low_confidence_triggers_hiding(self, pip):
pip.on_mood_change("hesitant", confidence=0.35)
assert pip.state == PipState.HIDING
def test_special_state_not_from_non_interruptible(self, pip):
pip._state = PipState.INVESTIGATING
pip.on_mood_change("energized", confidence=0.7)
# INVESTIGATING is not interruptible
assert pip.state == PipState.INVESTIGATING
class TestSpecialStateRecovery:
def test_alert_returns_to_wandering(self, pip):
now = time.monotonic()
pip._state = PipState.ALERT
pip._duration = 1.0
pip._entered_at = now - 2.0
pip.tick(now=now)
assert pip.state == PipState.WANDERING
def test_playful_returns_to_wandering(self, pip):
now = time.monotonic()
pip._state = PipState.PLAYFUL
pip._duration = 1.0
pip._entered_at = now - 2.0
pip.tick(now=now)
assert pip.state == PipState.WANDERING
def test_hiding_returns_to_waking(self, pip):
now = time.monotonic()
pip._state = PipState.HIDING
pip._duration = 1.0
pip._entered_at = now - 2.0
pip.tick(now=now)
assert pip.state == PipState.WAKING
class TestPositionHints:
def test_sleeping_near_fireplace(self, pip):
snap = pip.snapshot()
assert snap.position == _FIREPLACE_POS
def test_hiding_behind_desk(self, pip):
pip.on_mood_change("hesitant", confidence=0.35)
assert pip.state == PipState.HIDING
snap = pip.snapshot()
assert snap.position == (0.5, 0.3, -2.0)
def test_playful_near_crystal_ball(self, pip):
pip.on_mood_change("energized", confidence=0.7)
snap = pip.snapshot()
assert snap.position == (1.0, 1.2, 0.0)
class TestSingleton:
def test_module_singleton_exists(self):
from timmy.familiar import pip_familiar
assert isinstance(pip_familiar, Familiar)

113
tests/timmy/test_focus.py Normal file
View File

@@ -0,0 +1,113 @@
"""Tests for timmy.focus — deep focus mode state management."""
import json
import pytest
@pytest.fixture
def focus_mgr(tmp_path):
"""Create a FocusManager with a temporary state directory."""
from timmy.focus import FocusManager
return FocusManager(state_dir=tmp_path)
class TestFocusManager:
"""Unit tests for FocusManager."""
def test_default_state_is_broad(self, focus_mgr):
assert focus_mgr.get_mode() == "broad"
assert focus_mgr.get_topic() is None
assert not focus_mgr.is_focused()
def test_set_topic_activates_deep_focus(self, focus_mgr):
focus_mgr.set_topic("three-phase loop")
assert focus_mgr.get_topic() == "three-phase loop"
assert focus_mgr.get_mode() == "deep"
assert focus_mgr.is_focused()
def test_clear_returns_to_broad(self, focus_mgr):
focus_mgr.set_topic("bitcoin strategy")
focus_mgr.clear()
assert focus_mgr.get_topic() is None
assert focus_mgr.get_mode() == "broad"
assert not focus_mgr.is_focused()
def test_topic_strips_whitespace(self, focus_mgr):
focus_mgr.set_topic(" padded topic ")
assert focus_mgr.get_topic() == "padded topic"
def test_focus_context_when_focused(self, focus_mgr):
focus_mgr.set_topic("memory architecture")
ctx = focus_mgr.get_focus_context()
assert "DEEP FOCUS MODE" in ctx
assert "memory architecture" in ctx
def test_focus_context_when_broad(self, focus_mgr):
assert focus_mgr.get_focus_context() == ""
def test_persistence_across_instances(self, tmp_path):
from timmy.focus import FocusManager
mgr1 = FocusManager(state_dir=tmp_path)
mgr1.set_topic("persistent problem")
# New instance should load persisted state
mgr2 = FocusManager(state_dir=tmp_path)
assert mgr2.get_topic() == "persistent problem"
assert mgr2.is_focused()
def test_clear_persists(self, tmp_path):
from timmy.focus import FocusManager
mgr1 = FocusManager(state_dir=tmp_path)
mgr1.set_topic("will be cleared")
mgr1.clear()
mgr2 = FocusManager(state_dir=tmp_path)
assert not mgr2.is_focused()
assert mgr2.get_topic() is None
def test_state_file_is_valid_json(self, tmp_path, focus_mgr):
focus_mgr.set_topic("json check")
state_file = tmp_path / "focus.json"
assert state_file.exists()
data = json.loads(state_file.read_text())
assert data["topic"] == "json check"
assert data["mode"] == "deep"
def test_missing_state_file_is_fine(self, tmp_path):
"""FocusManager gracefully handles missing state file."""
from timmy.focus import FocusManager
mgr = FocusManager(state_dir=tmp_path / "nonexistent")
assert not mgr.is_focused()
class TestPrependFocusContext:
"""Tests for the session-level focus injection helper."""
def test_no_injection_when_unfocused(self, tmp_path, monkeypatch):
from timmy.focus import FocusManager
mgr = FocusManager(state_dir=tmp_path)
monkeypatch.setattr("timmy.focus.focus_manager", mgr)
from timmy.session import _prepend_focus_context
assert _prepend_focus_context("hello") == "hello"
def test_injection_when_focused(self, tmp_path, monkeypatch):
from timmy.focus import FocusManager
mgr = FocusManager(state_dir=tmp_path)
mgr.set_topic("test topic")
monkeypatch.setattr("timmy.focus.focus_manager", mgr)
from timmy.session import _prepend_focus_context
result = _prepend_focus_context("hello")
assert "DEEP FOCUS MODE" in result
assert "test topic" in result
assert result.endswith("hello")

View File

@@ -6,11 +6,13 @@ import pytest
from timmy.mcp_tools import (
_bridge_to_work_order,
_generate_avatar_image,
_parse_command,
close_mcp_sessions,
create_filesystem_mcp_tools,
create_gitea_issue_via_mcp,
create_gitea_mcp_tools,
update_gitea_avatar,
)
# ---------------------------------------------------------------------------
@@ -302,3 +304,122 @@ def test_mcp_tools_classified_in_safety():
assert not requires_confirmation("issue_write")
assert not requires_confirmation("list_directory")
assert requires_confirmation("write_file")
# ---------------------------------------------------------------------------
# update_gitea_avatar
# ---------------------------------------------------------------------------
def test_generate_avatar_image_returns_png():
"""_generate_avatar_image returns valid PNG bytes."""
pytest.importorskip("PIL")
data = _generate_avatar_image()
assert isinstance(data, bytes)
assert len(data) > 0
# PNG magic bytes
assert data[:4] == b"\x89PNG"
@pytest.mark.asyncio
async def test_update_avatar_not_configured():
"""update_gitea_avatar returns message when Gitea is disabled."""
with patch("timmy.mcp_tools.settings") as mock_settings:
mock_settings.gitea_enabled = False
mock_settings.gitea_token = ""
result = await update_gitea_avatar()
assert "not configured" in result
@pytest.mark.asyncio
async def test_update_avatar_success():
"""update_gitea_avatar uploads avatar and returns success."""
import sys
import timmy.mcp_tools as mcp_mod
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.text = ""
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
# Ensure PIL import check passes even if Pillow isn't installed
pil_stub = MagicMock()
with (
patch("timmy.mcp_tools.settings") as mock_settings,
patch.object(mcp_mod.httpx, "AsyncClient", return_value=mock_client),
patch("timmy.mcp_tools._generate_avatar_image", return_value=b"\x89PNG fake"),
patch.dict(sys.modules, {"PIL": pil_stub, "PIL.Image": pil_stub}),
):
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "tok123"
mock_settings.gitea_url = "http://localhost:3000"
result = await update_gitea_avatar()
assert "successfully" in result
mock_client.post.assert_awaited_once()
call_args = mock_client.post.call_args
assert "/api/v1/user/avatar" in call_args[0][0]
@pytest.mark.asyncio
async def test_update_avatar_api_failure():
"""update_gitea_avatar handles HTTP error gracefully."""
import sys
import timmy.mcp_tools as mcp_mod
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "bad request"
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
pil_stub = MagicMock()
with (
patch("timmy.mcp_tools.settings") as mock_settings,
patch.object(mcp_mod.httpx, "AsyncClient", return_value=mock_client),
patch("timmy.mcp_tools._generate_avatar_image", return_value=b"\x89PNG fake"),
patch.dict(sys.modules, {"PIL": pil_stub, "PIL.Image": pil_stub}),
):
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "tok123"
mock_settings.gitea_url = "http://localhost:3000"
result = await update_gitea_avatar()
assert "failed" in result.lower()
assert "400" in result
@pytest.mark.asyncio
async def test_update_avatar_connection_error():
"""update_gitea_avatar handles connection errors gracefully."""
import sys
import timmy.mcp_tools as mcp_mod
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=mcp_mod.httpx.ConnectError("refused"))
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
pil_stub = MagicMock()
with (
patch("timmy.mcp_tools.settings") as mock_settings,
patch.object(mcp_mod.httpx, "AsyncClient", return_value=mock_client),
patch("timmy.mcp_tools._generate_avatar_image", return_value=b"\x89PNG fake"),
patch.dict(sys.modules, {"PIL": pil_stub, "PIL.Image": pil_stub}),
):
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "tok123"
mock_settings.gitea_url = "http://localhost:3000"
result = await update_gitea_avatar()
assert "connect" in result.lower()

View File

@@ -7,6 +7,8 @@ import pytest
from timmy.memory_system import (
HotMemory,
MemorySystem,
jot_note,
log_decision,
reset_memory_system,
store_last_reflection,
)
@@ -246,3 +248,81 @@ class TestStoreLastReflection:
result = recall_last_reflection()
assert result is None
class TestJotNote:
"""Tests for jot_note() artifact tool."""
def test_saves_note_file(self, tmp_path):
"""jot_note creates a markdown file with title and body."""
notes_dir = tmp_path / "notes"
with patch("timmy.memory_system.NOTES_DIR", notes_dir):
result = jot_note("My Title", "Some body text")
assert "Note saved:" in result
files = list(notes_dir.glob("*.md"))
assert len(files) == 1
content = files[0].read_text()
assert "# My Title" in content
assert "Some body text" in content
assert "Created:" in content
def test_slug_in_filename(self, tmp_path):
"""Filename contains a slug derived from the title."""
notes_dir = tmp_path / "notes"
with patch("timmy.memory_system.NOTES_DIR", notes_dir):
jot_note("Hello World!", "body")
files = list(notes_dir.glob("*.md"))
assert "hello-world" in files[0].name
def test_rejects_empty_title(self):
"""jot_note rejects empty title."""
assert "title is empty" in jot_note("", "body")
assert "title is empty" in jot_note(" ", "body")
def test_rejects_empty_body(self):
"""jot_note rejects empty body."""
assert "body is empty" in jot_note("title", "")
assert "body is empty" in jot_note("title", " ")
class TestLogDecision:
"""Tests for log_decision() artifact tool."""
def test_creates_decision_log(self, tmp_path):
"""log_decision creates the log file and appends an entry."""
log_file = tmp_path / "decisions.md"
with patch("timmy.memory_system.DECISION_LOG", log_file):
result = log_decision("Use SQLite for storage")
assert "Decision logged:" in result
content = log_file.read_text()
assert "# Decision Log" in content
assert "Use SQLite for storage" in content
def test_appends_multiple_decisions(self, tmp_path):
"""Multiple decisions are appended to the same file."""
log_file = tmp_path / "decisions.md"
with patch("timmy.memory_system.DECISION_LOG", log_file):
log_decision("First decision")
log_decision("Second decision")
content = log_file.read_text()
assert "First decision" in content
assert "Second decision" in content
def test_includes_rationale(self, tmp_path):
"""Rationale is included when provided."""
log_file = tmp_path / "decisions.md"
with patch("timmy.memory_system.DECISION_LOG", log_file):
log_decision("Use Redis", "Fast in-memory cache")
content = log_file.read_text()
assert "Use Redis" in content
assert "Fast in-memory cache" in content
def test_rejects_empty_decision(self):
"""log_decision rejects empty decision string."""
assert "decision is empty" in log_decision("")
assert "decision is empty" in log_decision(" ")

View File

@@ -310,6 +310,6 @@ class TestSessionDisconnect:
result = await session.chat("test message")
assert "I'm having trouble reaching my language model" in result
assert "I'm having trouble reaching my inference backend" in result
# Should NOT have Ollama disconnected message
assert "Ollama appears to be disconnected" not in result

View File

@@ -19,6 +19,53 @@ def _reset_session_singleton():
mod._agent = None
# ---------------------------------------------------------------------------
# _annotate_confidence() helper
# ---------------------------------------------------------------------------
class TestAnnotateConfidence:
"""Unit tests for the DRY confidence annotation helper."""
def test_below_threshold_adds_tag(self):
from timmy.session import _annotate_confidence
result = _annotate_confidence("Hello world", 0.55)
assert "[confidence: 55%]" in result
def test_above_threshold_no_tag(self):
from timmy.session import _annotate_confidence
result = _annotate_confidence("Hello world", 0.85)
assert "[confidence:" not in result
assert result == "Hello world"
def test_at_threshold_no_tag(self):
from timmy.session import _annotate_confidence
result = _annotate_confidence("Hello world", 0.7)
assert "[confidence:" not in result
def test_none_confidence_no_tag(self):
from timmy.session import _annotate_confidence
result = _annotate_confidence("Hello world", None)
assert "[confidence:" not in result
assert result == "Hello world"
def test_zero_confidence_adds_tag(self):
from timmy.session import _annotate_confidence
result = _annotate_confidence("Hello world", 0.0)
assert "[confidence: 0%]" in result
def test_preserves_original_text(self):
from timmy.session import _annotate_confidence
result = _annotate_confidence("Original text here", 0.3)
assert result.startswith("Original text here")
# ---------------------------------------------------------------------------
# chat()
# ---------------------------------------------------------------------------

View File

@@ -12,6 +12,7 @@ from timmy.session_logger import (
flush_session_logs,
get_session_logger,
get_session_summary,
self_reflect,
session_history,
)
@@ -927,3 +928,155 @@ class TestSessionHistoryTool:
result = session_history("calculator")
assert "tool:calculator" in result
class TestSelfReflect:
"""Tests for the self_reflect() tool function."""
def _write_entries(self, logs_dir, entries):
"""Helper: write entries to today's session file."""
path = logs_dir / f"session_{date.today().isoformat()}.jsonl"
with open(path, "w") as f:
for entry in entries:
f.write(json.dumps(entry) + "\n")
def test_self_reflect_no_history(self, tmp_path, mock_settings, monkeypatch):
"""Should return a message when no history exists."""
sl = SessionLogger(logs_dir=tmp_path)
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
result = self_reflect()
assert "No conversation history" in result
def test_self_reflect_detects_low_confidence(self, tmp_path, mock_settings, monkeypatch):
"""Should flag low-confidence responses."""
sl = SessionLogger(logs_dir=tmp_path)
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
self._write_entries(
tmp_path,
[
{
"type": "message",
"role": "timmy",
"content": "I think maybe it could be X",
"confidence": 0.3,
"timestamp": "2026-03-01T10:00:00",
},
{
"type": "message",
"role": "timmy",
"content": "The answer is Y",
"confidence": 0.9,
"timestamp": "2026-03-01T10:01:00",
},
],
)
result = self_reflect()
assert "Low-Confidence Responses (1)" in result
assert "confidence=30%" in result
def test_self_reflect_detects_errors(self, tmp_path, mock_settings, monkeypatch):
"""Should report errors in reflection."""
sl = SessionLogger(logs_dir=tmp_path)
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
self._write_entries(
tmp_path,
[
{
"type": "error",
"error": "Ollama connection refused",
"timestamp": "2026-03-01T10:00:00",
},
{
"type": "message",
"role": "user",
"content": "hello",
"timestamp": "2026-03-01T10:01:00",
},
],
)
result = self_reflect()
assert "Errors (1)" in result
assert "Ollama connection refused" in result
def test_self_reflect_detects_repeated_topics(self, tmp_path, mock_settings, monkeypatch):
"""Should identify recurring user topics."""
sl = SessionLogger(logs_dir=tmp_path)
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
self._write_entries(
tmp_path,
[
{
"type": "message",
"role": "user",
"content": "tell me about bitcoin",
"timestamp": "2026-03-01T10:00:00",
},
{
"type": "message",
"role": "user",
"content": "more about bitcoin please",
"timestamp": "2026-03-01T10:01:00",
},
{
"type": "message",
"role": "user",
"content": "bitcoin price today",
"timestamp": "2026-03-01T10:02:00",
},
],
)
result = self_reflect()
assert "Recurring Topics" in result
assert "bitcoin" in result
def test_self_reflect_healthy_session(self, tmp_path, mock_settings, monkeypatch):
"""Should report healthy when no issues found."""
sl = SessionLogger(logs_dir=tmp_path)
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
self._write_entries(
tmp_path,
[
{
"type": "message",
"role": "user",
"content": "hi",
"timestamp": "2026-03-01T10:00:00",
},
{
"type": "message",
"role": "timmy",
"content": "Hello!",
"confidence": 0.9,
"timestamp": "2026-03-01T10:01:00",
},
],
)
result = self_reflect()
assert "Self-Reflection Report" in result
assert "Keep up the good work" in result
def test_self_reflect_includes_insights(self, tmp_path, mock_settings, monkeypatch):
"""Should include actionable insights section."""
sl = SessionLogger(logs_dir=tmp_path)
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
self._write_entries(
tmp_path,
[
{
"type": "message",
"role": "timmy",
"content": "I'm not sure about this",
"confidence": 0.2,
"timestamp": "2026-03-01T10:00:00",
},
],
)
result = self_reflect()
assert "Insights" in result
assert "confidence was low" in result

View File

@@ -1074,3 +1074,117 @@ def test_parse_facts_invalid_json(tmp_path):
"""Totally invalid text with no JSON array should return empty list."""
engine = _make_engine(tmp_path)
assert engine._parse_facts_response("no json here at all") == []
# ---------------------------------------------------------------------------
# Memory status check
# ---------------------------------------------------------------------------
def test_maybe_check_memory_fires_at_interval(tmp_path):
"""_maybe_check_memory should call get_memory_status every N thoughts."""
engine = _make_engine(tmp_path)
# Store exactly 50 thoughts to hit the default interval
for i in range(50):
engine._store_thought(f"Thought {i}.", "freeform")
with (
patch("timmy.thinking.settings") as mock_settings,
patch(
"timmy.tools_intro.get_memory_status",
return_value={
"tier1_hot_memory": {"line_count": 42},
"tier2_vault": {"file_count": 5},
},
) as mock_status,
):
mock_settings.thinking_memory_check_every = 50
engine._maybe_check_memory()
mock_status.assert_called_once()
def test_maybe_check_memory_skips_between_intervals(tmp_path):
"""_maybe_check_memory should not fire when count is not a multiple of interval."""
engine = _make_engine(tmp_path)
# Store 30 thoughts — not a multiple of 50
for i in range(30):
engine._store_thought(f"Thought {i}.", "freeform")
with (
patch("timmy.thinking.settings") as mock_settings,
patch(
"timmy.tools_intro.get_memory_status",
) as mock_status,
):
mock_settings.thinking_memory_check_every = 50
engine._maybe_check_memory()
mock_status.assert_not_called()
def test_maybe_check_memory_graceful_on_error(tmp_path):
"""_maybe_check_memory should not crash if get_memory_status fails."""
engine = _make_engine(tmp_path)
for i in range(50):
engine._store_thought(f"Thought {i}.", "freeform")
with (
patch("timmy.thinking.settings") as mock_settings,
patch(
"timmy.tools_intro.get_memory_status",
side_effect=Exception("boom"),
),
):
mock_settings.thinking_memory_check_every = 50
# Should not raise
engine._maybe_check_memory()
# ---------------------------------------------------------------------------
# Phantom file validation (_references_real_files)
# ---------------------------------------------------------------------------
def test_references_real_files_passes_existing_file(tmp_path):
"""Existing source files should pass validation."""
from timmy.thinking import ThinkingEngine
# src/timmy/thinking.py definitely exists in the project
text = "The bug is in src/timmy/thinking.py where the loop crashes."
assert ThinkingEngine._references_real_files(text) is True
def test_references_real_files_blocks_phantom_file(tmp_path):
"""Non-existent files should be blocked."""
from timmy.thinking import ThinkingEngine
# A completely fabricated module path
text = "The bug is in src/timmy/quantum_brain.py where sessions aren't tracked."
assert ThinkingEngine._references_real_files(text) is False
def test_references_real_files_blocks_phantom_swarm(tmp_path):
"""Non-existent swarm files should be blocked."""
from timmy.thinking import ThinkingEngine
text = "swarm/initialization.py needs to be fixed for proper startup."
assert ThinkingEngine._references_real_files(text) is False
def test_references_real_files_allows_no_paths(tmp_path):
"""Text with no file references should pass (pure prose is fine)."""
from timmy.thinking import ThinkingEngine
text = "The memory system should persist across restarts."
assert ThinkingEngine._references_real_files(text) is True
def test_references_real_files_blocks_mixed(tmp_path):
"""If any referenced file is phantom, the whole text fails."""
from timmy.thinking import ThinkingEngine
# Mix of real and fake files — should fail because of the fake one
text = "Fix src/timmy/thinking.py and also src/timmy/nonexistent_module.py for the memory leak."
assert ThinkingEngine._references_real_files(text) is False

View File

@@ -0,0 +1,255 @@
"""Unit tests for timmy.tools — coverage gaps.
Tests _make_smart_read_file, _safe_eval edge cases, consult_grok,
_create_stub_toolkit, get_tools_for_agent, and AiderTool edge cases.
"""
from __future__ import annotations
import ast
import math
from unittest.mock import MagicMock, patch
import pytest
from timmy.tools import (
_create_stub_toolkit,
_safe_eval,
consult_grok,
create_aider_tool,
get_tools_for_agent,
)
# ── _safe_eval edge cases ─────────────────────────────────────────────────────
class TestSafeEval:
"""Edge cases for the AST-based safe evaluator."""
def _eval(self, expr: str):
allowed = {k: getattr(math, k) for k in dir(math) if not k.startswith("_")}
allowed["math"] = math
allowed["abs"] = abs
allowed["round"] = round
allowed["min"] = min
allowed["max"] = max
tree = ast.parse(expr, mode="eval")
return _safe_eval(tree, allowed)
def test_unsupported_constant_type(self):
"""String constants should be rejected."""
with pytest.raises(ValueError, match="Unsupported constant"):
self._eval("'hello'")
def test_unsupported_binary_op(self):
"""Bitwise ops are not in the allowlist."""
with pytest.raises(ValueError, match="Unsupported"):
self._eval("3 & 5")
def test_unsupported_unary_op(self):
"""Bitwise inversion is not supported."""
with pytest.raises(ValueError, match="Unsupported"):
self._eval("~5")
def test_unknown_name(self):
with pytest.raises(ValueError, match="Unknown name"):
self._eval("foo")
def test_attribute_on_non_math(self):
"""Attribute access on anything except the math module is blocked."""
with pytest.raises(ValueError, match="Attribute access not allowed"):
self._eval("abs.__class__")
def test_call_non_callable(self):
"""Calling a non-callable (like a number) should fail."""
with pytest.raises((ValueError, TypeError)):
self._eval("(42)()")
def test_unsupported_syntax_subscript(self):
"""Subscript syntax (a[0]) is not supported."""
with pytest.raises(ValueError, match="Unsupported syntax"):
self._eval("[1, 2][0]")
def test_kwargs_in_call(self):
"""math.log with keyword arg should work through the evaluator."""
result = self._eval("round(3.14159)")
assert result == 3
def test_math_attr_valid(self):
"""Accessing a valid math attribute should work."""
result = self._eval("math.pi")
assert result == math.pi
def test_math_attr_invalid(self):
"""Accessing a nonexistent math attribute should fail."""
with pytest.raises(ValueError, match="Attribute access not allowed"):
self._eval("math.__builtins__")
# ── _make_smart_read_file ─────────────────────────────────────────────────────
class TestMakeSmartReadFile:
"""Test the smart_read_file wrapper for directory detection."""
def test_directory_returns_listing(self, tmp_path):
"""When given a directory, should list its contents."""
(tmp_path / "alpha.txt").touch()
(tmp_path / "beta.py").touch()
(tmp_path / ".hidden").touch() # should be excluded
from timmy.tools import _make_smart_read_file
file_tools = MagicMock()
file_tools.check_escape.return_value = (True, tmp_path)
smart_read = _make_smart_read_file(file_tools)
result = smart_read(file_name=str(tmp_path))
assert "is a directory" in result
assert "alpha.txt" in result
assert "beta.py" in result
assert ".hidden" not in result
def test_empty_directory(self, tmp_path):
"""Empty directory should show placeholder."""
from timmy.tools import _make_smart_read_file
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
file_tools = MagicMock()
file_tools.check_escape.return_value = (True, empty_dir)
smart_read = _make_smart_read_file(file_tools)
result = smart_read(file_name=str(empty_dir))
assert "empty directory" in result
def test_no_file_name_uses_path_kwarg(self):
"""When file_name is empty, should fall back to path= kwarg."""
from timmy.tools import _make_smart_read_file
file_tools = MagicMock()
file_tools.check_escape.return_value = (True, MagicMock(is_dir=lambda: False))
file_tools.read_file.return_value = "file content"
smart_read = _make_smart_read_file(file_tools)
smart_read(path="/some/file.txt")
file_tools.read_file.assert_called_once()
def test_no_file_name_no_path(self):
"""When neither file_name nor path is given, return error."""
from timmy.tools import _make_smart_read_file
file_tools = MagicMock()
smart_read = _make_smart_read_file(file_tools)
result = smart_read()
assert "Error" in result
def test_file_delegates_to_original(self, tmp_path):
"""Regular files should delegate to original read_file."""
from timmy.tools import _make_smart_read_file
f = tmp_path / "hello.txt"
f.write_text("hello world")
file_tools = MagicMock()
file_tools.check_escape.return_value = (True, f)
file_tools.read_file.return_value = "hello world"
smart_read = _make_smart_read_file(file_tools)
result = smart_read(file_name=str(f))
assert result == "hello world"
def test_preserves_docstring(self):
"""smart_read_file should copy the original's docstring."""
from timmy.tools import _make_smart_read_file
file_tools = MagicMock()
file_tools.read_file.__doc__ = "Original docstring."
smart_read = _make_smart_read_file(file_tools)
assert smart_read.__doc__ == "Original docstring."
# ── consult_grok ──────────────────────────────────────────────────────────────
class TestConsultGrok:
"""Test the Grok consultation tool."""
@patch("timmy.tools.settings")
def test_grok_unavailable(self, mock_settings):
"""When Grok is disabled, should return a helpful message."""
with patch("timmy.backends.grok_available", return_value=False):
result = consult_grok("What is 2+2?")
assert "not available" in result.lower()
# ── _create_stub_toolkit ──────────────────────────────────────────────────────
class TestCreateStubToolkit:
"""Test stub toolkit creation for creative agents."""
def test_stub_has_correct_name(self):
toolkit = _create_stub_toolkit("pixel")
if toolkit is None:
pytest.skip("Agno tools not available")
assert toolkit.name == "pixel"
def test_stub_for_different_agent(self):
toolkit = _create_stub_toolkit("lyra")
if toolkit is None:
pytest.skip("Agno tools not available")
assert toolkit.name == "lyra"
# ── get_tools_for_agent ───────────────────────────────────────────────────────
class TestGetToolsForAgent:
"""Test get_tools_for_agent (not just the alias)."""
def test_known_agent_returns_toolkit(self):
result = get_tools_for_agent("echo")
assert result is not None
def test_unknown_agent_returns_none(self):
result = get_tools_for_agent("nonexistent")
assert result is None
def test_custom_base_dir(self, tmp_path):
result = get_tools_for_agent("echo", base_dir=tmp_path)
assert result is not None
# ── AiderTool edge cases ─────────────────────────────────────────────────────
class TestAiderToolEdgeCases:
"""Additional edge cases for the AiderTool."""
@patch("subprocess.run")
def test_aider_success_empty_stdout(self, mock_run, tmp_path):
"""When stdout is empty, should return fallback message."""
mock_run.return_value = MagicMock(returncode=0, stdout="")
tool = create_aider_tool(tmp_path)
result = tool.run_aider("do something")
assert "successfully" in result.lower()
@patch("subprocess.run")
def test_aider_custom_model(self, mock_run, tmp_path):
"""Custom model parameter should be passed to subprocess."""
mock_run.return_value = MagicMock(returncode=0, stdout="done")
tool = create_aider_tool(tmp_path)
tool.run_aider("task", model="deepseek-coder:6.7b")
args = mock_run.call_args[0][0]
assert "ollama/deepseek-coder:6.7b" in args
@patch("subprocess.run")
def test_aider_os_error(self, mock_run, tmp_path):
"""OSError should be caught gracefully."""
mock_run.side_effect = OSError("Permission denied")
tool = create_aider_tool(tmp_path)
result = tool.run_aider("task")
assert "error" in result.lower()

View File

@@ -0,0 +1,251 @@
"""Tests for Workshop presence heartbeat."""
import json
from unittest.mock import patch
import pytest
from timmy.workshop_state import (
WorkshopHeartbeat,
_state_hash,
get_state_dict,
write_state,
)
# ---------------------------------------------------------------------------
# get_state_dict
# ---------------------------------------------------------------------------
def test_get_state_dict_returns_v1_schema():
state = get_state_dict()
assert state["version"] == 1
assert "liveness" in state
assert "current_focus" in state
assert "mood" in state
assert isinstance(state["active_threads"], list)
assert isinstance(state["recent_events"], list)
assert isinstance(state["concerns"], list)
# Issue #360 enriched fields
assert isinstance(state["confidence"], float)
assert 0.0 <= state["confidence"] <= 1.0
assert isinstance(state["energy"], float)
assert 0.0 <= state["energy"] <= 1.0
assert state["identity"]["name"] == "Timmy"
assert state["identity"]["title"] == "The Workshop Wizard"
assert isinstance(state["identity"]["uptime_seconds"], int)
assert state["activity"]["current"] in ("idle", "thinking")
assert state["environment"]["time_of_day"] in (
"morning",
"afternoon",
"evening",
"night",
"deep-night",
)
assert state["environment"]["day_of_week"] in (
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
)
assert state["interaction"]["visitor_present"] is False
assert isinstance(state["interaction"]["conversation_turns"], int)
assert state["meta"]["schema_version"] == 1
assert state["meta"]["writer"] == "timmy-loop"
assert "updated_at" in state["meta"]
def test_get_state_dict_idle_mood():
"""Idle engagement + settled mood → 'calm' presence mood."""
from timmy.cognitive_state import CognitiveState, CognitiveTracker
tracker = CognitiveTracker.__new__(CognitiveTracker)
tracker.state = CognitiveState(engagement="idle", mood="settled")
with patch("timmy.cognitive_state.cognitive_tracker", tracker):
state = get_state_dict()
assert state["mood"] == "calm"
def test_get_state_dict_maps_mood():
"""Cognitive moods map to presence moods."""
from timmy.cognitive_state import CognitiveState, CognitiveTracker
for cog_mood, expected in [
("curious", "contemplative"),
("hesitant", "uncertain"),
("energized", "excited"),
]:
tracker = CognitiveTracker.__new__(CognitiveTracker)
tracker.state = CognitiveState(engagement="deep", mood=cog_mood)
with patch("timmy.cognitive_state.cognitive_tracker", tracker):
state = get_state_dict()
assert state["mood"] == expected, f"Expected {expected} for {cog_mood}"
# ---------------------------------------------------------------------------
# write_state
# ---------------------------------------------------------------------------
def test_write_state_creates_file(tmp_path):
target = tmp_path / "presence.json"
state = {"version": 1, "liveness": "2026-01-01T00:00:00Z", "current_focus": ""}
write_state(state, path=target)
assert target.exists()
data = json.loads(target.read_text())
assert data["version"] == 1
def test_write_state_creates_parent_dirs(tmp_path):
target = tmp_path / "deep" / "nested" / "presence.json"
write_state({"version": 1}, path=target)
assert target.exists()
# ---------------------------------------------------------------------------
# _state_hash
# ---------------------------------------------------------------------------
def test_state_hash_ignores_liveness():
a = {"version": 1, "mood": "focused", "liveness": "2026-01-01T00:00:00Z"}
b = {"version": 1, "mood": "focused", "liveness": "2026-12-31T23:59:59Z"}
assert _state_hash(a) == _state_hash(b)
def test_state_hash_detects_mood_change():
a = {"version": 1, "mood": "focused", "liveness": "t1"}
b = {"version": 1, "mood": "idle", "liveness": "t1"}
assert _state_hash(a) != _state_hash(b)
# ---------------------------------------------------------------------------
# WorkshopHeartbeat — _write_if_changed
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_write_if_changed_writes_on_first_call(tmp_path):
target = tmp_path / "presence.json"
hb = WorkshopHeartbeat(interval=60, path=target)
with patch("timmy.workshop_state.get_state_dict") as mock_state:
mock_state.return_value = {
"version": 1,
"liveness": "t1",
"current_focus": "testing",
"mood": "focused",
}
await hb._write_if_changed()
assert target.exists()
data = json.loads(target.read_text())
assert data["version"] == 1
assert data["current_focus"] == "testing"
@pytest.mark.asyncio
async def test_write_if_changed_skips_when_unchanged(tmp_path):
target = tmp_path / "presence.json"
hb = WorkshopHeartbeat(interval=60, path=target)
fixed_state = {"version": 1, "liveness": "t1", "mood": "idle"}
with patch("timmy.workshop_state.get_state_dict", return_value=fixed_state):
await hb._write_if_changed() # First write
target.write_text("") # Clear to detect if second write happens
await hb._write_if_changed() # Should skip — state unchanged
# File should still be empty (second write was skipped)
assert target.read_text() == ""
@pytest.mark.asyncio
async def test_write_if_changed_writes_on_state_change(tmp_path):
target = tmp_path / "presence.json"
hb = WorkshopHeartbeat(interval=60, path=target)
state_a = {"version": 1, "liveness": "t1", "mood": "idle"}
state_b = {"version": 1, "liveness": "t2", "mood": "focused"}
with patch("timmy.workshop_state.get_state_dict", return_value=state_a):
await hb._write_if_changed()
with patch("timmy.workshop_state.get_state_dict", return_value=state_b):
await hb._write_if_changed()
data = json.loads(target.read_text())
assert data["mood"] == "focused"
@pytest.mark.asyncio
async def test_write_if_changed_calls_on_change(tmp_path):
"""on_change callback is invoked with state dict when state changes."""
target = tmp_path / "presence.json"
received = []
async def capture(state_dict):
received.append(state_dict)
hb = WorkshopHeartbeat(interval=60, path=target, on_change=capture)
state = {"version": 1, "liveness": "t1", "mood": "focused"}
with patch("timmy.workshop_state.get_state_dict", return_value=state):
await hb._write_if_changed()
assert len(received) == 1
assert received[0]["mood"] == "focused"
@pytest.mark.asyncio
async def test_write_if_changed_skips_on_change_when_unchanged(tmp_path):
"""on_change is NOT called when state hash is unchanged."""
target = tmp_path / "presence.json"
call_count = 0
async def counter(_):
nonlocal call_count
call_count += 1
hb = WorkshopHeartbeat(interval=60, path=target, on_change=counter)
state = {"version": 1, "liveness": "t1", "mood": "idle"}
with patch("timmy.workshop_state.get_state_dict", return_value=state):
await hb._write_if_changed()
await hb._write_if_changed()
assert call_count == 1
# ---------------------------------------------------------------------------
# WorkshopHeartbeat — notify
# ---------------------------------------------------------------------------
def test_notify_sets_trigger():
hb = WorkshopHeartbeat(interval=60)
assert not hb._trigger.is_set()
hb.notify()
assert hb._trigger.is_set()
# ---------------------------------------------------------------------------
# WorkshopHeartbeat — start/stop lifecycle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_heartbeat_start_stop_lifecycle(tmp_path):
target = tmp_path / "presence.json"
hb = WorkshopHeartbeat(interval=60, path=target)
with patch("timmy.workshop_state.get_state_dict", return_value={"version": 1}):
await hb.start()
assert hb._task is not None
assert not hb._task.done()
await hb.stop()
assert hb._task is None

View File

@@ -0,0 +1,319 @@
"""Unit tests for timmy.agentic_loop — agentic loop data structures, parsing, and execution."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from timmy.agentic_loop import (
AgenticResult,
AgenticStep,
_broadcast_progress,
_parse_steps,
run_agentic_loop,
)
# ── Data structures ──────────────────────────────────────────────────────────
class TestAgenticStep:
def test_fields(self):
step = AgenticStep(
step_num=1,
description="Do something",
result="Done",
status="completed",
duration_ms=42,
)
assert step.step_num == 1
assert step.description == "Do something"
assert step.result == "Done"
assert step.status == "completed"
assert step.duration_ms == 42
class TestAgenticResult:
def test_defaults(self):
r = AgenticResult(task_id="abc", task="test task", summary="ok")
assert r.steps == []
assert r.status == "completed"
assert r.total_duration_ms == 0
def test_with_steps(self):
step = AgenticStep(1, "s", "r", "completed", 10)
r = AgenticResult(task_id="x", task="t", summary="s", steps=[step])
assert len(r.steps) == 1
# ── _parse_steps ─────────────────────────────────────────────────────────────
class TestParseSteps:
def test_numbered_dot(self):
text = "1. First step\n2. Second step\n3. Third step"
assert _parse_steps(text) == ["First step", "Second step", "Third step"]
def test_numbered_paren(self):
text = "1) Alpha\n2) Beta"
assert _parse_steps(text) == ["Alpha", "Beta"]
def test_mixed_whitespace(self):
text = " 1. Indented step\n 2. Another "
result = _parse_steps(text)
assert result == ["Indented step", "Another"]
def test_fallback_plain_lines(self):
text = "Do this\nDo that\nDo the other"
assert _parse_steps(text) == ["Do this", "Do that", "Do the other"]
def test_empty_string(self):
assert _parse_steps("") == []
def test_blank_lines_skipped_in_fallback(self):
text = "line one\n\nline two\n \nline three"
assert _parse_steps(text) == ["line one", "line two", "line three"]
# ── _get_loop_agent ──────────────────────────────────────────────────────────
class TestGetLoopAgent:
def test_creates_agent_once(self):
import timmy.agentic_loop as al
saved = al._loop_agent
try:
al._loop_agent = None
mock_agent = MagicMock()
with patch("timmy.agent.create_timmy", return_value=mock_agent):
result = al._get_loop_agent()
assert result is mock_agent
# Second call returns cached
result2 = al._get_loop_agent()
assert result2 is mock_agent
finally:
al._loop_agent = saved
def test_returns_cached(self):
import timmy.agentic_loop as al
saved = al._loop_agent
try:
sentinel = object()
al._loop_agent = sentinel
assert al._get_loop_agent() is sentinel
finally:
al._loop_agent = saved
# ── _broadcast_progress ──────────────────────────────────────────────────────
class TestBroadcastProgress:
@pytest.mark.asyncio
async def test_success(self):
mock_ws = AsyncMock()
with (
patch("timmy.agentic_loop.ws_manager", mock_ws, create=True),
patch.dict(
"sys.modules",
{"infrastructure.ws_manager.handler": MagicMock(ws_manager=mock_ws)},
),
):
await _broadcast_progress("test.event", {"key": "val"})
mock_ws.broadcast.assert_awaited_once_with("test.event", {"key": "val"})
@pytest.mark.asyncio
async def test_import_error_swallowed(self):
with patch.dict("sys.modules", {"infrastructure.ws_manager.handler": None}):
# Should not raise
await _broadcast_progress("test.event", {})
# ── run_agentic_loop ─────────────────────────────────────────────────────────
def _make_mock_agent(plan_text, step_responses=None):
"""Create a mock agent whose .run returns predictable content."""
call_count = 0
def run_side_effect(prompt, *, stream=False, session_id=""):
nonlocal call_count
call_count += 1
resp = MagicMock()
if call_count == 1:
# Planning call
resp.content = plan_text
else:
idx = call_count - 2 # step index (0-based)
if step_responses and idx < len(step_responses):
val = step_responses[idx]
if isinstance(val, Exception):
raise val
resp.content = val
else:
resp.content = f"Step result {call_count}"
return resp
agent = MagicMock()
agent.run = MagicMock(side_effect=run_side_effect)
return agent
@pytest.fixture
def _patch_broadcast():
with patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock):
yield
@pytest.fixture
def _patch_clean_response():
with patch("timmy.session._clean_response", side_effect=lambda x: x):
yield
class TestRunAgenticLoop:
@pytest.mark.asyncio
async def test_successful_execution(self, _patch_broadcast, _patch_clean_response):
agent = _make_mock_agent("1. Step A\n2. Step B", ["Result A", "Result B"])
mock_settings = MagicMock()
mock_settings.max_agent_steps = 10
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch("timmy.agentic_loop.settings", mock_settings, create=True),
patch.dict("sys.modules", {"config": MagicMock(settings=mock_settings)}),
):
result = await run_agentic_loop("do stuff", max_steps=5)
assert result.status == "completed"
assert len(result.steps) == 2
assert result.steps[0].status == "completed"
assert result.steps[0].description == "Step A"
assert result.total_duration_ms >= 0
@pytest.mark.asyncio
async def test_planning_failure(self, _patch_broadcast):
agent = MagicMock()
agent.run = MagicMock(side_effect=RuntimeError("LLM down"))
mock_settings = MagicMock()
mock_settings.max_agent_steps = 5
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch.dict("sys.modules", {"config": MagicMock(settings=mock_settings)}),
):
result = await run_agentic_loop("do stuff", max_steps=3)
assert result.status == "failed"
assert "Planning failed" in result.summary
@pytest.mark.asyncio
async def test_empty_plan(self, _patch_broadcast):
agent = _make_mock_agent("")
mock_settings = MagicMock()
mock_settings.max_agent_steps = 5
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch.dict("sys.modules", {"config": MagicMock(settings=mock_settings)}),
):
result = await run_agentic_loop("do stuff", max_steps=3)
assert result.status == "failed"
assert "no steps" in result.summary.lower()
@pytest.mark.asyncio
async def test_step_failure_triggers_adaptation(self, _patch_broadcast, _patch_clean_response):
agent = _make_mock_agent(
"1. Do X\n2. Do Y",
[RuntimeError("oops"), "Adapted result", "Y done"],
)
mock_settings = MagicMock()
mock_settings.max_agent_steps = 10
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch.dict("sys.modules", {"config": MagicMock(settings=mock_settings)}),
):
result = await run_agentic_loop("do stuff", max_steps=5)
# Step 1 should be adapted, step 2 completed
statuses = [s.status for s in result.steps]
assert "adapted" in statuses
@pytest.mark.asyncio
async def test_truncation_marks_partial(self, _patch_broadcast, _patch_clean_response):
agent = _make_mock_agent(
"1. A\n2. B\n3. C\n4. D\n5. E",
["r1", "r2"],
)
mock_settings = MagicMock()
mock_settings.max_agent_steps = 10
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch.dict("sys.modules", {"config": MagicMock(settings=mock_settings)}),
):
result = await run_agentic_loop("do stuff", max_steps=2)
assert result.status == "partial"
@pytest.mark.asyncio
async def test_on_progress_callback(self, _patch_broadcast, _patch_clean_response):
agent = _make_mock_agent("1. Only step", ["done"])
mock_settings = MagicMock()
mock_settings.max_agent_steps = 10
callback = AsyncMock()
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch.dict("sys.modules", {"config": MagicMock(settings=mock_settings)}),
):
result = await run_agentic_loop("do stuff", max_steps=5, on_progress=callback)
callback.assert_awaited_once_with("Only step", 1, 1)
assert result.status == "completed"
@pytest.mark.asyncio
async def test_default_max_steps_from_settings(self, _patch_broadcast, _patch_clean_response):
agent = _make_mock_agent("1. S1", ["r1"])
mock_settings = MagicMock()
mock_settings.max_agent_steps = 3
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch.dict("sys.modules", {"config": MagicMock(settings=mock_settings)}),
):
result = await run_agentic_loop("do stuff") # max_steps=0 → from settings
assert result.status == "completed"
@pytest.mark.asyncio
async def test_failed_step_and_failed_adaptation(self, _patch_broadcast, _patch_clean_response):
"""When both step and adaptation fail, step is marked failed."""
call_count = 0
def run_side_effect(prompt, *, stream=False, session_id=""):
nonlocal call_count
call_count += 1
if call_count == 1:
resp = MagicMock()
resp.content = "1. Only step"
return resp
# Both step execution and adaptation fail
raise RuntimeError("everything broken")
agent = MagicMock()
agent.run = MagicMock(side_effect=run_side_effect)
mock_settings = MagicMock()
mock_settings.max_agent_steps = 10
with (
patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
patch.dict("sys.modules", {"config": MagicMock(settings=mock_settings)}),
):
result = await run_agentic_loop("do stuff", max_steps=5)
assert result.steps[0].status == "failed"
assert "Failed" in result.steps[0].result
assert result.status == "partial"

View File

@@ -0,0 +1,155 @@
"""Unit tests for timmy.memory.embeddings — embedding, similarity, and keyword overlap."""
import math
from unittest.mock import MagicMock, patch
import pytest
import timmy.memory.embeddings as emb
from timmy.memory.embeddings import (
_keyword_overlap,
_simple_hash_embedding,
cosine_similarity,
embed_text,
)
# ── _simple_hash_embedding ──────────────────────────────────────────────────
class TestSimpleHashEmbedding:
def test_returns_128_dim_vector(self):
vec = _simple_hash_embedding("hello world")
assert len(vec) == 128
def test_normalized(self):
vec = _simple_hash_embedding("some text for embedding")
mag = math.sqrt(sum(x * x for x in vec))
assert mag == pytest.approx(1.0, abs=1e-6)
def test_deterministic(self):
a = _simple_hash_embedding("same input")
b = _simple_hash_embedding("same input")
assert a == b
def test_different_texts_differ(self):
a = _simple_hash_embedding("hello world")
b = _simple_hash_embedding("goodbye moon")
assert a != b
def test_empty_string(self):
vec = _simple_hash_embedding("")
assert len(vec) == 128
# All zeros normalised stays zero (mag fallback to 1.0)
assert all(x == 0.0 for x in vec)
def test_long_text_truncates_at_50_words(self):
"""Words beyond 50 should not change the result."""
short = " ".join(f"word{i}" for i in range(50))
long = short + " extra1 extra2 extra3"
assert _simple_hash_embedding(short) == _simple_hash_embedding(long)
# ── cosine_similarity ────────────────────────────────────────────────────────
class TestCosineSimilarity:
def test_identical_vectors(self):
v = [1.0, 2.0, 3.0]
assert cosine_similarity(v, v) == pytest.approx(1.0)
def test_orthogonal_vectors(self):
a = [1.0, 0.0]
b = [0.0, 1.0]
assert cosine_similarity(a, b) == pytest.approx(0.0)
def test_opposite_vectors(self):
a = [1.0, 0.0]
b = [-1.0, 0.0]
assert cosine_similarity(a, b) == pytest.approx(-1.0)
def test_zero_vector_returns_zero(self):
assert cosine_similarity([0.0, 0.0], [1.0, 2.0]) == 0.0
assert cosine_similarity([1.0, 2.0], [0.0, 0.0]) == 0.0
def test_both_zero_vectors(self):
assert cosine_similarity([0.0], [0.0]) == 0.0
# ── _keyword_overlap ─────────────────────────────────────────────────────────
class TestKeywordOverlap:
def test_full_overlap(self):
assert _keyword_overlap("hello world", "hello world") == pytest.approx(1.0)
def test_partial_overlap(self):
assert _keyword_overlap("hello world", "hello moon") == pytest.approx(0.5)
def test_no_overlap(self):
assert _keyword_overlap("hello", "goodbye") == pytest.approx(0.0)
def test_empty_query(self):
assert _keyword_overlap("", "anything") == 0.0
def test_case_insensitive(self):
assert _keyword_overlap("Hello World", "hello world") == pytest.approx(1.0)
# ── embed_text ───────────────────────────────────────────────────────────────
class TestEmbedText:
def test_uses_fallback_when_model_disabled(self):
with patch.object(emb, "_get_embedding_model", return_value=False):
vec = embed_text("test")
assert len(vec) == 128 # hash fallback dimension
def test_uses_model_when_available(self):
mock_encoding = MagicMock()
mock_encoding.tolist.return_value = [0.1, 0.2, 0.3]
mock_model = MagicMock()
mock_model.encode.return_value = mock_encoding
with patch.object(emb, "_get_embedding_model", return_value=mock_model):
result = embed_text("test")
assert result == pytest.approx([0.1, 0.2, 0.3])
mock_model.encode.assert_called_once_with("test")
# ── _get_embedding_model ─────────────────────────────────────────────────────
class TestGetEmbeddingModel:
def setup_method(self):
self._saved_model = emb.EMBEDDING_MODEL
emb.EMBEDDING_MODEL = None
def teardown_method(self):
emb.EMBEDDING_MODEL = self._saved_model
def test_skip_embeddings_setting(self):
mock_settings = MagicMock()
mock_settings.timmy_skip_embeddings = True
with patch.dict("sys.modules", {"config": MagicMock(settings=mock_settings)}):
emb.EMBEDDING_MODEL = None
result = emb._get_embedding_model()
assert result is False
def test_fallback_when_transformers_missing(self):
mock_settings = MagicMock()
mock_settings.timmy_skip_embeddings = False
with patch.dict(
"sys.modules",
{
"config": MagicMock(settings=mock_settings),
"sentence_transformers": None,
},
):
emb.EMBEDDING_MODEL = None
result = emb._get_embedding_model()
assert result is False
def test_returns_cached_model(self):
sentinel = object()
emb.EMBEDDING_MODEL = sentinel
assert emb._get_embedding_model() is sentinel

View File

@@ -0,0 +1,460 @@
"""Unit tests for timmy.memory.unified — schema, migration, and dataclasses."""
import sqlite3
import uuid
from unittest.mock import patch
import pytest
from timmy.memory.unified import (
MemoryChunk,
MemoryEntry,
_ensure_schema,
_get_table_columns,
_migrate_schema,
get_conn,
get_connection,
)
# ── Helpers ──────────────────────────────────────────────────────────────────
def _make_conn() -> sqlite3.Connection:
"""Return an in-memory SQLite connection with row_factory set."""
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
return conn
# ── get_connection / get_conn ────────────────────────────────────────────────
class TestGetConnection:
"""Tests for get_connection context manager."""
def test_returns_connection(self, tmp_path):
"""get_connection yields a usable sqlite3 connection."""
db = tmp_path / "mem.db"
with patch("timmy.memory.unified.DB_PATH", db):
with get_connection() as conn:
assert isinstance(conn, sqlite3.Connection)
# Schema should already be created
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='memories'"
)
assert cur.fetchone() is not None
def test_creates_parent_directory(self, tmp_path):
"""get_connection creates parent dirs if missing."""
db = tmp_path / "sub" / "dir" / "mem.db"
with patch("timmy.memory.unified.DB_PATH", db):
with get_connection() as conn:
conn.execute("SELECT 1")
assert db.parent.exists()
def test_get_conn_alias(self):
"""get_conn is the same object as get_connection."""
assert get_conn is get_connection
def test_connection_has_row_factory(self, tmp_path):
"""Connection should use sqlite3.Row factory."""
db = tmp_path / "mem.db"
with patch("timmy.memory.unified.DB_PATH", db):
with get_connection() as conn:
assert conn.row_factory is sqlite3.Row
def test_wal_mode_enabled(self, tmp_path):
"""Connection enables WAL journal mode."""
db = tmp_path / "mem.db"
with patch("timmy.memory.unified.DB_PATH", db):
with get_connection() as conn:
cur = conn.execute("PRAGMA journal_mode")
mode = cur.fetchone()[0]
assert mode == "wal"
# ── _ensure_schema ───────────────────────────────────────────────────────────
class TestEnsureSchema:
"""Tests for _ensure_schema."""
def test_creates_memories_table(self):
"""Memories table should be created with correct columns."""
conn = _make_conn()
_ensure_schema(conn)
cols = _get_table_columns(conn, "memories")
expected = {
"id",
"content",
"memory_type",
"source",
"embedding",
"metadata",
"source_hash",
"agent_id",
"task_id",
"session_id",
"confidence",
"tags",
"created_at",
"last_accessed",
"access_count",
}
assert cols == expected
conn.close()
def test_creates_indexes(self):
"""Expected indexes should exist after schema creation."""
conn = _make_conn()
_ensure_schema(conn)
cur = conn.execute("SELECT name FROM sqlite_master WHERE type='index'")
indexes = {row[0] for row in cur.fetchall()}
for idx in [
"idx_memories_type",
"idx_memories_time",
"idx_memories_session",
"idx_memories_agent",
"idx_memories_source",
]:
assert idx in indexes, f"Missing index: {idx}"
conn.close()
def test_idempotent(self):
"""Calling _ensure_schema twice should not error."""
conn = _make_conn()
_ensure_schema(conn)
_ensure_schema(conn) # no error
conn.close()
def test_default_values(self):
"""Inserted row should have correct defaults."""
conn = _make_conn()
_ensure_schema(conn)
conn.execute(
"INSERT INTO memories (id, content, created_at) VALUES (?, ?, ?)",
("test-1", "hello", "2025-01-01T00:00:00"),
)
conn.commit()
row = conn.execute("SELECT * FROM memories WHERE id='test-1'").fetchone()
assert row["memory_type"] == "fact"
assert row["source"] == "agent"
assert row["confidence"] == 0.8
assert row["tags"] == "[]"
assert row["access_count"] == 0
conn.close()
# ── _get_table_columns ───────────────────────────────────────────────────────
class TestGetTableColumns:
"""Tests for _get_table_columns helper."""
def test_returns_column_names(self):
conn = _make_conn()
conn.execute("CREATE TABLE t (a TEXT, b INTEGER, c REAL)")
assert _get_table_columns(conn, "t") == {"a", "b", "c"}
conn.close()
def test_empty_for_missing_table(self):
"""PRAGMA table_info on non-existent table returns empty set."""
conn = _make_conn()
assert _get_table_columns(conn, "nonexistent") == set()
conn.close()
# ── _migrate_schema ──────────────────────────────────────────────────────────
class TestMigrateSchema:
"""Tests for _migrate_schema — old table migration."""
def _setup_memories_table(self, conn):
"""Create the unified memories table for migration tests."""
_ensure_schema(conn)
def test_no_old_tables_is_noop(self):
"""Migration with only memories table does nothing."""
conn = _make_conn()
self._setup_memories_table(conn)
# Should not raise
_migrate_schema(conn)
conn.close()
def test_migrates_episodes_with_context_type(self):
"""Episodes rows migrate to memories with context_type as memory_type."""
conn = _make_conn()
self._setup_memories_table(conn)
conn.execute("""
CREATE TABLE episodes (
id TEXT PRIMARY KEY,
content TEXT,
context_type TEXT,
source TEXT,
embedding TEXT,
metadata TEXT,
agent_id TEXT,
task_id TEXT,
session_id TEXT,
timestamp TEXT
)
""")
conn.execute(
"INSERT INTO episodes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
"ep-1",
"test content",
"conversation",
"agent",
None,
None,
"a1",
"t1",
"s1",
"2025-01-01T00:00:00",
),
)
conn.commit()
_migrate_schema(conn)
# Episodes table should be dropped
tables = {
r[0]
for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
}
assert "episodes" not in tables
# Data should be in memories
row = conn.execute("SELECT * FROM memories WHERE id='ep-1'").fetchone()
assert row is not None
assert row["content"] == "test content"
assert row["memory_type"] == "conversation"
assert row["source"] == "agent"
conn.close()
def test_migrates_episodes_without_context_type(self):
"""Episodes without context_type column default to 'conversation'."""
conn = _make_conn()
self._setup_memories_table(conn)
conn.execute("""
CREATE TABLE episodes (
id TEXT PRIMARY KEY,
content TEXT,
source TEXT,
embedding TEXT,
metadata TEXT,
agent_id TEXT,
task_id TEXT,
session_id TEXT,
timestamp TEXT
)
""")
conn.execute(
"INSERT INTO episodes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
("ep-2", "no type", "user", None, None, None, None, None, "2025-02-01"),
)
conn.commit()
_migrate_schema(conn)
row = conn.execute("SELECT * FROM memories WHERE id='ep-2'").fetchone()
assert row["memory_type"] == "conversation"
conn.close()
def test_migrates_chunks(self):
"""Chunks table migrates to memories as vault_chunk type."""
conn = _make_conn()
self._setup_memories_table(conn)
conn.execute("""
CREATE TABLE chunks (
id TEXT PRIMARY KEY,
content TEXT,
filepath TEXT,
embedding TEXT,
created_at TEXT
)
""")
conn.execute(
"INSERT INTO chunks VALUES (?, ?, ?, ?, ?)",
("ch-1", "chunk text", "/vault/note.md", None, "2025-03-01"),
)
conn.commit()
_migrate_schema(conn)
tables = {
r[0]
for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
}
assert "chunks" not in tables
row = conn.execute("SELECT * FROM memories WHERE id='ch-1'").fetchone()
assert row is not None
assert row["memory_type"] == "vault_chunk"
assert row["source"] == "/vault/note.md"
conn.close()
def test_migrates_chunks_minimal_columns(self):
"""Chunks with minimal columns (text instead of content, no id)."""
conn = _make_conn()
self._setup_memories_table(conn)
conn.execute("CREATE TABLE chunks (text TEXT, source TEXT)")
conn.execute("INSERT INTO chunks VALUES (?, ?)", ("minimal chunk", "vault"))
conn.commit()
_migrate_schema(conn)
rows = conn.execute("SELECT * FROM memories WHERE memory_type='vault_chunk'").fetchall()
assert len(rows) == 1
assert rows[0]["content"] == "minimal chunk"
assert rows[0]["source"] == "vault"
conn.close()
def test_drops_facts_table(self):
"""Old facts table is dropped during migration."""
conn = _make_conn()
self._setup_memories_table(conn)
conn.execute("CREATE TABLE facts (id TEXT PRIMARY KEY, content TEXT)")
conn.commit()
_migrate_schema(conn)
tables = {
r[0]
for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
}
assert "facts" not in tables
conn.close()
def test_migration_handles_episode_error_gracefully(self):
"""If episode migration fails, it logs warning but continues."""
conn = _make_conn()
self._setup_memories_table(conn)
# Create episodes with a column that will cause a conflict
conn.execute("CREATE TABLE episodes (id TEXT PRIMARY KEY, content TEXT)")
# Insert a row with the same ID already in memories to trigger conflict
conn.execute(
"INSERT INTO memories (id, content, created_at) VALUES (?, ?, ?)",
("dup-id", "existing", "2025-01-01"),
)
conn.execute("INSERT INTO episodes VALUES (?, ?)", ("dup-id", "duplicate"))
conn.commit()
# Should not raise — logs warning instead
_migrate_schema(conn)
conn.close()
def test_full_migration_all_old_tables(self):
"""All three old tables migrate in a single pass."""
conn = _make_conn()
self._setup_memories_table(conn)
conn.execute("""
CREATE TABLE episodes (
id TEXT, content TEXT, context_type TEXT, source TEXT,
embedding TEXT, metadata TEXT, agent_id TEXT, task_id TEXT,
session_id TEXT, timestamp TEXT
)
""")
conn.execute(
"CREATE TABLE chunks (id TEXT, content TEXT, filepath TEXT, embedding TEXT, created_at TEXT)"
)
conn.execute("CREATE TABLE facts (id TEXT, content TEXT)")
conn.execute(
"INSERT INTO episodes VALUES ('e1','ep','conv','agent',NULL,NULL,NULL,NULL,NULL,'2025-01-01')"
)
conn.execute("INSERT INTO chunks VALUES ('c1','chunk','/f.md',NULL,'2025-01-01')")
conn.commit()
_migrate_schema(conn)
tables = {
r[0]
for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
}
assert "episodes" not in tables
assert "chunks" not in tables
assert "facts" not in tables
assert "memories" in tables
assert conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0] >= 2
conn.close()
# ── MemoryEntry dataclass ────────────────────────────────────────────────────
class TestMemoryEntry:
"""Tests for the MemoryEntry dataclass."""
def test_defaults(self):
entry = MemoryEntry()
assert entry.content == ""
assert entry.source == ""
assert entry.context_type == "conversation"
assert entry.agent_id is None
assert entry.task_id is None
assert entry.session_id is None
assert entry.metadata is None
assert entry.embedding is None
assert entry.relevance_score is None
# ID should be a valid UUID
uuid.UUID(entry.id)
# Timestamp should be ISO format
assert "T" in entry.timestamp
def test_custom_values(self):
entry = MemoryEntry(
id="custom-id",
content="hello world",
source="user",
context_type="fact",
agent_id="agent-1",
task_id="task-1",
session_id="sess-1",
metadata={"key": "value"},
embedding=[0.1, 0.2, 0.3],
timestamp="2025-06-01T00:00:00",
relevance_score=0.95,
)
assert entry.id == "custom-id"
assert entry.content == "hello world"
assert entry.source == "user"
assert entry.context_type == "fact"
assert entry.agent_id == "agent-1"
assert entry.metadata == {"key": "value"}
assert entry.embedding == [0.1, 0.2, 0.3]
assert entry.relevance_score == 0.95
def test_unique_ids(self):
"""Each MemoryEntry gets a unique default ID."""
a = MemoryEntry()
b = MemoryEntry()
assert a.id != b.id
# ── MemoryChunk dataclass ────────────────────────────────────────────────────
class TestMemoryChunk:
"""Tests for the MemoryChunk dataclass."""
def test_fields(self):
chunk = MemoryChunk(
id="ch-1",
source="/vault/file.md",
content="some text",
embedding=[0.5, 0.6],
created_at="2025-01-01T00:00:00",
)
assert chunk.id == "ch-1"
assert chunk.source == "/vault/file.md"
assert chunk.content == "some text"
assert chunk.embedding == [0.5, 0.6]
assert chunk.created_at == "2025-01-01T00:00:00"
def test_required_fields(self):
"""MemoryChunk requires all fields — no defaults."""
with pytest.raises(TypeError):
MemoryChunk() # type: ignore[call-arg]

View File

@@ -163,9 +163,11 @@ commands =
[testenv:dev]
description = Start dashboard with auto-reload (local development)
setenv =
{[testenv]setenv}
TIMMY_TEST_MODE = 0
commands =
uvicorn dashboard.app:app --reload --host 0.0.0.0 --port 8000 \
--reload-exclude ".claude"
python {toxinidir}/scripts/dev_server.py {posargs}
# ── All Tests (parallel) ─────────────────────────────────────────────────────