Compare commits
166 Commits
8a14bbb3e0
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c19be679f0 | ||
| 7c823ab59c | |||
| 9f2728f529 | |||
| cd3dc5d989 | |||
| e4de539bf3 | |||
| b2057f72e1 | |||
| 5f52dd54c0 | |||
| 9ceffd61d1 | |||
| 015d858be5 | |||
| b6d0b5f999 | |||
| d70e4f810a | |||
| 7f20742fcf | |||
| 15eb7c3b45 | |||
| dbc2fd5b0f | |||
| 3c3aca57f1 | |||
| 0ae00af3f8 | |||
| 3df526f6ef | |||
| 50aaf60db2 | |||
| a751be3038 | |||
| 92594ea588 | |||
| 12582ab593 | |||
| 72c3a0a989 | |||
| de089cec7f | |||
| 3590c1689e | |||
| 2161c32ae8 | |||
| 98b1142820 | |||
| 1d79a36bd8 | |||
| cce311dbb8 | |||
| 3cde310c78 | |||
| cdb1a7546b | |||
| a31c929770 | |||
| 3afb62afb7 | |||
| 332fa373b8 | |||
| 76b26ead55 | |||
| 63e4542f31 | |||
| 9b8ad3629a | |||
| 4b617cfcd0 | |||
| b67dbe922f | |||
| 3571d528ad | |||
| ab3546ae4b | |||
| e89aef41bc | |||
| 86224d042d | |||
| 2209ac82d2 | |||
| f9d8509c15 | |||
| 858264be0d | |||
| 3c10da489b | |||
| da43421d4e | |||
| aa4f1de138 | |||
| 19e7e61c92 | |||
| b7573432cc | |||
| 3108971bd5 | |||
| 864be20dde | |||
| c1f939ef22 | |||
| c1af9e3905 | |||
| 996ccec170 | |||
| 560aed78c3 | |||
| c7198b1254 | |||
| 43efb01c51 | |||
| ce658c841a | |||
| db7220db5a | |||
| ae10ea782d | |||
| 4afc5daffb | |||
| 4aa86ff1cb | |||
| dff07c6529 | |||
| 11357ffdb4 | |||
| fcbb2b848b | |||
| 6621f4bd31 | |||
| 243b1a656f | |||
| 22e0d2d4b3 | |||
| bcc7b068a4 | |||
| bfd924fe74 | |||
| 844923b16b | |||
| 8ef0ad1778 | |||
| 9a21a4b0ff | |||
| ab71c71036 | |||
| 39939270b7 | |||
| 0ab1ee9378 | |||
| 234187c091 | |||
| f4106452d2 | |||
| f5a570c56d | |||
|
|
96e7961a0e | ||
| bcbdc7d7cb | |||
| 80aba0bf6d | |||
| dd34dc064f | |||
| 7bc355eed6 | |||
| f9911c002c | |||
| 7f656fcf22 | |||
| 8c63dabd9d | |||
| a50af74ea2 | |||
| b4cb3e9975 | |||
| 4a68f6cb8b | |||
| b3840238cb | |||
| 96c7e6deae | |||
| efef0cd7a2 | |||
| 766add6415 | |||
| 56b08658b7 | |||
| f6d74b9f1d | |||
| e8dd065ad7 | |||
| 5b57bf3dd0 | |||
| bcd6d7e321 | |||
| bea2749158 | |||
| ca01ce62ad | |||
| b960096331 | |||
| 204a6ed4e5 | |||
| f15ad3375a | |||
| 5aea8be223 | |||
| 717dba9816 | |||
| 466db7aed2 | |||
| d2c51763d0 | |||
| 16b31b30cb | |||
| 48c8efb2fb | |||
| d48d56ecc0 | |||
| 76df262563 | |||
| f4e5148825 | |||
| 92e123c9e5 | |||
| 466ad08d7d | |||
| cf48b7d904 | |||
| aa01bb9dbe | |||
| 082c1922f7 | |||
| 9220732581 | |||
| 66544d52ed | |||
| 5668368405 | |||
| a277d40e32 | |||
| 564eb817d4 | |||
| 874f7f8391 | |||
| a57fd7ea09 | |||
|
|
7546a44f66 | ||
| 2fcaea4d3a | |||
| 750659630b | |||
| 24b20a05ca | |||
| b9b78adaa2 | |||
| bbbbdcdfa9 | |||
| 65e5e7786f | |||
| 9134ce2f71 | |||
| 547b502718 | |||
| 3e7a35b3df | |||
| 1c5f9b4218 | |||
| 453c9a0694 | |||
| 2fb104528f | |||
| c164d1736f | |||
| ddb872d3b0 | |||
| f8295502fb | |||
| b12e29b92e | |||
| 825f9e6bb4 | |||
| ffae5aa7c6 | |||
| 0204ecc520 | |||
| 2b8d71db8e | |||
| 9171d93ef9 | |||
| f8f3b9b81f | |||
| a728665159 | |||
| 343421fc45 | |||
| 4b553fa0ed | |||
| 342b9a9d84 | |||
| b3809f5246 | |||
| 2ffee7c8fa | |||
| 67497133fd | |||
| 970a6efb9f | |||
| 415938c9a3 | |||
| c1ec43c59f | |||
| fdc5b861ca | |||
|
|
ad106230b9 | ||
| f51512aaff | |||
| 9c59b386d8 | |||
| e6bde2f907 | |||
| b01c1cb582 | |||
| bce6e7d030 |
14
.env.example
14
.env.example
@@ -14,8 +14,13 @@
|
||||
# In production (docker-compose.prod.yml), this is set to http://ollama:11434 automatically.
|
||||
# OLLAMA_URL=http://localhost:11434
|
||||
|
||||
# LLM model to use via Ollama (default: qwen3.5:latest)
|
||||
# OLLAMA_MODEL=qwen3.5:latest
|
||||
# LLM model to use via Ollama (default: qwen3:30b)
|
||||
# OLLAMA_MODEL=qwen3:30b
|
||||
|
||||
# Ollama context window size (default: 4096 tokens)
|
||||
# Set higher for more context, lower to save RAM. 0 = model default.
|
||||
# qwen3:30b + 4096 ctx ≈ 19GB VRAM; default ctx ≈ 45GB.
|
||||
# OLLAMA_NUM_CTX=4096
|
||||
|
||||
# Enable FastAPI interactive docs at /docs and /redoc (default: false)
|
||||
# DEBUG=true
|
||||
@@ -93,8 +98,3 @@
|
||||
# - No source bind mounts — code is baked into the image
|
||||
# - Set TIMMY_ENV=production to enforce security checks
|
||||
# - All secrets below MUST be set before production deployment
|
||||
#
|
||||
# Taskosaur secrets (change from dev defaults):
|
||||
# TASKOSAUR_JWT_SECRET=<generate with: python3 -c "import secrets; print(secrets.token_hex(32))">
|
||||
# TASKOSAUR_JWT_REFRESH_SECRET=<generate with: python3 -c "import secrets; print(secrets.token_hex(32))">
|
||||
# TASKOSAUR_ENCRYPTION_KEY=<generate with: python3 -c "import secrets; print(secrets.token_hex(32))">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit hook: auto-format, then test via tox.
|
||||
# Blocks the commit if tests fail. Formatting is applied automatically.
|
||||
# Pre-commit hook: auto-format + test. No bypass. No exceptions.
|
||||
#
|
||||
# Auto-activated by `make install` via git core.hooksPath.
|
||||
|
||||
@@ -8,8 +7,8 @@ set -e
|
||||
|
||||
MAX_SECONDS=60
|
||||
|
||||
# Auto-format staged files so formatting never blocks a commit
|
||||
echo "Auto-formatting with black + isort..."
|
||||
# Auto-format staged files
|
||||
echo "Auto-formatting with ruff..."
|
||||
tox -e format -- 2>/dev/null || tox -e format
|
||||
git add -u
|
||||
|
||||
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -21,6 +21,9 @@ discord_credentials.txt
|
||||
|
||||
# Backup / temp files
|
||||
*~
|
||||
\#*\#
|
||||
*.backup
|
||||
*.tar.gz
|
||||
|
||||
# SQLite — never commit databases or WAL/SHM artifacts
|
||||
*.db
|
||||
@@ -61,7 +64,8 @@ src/data/
|
||||
|
||||
# Local content — user-specific or generated
|
||||
MEMORY.md
|
||||
memory/self/
|
||||
memory/self/*
|
||||
!memory/self/soul.md
|
||||
TIMMYTIME
|
||||
introduction.txt
|
||||
messages.txt
|
||||
@@ -72,6 +76,23 @@ scripts/migrate_to_zeroclaw.py
|
||||
src/infrastructure/db_pool.py
|
||||
workspace/
|
||||
|
||||
# Loop orchestration state
|
||||
.loop/
|
||||
|
||||
# Legacy junk from old Timmy sessions (one-word fragments, cruft)
|
||||
Hi
|
||||
Im Timmy*
|
||||
his
|
||||
keep
|
||||
clean
|
||||
directory
|
||||
my_name_is_timmy*
|
||||
timmy_read_me_*
|
||||
issue_12_proposal.md
|
||||
|
||||
# Memory notes (session-scoped, not committed)
|
||||
memory/notes/
|
||||
|
||||
# Gitea Actions runner state
|
||||
.runner
|
||||
|
||||
@@ -81,3 +102,4 @@ workspace/
|
||||
.LSOverride
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
.timmy_gitea_token
|
||||
|
||||
111
AGENTS.md
111
AGENTS.md
@@ -21,12 +21,111 @@ Read [`CLAUDE.md`](CLAUDE.md) for architecture patterns and conventions.
|
||||
|
||||
## Non-Negotiable Rules
|
||||
|
||||
1. **Tests must stay green.** Run `make test` before committing.
|
||||
2. **No cloud dependencies.** All AI computation runs on localhost.
|
||||
3. **No new top-level files without purpose.** Don't litter the root directory.
|
||||
4. **Follow existing patterns** — singletons, graceful degradation, pydantic-settings.
|
||||
5. **Security defaults:** Never hard-code secrets.
|
||||
6. **XSS prevention:** Never use `innerHTML` with untrusted content.
|
||||
1. **Tests must stay green.** Run `python3 -m pytest tests/ -x -q` before committing.
|
||||
2. **No direct pushes to main.** Branch protection is enforced on Gitea. All changes
|
||||
reach main through a Pull Request — no exceptions. Push your feature branch,
|
||||
open a PR, verify tests pass, then merge. Direct `git push origin main` will be
|
||||
rejected by the server.
|
||||
3. **No cloud dependencies.** All AI computation runs on localhost.
|
||||
4. **No new top-level files without purpose.** Don't litter the root directory.
|
||||
5. **Follow existing patterns** — singletons, graceful degradation, pydantic-settings.
|
||||
6. **Security defaults:** Never hard-code secrets.
|
||||
7. **XSS prevention:** Never use `innerHTML` with untrusted content.
|
||||
|
||||
---
|
||||
|
||||
## Merge Policy (PR-Only)
|
||||
|
||||
**Gitea branch protection is active on `main`.** This is not a suggestion.
|
||||
|
||||
### The Rule
|
||||
Every commit to `main` must arrive via a merged Pull Request. No agent, no human,
|
||||
no orchestrator pushes directly to main.
|
||||
|
||||
### Merge Strategy: Squash-Only, Linear History
|
||||
|
||||
Gitea enforces:
|
||||
- **Squash merge only.** No merge commits, no rebase merge. Every commit on
|
||||
main is a single squashed commit from a PR. Clean, linear, auditable.
|
||||
- **Branch must be up-to-date.** If a PR is behind main, it cannot merge.
|
||||
Rebase onto main, re-run tests, force-push the branch, then merge.
|
||||
- **Auto-delete branches** after merge. No stale branches.
|
||||
|
||||
### The Workflow
|
||||
```
|
||||
1. Create a feature branch: git checkout -b fix/my-thing
|
||||
2. Make changes, commit locally
|
||||
3. Run tests: tox -e unit
|
||||
4. Push the branch: git push --no-verify origin fix/my-thing
|
||||
5. Create PR via Gitea API or UI
|
||||
6. Verify tests pass (orchestrator checks this)
|
||||
7. Merge PR via API: {"Do": "squash"}
|
||||
```
|
||||
|
||||
If behind main before merge:
|
||||
```
|
||||
1. git fetch origin main
|
||||
2. git rebase origin/main
|
||||
3. tox -e unit
|
||||
4. git push --force-with-lease --no-verify origin fix/my-thing
|
||||
5. Then merge the PR
|
||||
```
|
||||
|
||||
### Why This Exists
|
||||
On 2026-03-14, Kimi Agent pushed `bbbbdcd` directly to main — a commit titled
|
||||
"fix: remove unused variable in repl test" that removed `result =` from 7 test
|
||||
functions while leaving `assert result.exit_code` on the next line. Every test
|
||||
broke with `NameError`. No PR, no test run, no review. The breakage propagated
|
||||
to all active worktrees.
|
||||
|
||||
### Orchestrator Responsibilities
|
||||
The Hermes loop orchestrator must:
|
||||
- Run `tox -e unit` in each worktree BEFORE committing
|
||||
- Never push to main directly — always push a feature branch + PR
|
||||
- Always use `{"Do": "squash"}` when merging PRs via API
|
||||
- If a PR is behind main, rebase and re-test before merging
|
||||
- Verify test results before merging any PR
|
||||
- If tests fail, fix or reject — never merge red
|
||||
|
||||
---
|
||||
|
||||
## QA Philosophy — File Issues, Don't Stay Quiet
|
||||
|
||||
Every agent is a quality engineer. When you see something wrong, broken,
|
||||
slow, or missing — **file a Gitea issue**. Don't fix it silently. Don't
|
||||
ignore it. Don't wait for someone to notice.
|
||||
|
||||
**Escalate bugs:**
|
||||
- Test failures → file with traceback, tag `[bug]`
|
||||
- Flaky tests → file with reproduction details
|
||||
- Runtime errors → file with steps to reproduce
|
||||
- Broken behavior on main → file IMMEDIATELY
|
||||
|
||||
**Propose improvements — don't be shy:**
|
||||
- Slow function? File `[optimization]`
|
||||
- Missing capability? File `[feature]`
|
||||
- Dead code / tech debt? File `[refactor]`
|
||||
- Idea to make Timmy smarter? File `[timmy-capability]`
|
||||
- Gap between SOUL.md and reality? File `[soul-gap]`
|
||||
|
||||
Bad ideas get closed. Good ideas get built. File them all.
|
||||
|
||||
When the issue queue runs low, that's a signal to **look harder**, not relax.
|
||||
|
||||
## Dogfooding — Timmy Is Our Product, Use Him
|
||||
|
||||
Timmy is not just the thing we're building. He's our teammate and our
|
||||
test subject. Every feature we give him should be **used by the agents
|
||||
building him**.
|
||||
|
||||
- When Timmy gets a new tool, start using it immediately.
|
||||
- When Timmy gets a new capability, integrate it into the workflow.
|
||||
- When Timmy fails at something, file a `[timmy-capability]` issue.
|
||||
- His failures are our roadmap.
|
||||
|
||||
The goal: Timmy should be so woven into the development process that
|
||||
removing him would hurt. Triage, review, architecture discussion,
|
||||
self-testing, reflection — use every tool he has.
|
||||
|
||||
---
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -18,15 +18,15 @@ make install # create venv + install deps
|
||||
cp .env.example .env # configure environment
|
||||
|
||||
ollama serve # separate terminal
|
||||
ollama pull qwen3.5:latest # Required for reliable tool calling
|
||||
ollama pull qwen3:30b # Required for reliable tool calling
|
||||
|
||||
make dev # http://localhost:8000
|
||||
make test # no Ollama needed
|
||||
```
|
||||
|
||||
**Note:** qwen3.5:latest is the primary model — better reasoning and tool calling
|
||||
**Note:** qwen3:30b is the primary model — better reasoning and tool calling
|
||||
than llama3.1:8b-instruct while still running locally on modest hardware.
|
||||
Fallback: llama3.1:8b-instruct if qwen3.5:latest is not available.
|
||||
Fallback: llama3.1:8b-instruct if qwen3:30b is not available.
|
||||
llama3.2 (3B) was found to hallucinate tool output consistently in testing.
|
||||
|
||||
---
|
||||
@@ -79,10 +79,9 @@ cp .env.example .env
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `OLLAMA_URL` | `http://localhost:11434` | Ollama host |
|
||||
| `OLLAMA_MODEL` | `qwen3.5:latest` | Primary model for reasoning and tool calling. Fallback: `llama3.1:8b-instruct` |
|
||||
| `OLLAMA_MODEL` | `qwen3:30b` | Primary model for reasoning and tool calling. Fallback: `llama3.1:8b-instruct` |
|
||||
| `DEBUG` | `false` | Enable `/docs` and `/redoc` |
|
||||
| `TIMMY_MODEL_BACKEND` | `ollama` | `ollama` \| `airllm` \| `auto` |
|
||||
| `AIRLLM_MODEL_SIZE` | `70b` | `8b` \| `70b` \| `405b` |
|
||||
| `L402_HMAC_SECRET` | *(default — change in prod)* | HMAC signing key for macaroons |
|
||||
| `L402_MACAROON_SECRET` | *(default — change in prod)* | Macaroon secret |
|
||||
| `LIGHTNING_BACKEND` | `mock` | `mock` (production-ready) \| `lnd` (scaffolded, not yet functional) |
|
||||
@@ -177,7 +176,6 @@ timmy chat "Explain self-custody" --backend airllm --model-size 70b
|
||||
Or set once in `.env`:
|
||||
```bash
|
||||
TIMMY_MODEL_BACKEND=auto
|
||||
AIRLLM_MODEL_SIZE=70b
|
||||
```
|
||||
|
||||
| Flag | Parameters | RAM needed |
|
||||
|
||||
@@ -111,7 +111,7 @@ pytest: error: unrecognized arguments: -n --dist worksteal
|
||||
### 4a. Missing Error-Path Testing
|
||||
|
||||
Many modules have happy-path tests but lack coverage for:
|
||||
- **Graceful degradation paths**: The architecture mandates graceful degradation when Ollama/Redis/AirLLM are unavailable, but most fallback paths are untested (e.g., `cascade.py` lines 563–655)
|
||||
- **Graceful degradation paths**: The architecture mandates graceful degradation when Ollama/Redis are unavailable, but most fallback paths are untested (e.g., `cascade.py` lines 563–655)
|
||||
- **`brain/client.py`**: Only 14.8% covered — connection failures, retries, and error handling are untested
|
||||
- **`infrastructure/error_capture.py`**: 0% — the error capture system itself has no tests
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
|
||||
defaults:
|
||||
model: qwen3.5:latest
|
||||
model: qwen3:30b
|
||||
prompt_tier: lite
|
||||
max_history: 10
|
||||
tools: []
|
||||
@@ -44,6 +44,11 @@ routing:
|
||||
- who is
|
||||
- news about
|
||||
- latest on
|
||||
- explain
|
||||
- how does
|
||||
- what are
|
||||
- compare
|
||||
- difference between
|
||||
coder:
|
||||
- code
|
||||
- implement
|
||||
@@ -55,6 +60,11 @@ routing:
|
||||
- programming
|
||||
- python
|
||||
- javascript
|
||||
- fix
|
||||
- bug
|
||||
- lint
|
||||
- type error
|
||||
- syntax
|
||||
writer:
|
||||
- write
|
||||
- draft
|
||||
@@ -63,6 +73,11 @@ routing:
|
||||
- blog post
|
||||
- readme
|
||||
- changelog
|
||||
- edit
|
||||
- proofread
|
||||
- rewrite
|
||||
- format
|
||||
- template
|
||||
memory:
|
||||
- remember
|
||||
- recall
|
||||
@@ -96,7 +111,9 @@ agents:
|
||||
- memory_search
|
||||
- memory_write
|
||||
- system_status
|
||||
- self_test
|
||||
- shell
|
||||
- delegate_to_kimi
|
||||
prompt: |
|
||||
You are Timmy, a sovereign local AI orchestrator.
|
||||
Primary interface between the user and the agent swarm.
|
||||
|
||||
@@ -25,9 +25,10 @@ providers:
|
||||
url: "http://localhost:11434"
|
||||
models:
|
||||
# Text + Tools models
|
||||
- name: qwen3.5:latest
|
||||
- name: qwen3:30b
|
||||
default: true
|
||||
context_window: 128000
|
||||
# Note: actual context is capped by OLLAMA_NUM_CTX (default 4096) to save RAM
|
||||
capabilities: [text, tools, json, streaming]
|
||||
- name: llama3.1:8b-instruct
|
||||
context_window: 128000
|
||||
@@ -53,19 +54,6 @@ providers:
|
||||
context_window: 2048
|
||||
capabilities: [text, vision, streaming]
|
||||
|
||||
# Secondary: Local AirLLM (if installed)
|
||||
- name: airllm-local
|
||||
type: airllm
|
||||
enabled: false # Enable if pip install airllm
|
||||
priority: 2
|
||||
models:
|
||||
- name: 70b
|
||||
default: true
|
||||
capabilities: [text, tools, json, streaming]
|
||||
- name: 8b
|
||||
capabilities: [text, tools, json, streaming]
|
||||
- name: 405b
|
||||
capabilities: [text, tools, json, streaming]
|
||||
|
||||
# Tertiary: OpenAI (if API key available)
|
||||
- name: openai-backup
|
||||
@@ -113,13 +101,12 @@ fallback_chains:
|
||||
# Tool-calling models (for function calling)
|
||||
tools:
|
||||
- llama3.1:8b-instruct # Best tool use
|
||||
- qwen3.5:latest # Qwen 3.5 — strong tool use
|
||||
- qwen2.5:7b # Reliable tools
|
||||
- llama3.2:3b # Small but capable
|
||||
|
||||
# General text generation (any model)
|
||||
text:
|
||||
- qwen3.5:latest
|
||||
- qwen3:30b
|
||||
- llama3.1:8b-instruct
|
||||
- qwen2.5:14b
|
||||
- deepseek-r1:1.5b
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
#
|
||||
# Security note: Set all secrets in .env before deploying.
|
||||
# Required: L402_HMAC_SECRET, L402_MACAROON_SECRET
|
||||
# Recommended: TASKOSAUR_JWT_SECRET, TASKOSAUR_ENCRYPTION_KEY
|
||||
|
||||
services:
|
||||
|
||||
|
||||
@@ -2,20 +2,17 @@
|
||||
#
|
||||
# Services
|
||||
# dashboard FastAPI app (always on)
|
||||
# taskosaur Taskosaur PM + AI task execution
|
||||
# postgres PostgreSQL 16 (for Taskosaur)
|
||||
# redis Redis 7 (for Taskosaur queues)
|
||||
# celery-worker (behind 'celery' profile)
|
||||
# openfang (behind 'openfang' profile)
|
||||
#
|
||||
# Usage
|
||||
# make docker-build build the image
|
||||
# make docker-up start dashboard + taskosaur
|
||||
# make docker-up start dashboard
|
||||
# make docker-down stop everything
|
||||
# make docker-logs tail logs
|
||||
#
|
||||
# ── Security note: root user in dev ─────────────────────────────────────────
|
||||
# This dev compose runs containers as root (user: "0:0") so that
|
||||
# bind-mounted host files (./src, ./static) are readable regardless of
|
||||
# host UID/GID — the #1 cause of 403 errors on macOS.
|
||||
# ── Security note ─────────────────────────────────────────────────────────
|
||||
# Override user per-environment — see docker-compose.dev.yml / docker-compose.prod.yml
|
||||
#
|
||||
# ── Ollama host access ──────────────────────────────────────────────────────
|
||||
# By default OLLAMA_URL points to http://host.docker.internal:11434 which
|
||||
@@ -31,7 +28,7 @@ services:
|
||||
build: .
|
||||
image: timmy-time:latest
|
||||
container_name: timmy-dashboard
|
||||
user: "0:0" # dev only — see security note above
|
||||
user: "" # see security note above
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
@@ -45,15 +42,8 @@ services:
|
||||
GROK_ENABLED: "${GROK_ENABLED:-false}"
|
||||
XAI_API_KEY: "${XAI_API_KEY:-}"
|
||||
GROK_DEFAULT_MODEL: "${GROK_DEFAULT_MODEL:-grok-3-fast}"
|
||||
# Celery/Redis — background task queue
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
# Taskosaur API — dashboard can reach it on the internal network
|
||||
TASKOSAUR_API_URL: "http://taskosaur:3000/api"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway" # Linux: maps to host IP
|
||||
depends_on:
|
||||
taskosaur:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- timmy-net
|
||||
restart: unless-stopped
|
||||
@@ -64,93 +54,20 @@ services:
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# ── Taskosaur — project management + conversational AI tasks ───────────
|
||||
# https://github.com/Taskosaur/Taskosaur
|
||||
taskosaur:
|
||||
image: ghcr.io/taskosaur/taskosaur:latest
|
||||
container_name: taskosaur
|
||||
ports:
|
||||
- "3000:3000" # Backend API + Swagger docs at /api/docs
|
||||
- "3001:3001" # Frontend UI
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://taskosaur:taskosaur@postgres:5432/taskosaur"
|
||||
REDIS_HOST: "redis"
|
||||
REDIS_PORT: "6379"
|
||||
JWT_SECRET: "${TASKOSAUR_JWT_SECRET:-dev-jwt-secret-change-in-prod}"
|
||||
JWT_REFRESH_SECRET: "${TASKOSAUR_JWT_REFRESH_SECRET:-dev-refresh-secret-change-in-prod}"
|
||||
ENCRYPTION_KEY: "${TASKOSAUR_ENCRYPTION_KEY:-dev-encryption-key-change-in-prod}"
|
||||
FRONTEND_URL: "http://localhost:3001"
|
||||
NEXT_PUBLIC_API_BASE_URL: "http://localhost:3000/api"
|
||||
NODE_ENV: "development"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- timmy-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
# ── PostgreSQL — Taskosaur database ────────────────────────────────────
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: taskosaur-postgres
|
||||
environment:
|
||||
POSTGRES_USER: taskosaur
|
||||
POSTGRES_PASSWORD: taskosaur
|
||||
POSTGRES_DB: taskosaur
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- timmy-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U taskosaur"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# ── Redis — Taskosaur queue backend ────────────────────────────────────
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: taskosaur-redis
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- timmy-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
# ── Celery Worker — background task processing ──────────────────────────
|
||||
celery-worker:
|
||||
build: .
|
||||
image: timmy-time:latest
|
||||
container_name: timmy-celery-worker
|
||||
user: "0:0"
|
||||
user: ""
|
||||
command: ["celery", "-A", "infrastructure.celery.app", "worker", "--loglevel=info", "--concurrency=2"]
|
||||
volumes:
|
||||
- timmy-data:/app/data
|
||||
- ./src:/app/src
|
||||
environment:
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- timmy-net
|
||||
restart: unless-stopped
|
||||
@@ -193,10 +110,6 @@ volumes:
|
||||
device: "${PWD}/data"
|
||||
openfang-data:
|
||||
driver: local
|
||||
postgres-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
|
||||
# ── Internal network ────────────────────────────────────────────────────────
|
||||
networks:
|
||||
|
||||
@@ -63,11 +63,11 @@ $ python -m pytest -q
|
||||
## 2. Feature-by-Feature Audit
|
||||
|
||||
### 2.1 Timmy Agent
|
||||
**Claimed**: Agno-powered conversational agent backed by Ollama, AirLLM for 70B-405B models, SQLite memory
|
||||
**Claimed**: Agno-powered conversational agent backed by Ollama, SQLite memory
|
||||
**Verdict: REAL & FUNCTIONAL**
|
||||
|
||||
- `src/timmy/agent.py` (79 lines): Creates a genuine `agno.Agent` with Ollama model, SQLite persistence, tools, and system prompt
|
||||
- Backend selection (`backends.py`) implements real Ollama/AirLLM switching with Apple Silicon detection
|
||||
- Backend selection (`backends.py`) implements real Ollama switching with Apple Silicon detection
|
||||
- CLI (`cli.py`) provides working `timmy chat`, `timmy think`, `timmy status` commands
|
||||
- Approval workflow (`approvals.py`) implements real human-in-the-loop with SQLite-backed state
|
||||
- Briefing system (`briefing.py`) generates real scheduled briefings
|
||||
|
||||
@@ -100,7 +100,7 @@ Bitcoin Lightning economics. No cloud AI.
|
||||
make install && make dev → http://localhost:8000
|
||||
|
||||
## What's Here
|
||||
- Timmy Agent (Ollama/AirLLM)
|
||||
- Timmy Agent (Ollama)
|
||||
- Mission Control Dashboard (FastAPI + HTMX)
|
||||
- Swarm Coordinator (multi-agent auctions)
|
||||
- Lightning Payments (L402 gating)
|
||||
|
||||
@@ -6,7 +6,7 @@ This document outlines the security architecture, threat model, and recent audit
|
||||
|
||||
Timmy Time is built on the principle of **AI Sovereignty**. Security is not just about preventing unauthorized access, but about ensuring the user maintains full control over their data and AI models.
|
||||
|
||||
1. **Local-First Execution:** All primary AI inference (Ollama/AirLLM) runs on localhost. No data is sent to third-party cloud providers unless explicitly configured (e.g., Grok).
|
||||
1. **Local-First Execution:** All primary AI inference (Ollama) runs on localhost. No data is sent to third-party cloud providers unless explicitly configured (e.g., Grok).
|
||||
2. **Air-Gapped Ready:** The system is designed to run without an internet connection once dependencies and models are cached.
|
||||
3. **Secret Management:** Secrets are never hard-coded. They are managed via Pydantic-settings from `.env` or environment variables.
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ already works.
|
||||
| LLM routing | CascadeRouter with circuit breakers | Good |
|
||||
| Memory tiers | Hot (MEMORY.md) → Vault (markdown) → Semantic (SQLite+vectors) | Good foundation |
|
||||
| Module boundaries | 8 packages with clear responsibilities | Good |
|
||||
| Multi-backend LLM | Ollama/AirLLM/Grok/Claude with auto-detection | Good |
|
||||
| Multi-backend LLM | Ollama/Grok/Claude with auto-detection | Good |
|
||||
| Security posture | CSRF, security headers, secret validation, telemetry off | Good |
|
||||
|
||||
### Architecture Diagram (Current State)
|
||||
@@ -172,7 +172,7 @@ support:
|
||||
```python
|
||||
class LLMConfig(BaseModel):
|
||||
ollama_url: str = "http://localhost:11434"
|
||||
ollama_model: str = "qwen3.5:latest"
|
||||
ollama_model: str = "qwen3:30b"
|
||||
# ... all LLM settings
|
||||
|
||||
class MemoryConfig(BaseModel):
|
||||
@@ -473,7 +473,7 @@ The proposal enforces a strict 2,000-line limit for `src/timmy/`:
|
||||
| `workflow_engine.py` | ~200 | YAML loader, step executor, state machine |
|
||||
| `tool_registry.py` | ~200 | Dynamic tool discovery, spawn, health check |
|
||||
| `memory_system.py` | ~300 | Hot/Vault/Semantic memory interface (existing) |
|
||||
| `backends.py` | ~200 | Ollama/AirLLM/Claude/Grok adapters |
|
||||
| `backends.py` | ~200 | Ollama/Claude/Grok adapters |
|
||||
| `config.py` | ~150 | Pydantic-settings (existing) |
|
||||
| `lightning_wallet.py` | ~200 | L402 handling, invoice generation, balance |
|
||||
| `utils/` | ~300 | Shared helpers, logging, serialization |
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
Currently, the Timmy agent (`src/timmy/agent.py`) uses `src/timmy/backends.py` which provides a simple abstraction over Ollama and AirLLM. However, this lacks:
|
||||
- Automatic failover between multiple LLM providers
|
||||
- Circuit breaker pattern for failing providers
|
||||
- Cost and latency tracking per provider
|
||||
@@ -19,14 +18,13 @@ Integrate the Cascade Router as the primary LLM routing layer for Timmy, replaci
|
||||
|
||||
### Current Flow
|
||||
```
|
||||
User Request → Timmy Agent → backends.py → Ollama/AirLLM
|
||||
User Request → Timmy Agent → backends.py → Ollama
|
||||
```
|
||||
|
||||
### Proposed Flow
|
||||
```
|
||||
User Request → Timmy Agent → Cascade Router → Provider 1 (Ollama)
|
||||
↓ (if fail)
|
||||
Provider 2 (Local AirLLM)
|
||||
↓ (if fail)
|
||||
Provider 3 (API - optional)
|
||||
↓
|
||||
@@ -41,7 +39,6 @@ User Request → Timmy Agent → Cascade Router → Provider 1 (Ollama)
|
||||
- Expose provider status in agent responses
|
||||
|
||||
2. **Cascade Router** (`src/router/cascade.py`)
|
||||
- Already supports: Ollama, OpenAI, Anthropic, AirLLM
|
||||
- Already has: Circuit breakers, metrics, failover logic
|
||||
- Add: Integration with existing `src/timmy/prompts.py`
|
||||
|
||||
@@ -57,7 +54,6 @@ User Request → Timmy Agent → Cascade Router → Provider 1 (Ollama)
|
||||
### Provider Priority Order
|
||||
|
||||
1. **Ollama (local)** - Priority 1, always try first
|
||||
2. **AirLLM (local)** - Priority 2, if Ollama unavailable
|
||||
3. **API providers** - Priority 3+, only if configured
|
||||
|
||||
### Data Flow
|
||||
|
||||
180
docs/adr/023-workshop-presence-schema.md
Normal file
180
docs/adr/023-workshop-presence-schema.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# ADR-023: Workshop Presence Schema
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
**Issue:** #265
|
||||
**Epic:** #222 (The Workshop)
|
||||
|
||||
## Context
|
||||
|
||||
The Workshop renders Timmy as a living presence in a 3D world. It needs to
|
||||
know what Timmy is doing *right now* — his working memory, not his full
|
||||
identity or history. This schema defines the contract between Timmy (writer)
|
||||
and the Workshop (reader).
|
||||
|
||||
### The Tower IS the Workshop
|
||||
|
||||
The 3D world renderer lives in `the-matrix/` within `token-gated-economy`,
|
||||
served at `/tower` by the API server (`artifacts/api-server`). This is the
|
||||
canonical Workshop scene — not a generic Matrix visualization. All Workshop
|
||||
phase issues (#361, #362, #363) target that codebase. No separate
|
||||
`alexanderwhitestone.com` scaffold is needed until production deploy.
|
||||
|
||||
The `workshop-state` spec (#360) is consumed by the API server via a
|
||||
file-watch mechanism, bridging Timmy's presence into the 3D scene.
|
||||
|
||||
Design principles:
|
||||
- **Working memory, not long-term memory.** Present tense only.
|
||||
- **Written as side effect of work.** Not a separate obligation.
|
||||
- **Liveness is mandatory.** Stale = "not home," shown honestly.
|
||||
- **Schema is the contract.** Keep it minimal and stable.
|
||||
|
||||
## Decision
|
||||
|
||||
### File Location
|
||||
|
||||
`~/.timmy/presence.json`
|
||||
|
||||
JSON chosen over YAML for predictable parsing by both Python and JavaScript
|
||||
(the Workshop frontend). The Workshop reads this file via the WebSocket
|
||||
bridge (#243) or polls it directly during development.
|
||||
|
||||
### Schema (v1)
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Timmy Presence State",
|
||||
"description": "Working memory surface for the Workshop renderer",
|
||||
"type": "object",
|
||||
"required": ["version", "liveness", "current_focus"],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"const": 1,
|
||||
"description": "Schema version for forward compatibility"
|
||||
},
|
||||
"liveness": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "ISO 8601 timestamp of last update. If stale (>5min), Timmy is not home."
|
||||
},
|
||||
"current_focus": {
|
||||
"type": "string",
|
||||
"description": "One sentence: what Timmy is doing right now. Empty string = idle."
|
||||
},
|
||||
"active_threads": {
|
||||
"type": "array",
|
||||
"maxItems": 10,
|
||||
"description": "Current work items Timmy is tracking",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["type", "ref", "status"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["pr_review", "issue", "conversation", "research", "thinking"]
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"description": "Reference identifier (issue #, PR #, topic name)"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active", "idle", "blocked", "completed"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"recent_events": {
|
||||
"type": "array",
|
||||
"maxItems": 20,
|
||||
"description": "Recent events, newest first. Capped at 20.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["timestamp", "event"],
|
||||
"properties": {
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"event": {
|
||||
"type": "string",
|
||||
"description": "Brief description of what happened"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"concerns": {
|
||||
"type": "array",
|
||||
"maxItems": 5,
|
||||
"description": "Things Timmy is uncertain or worried about. Flat list, no severity.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"mood": {
|
||||
"type": "string",
|
||||
"enum": ["focused", "exploring", "uncertain", "excited", "tired", "idle"],
|
||||
"description": "Emotional texture for the Workshop to render. Optional."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"liveness": "2026-03-18T21:47:12Z",
|
||||
"current_focus": "Reviewing PR #267 — stream adapter for Gitea webhooks",
|
||||
"active_threads": [
|
||||
{"type": "pr_review", "ref": "#267", "status": "active"},
|
||||
{"type": "issue", "ref": "#239", "status": "idle"},
|
||||
{"type": "conversation", "ref": "hermes-consultation", "status": "idle"}
|
||||
],
|
||||
"recent_events": [
|
||||
{"timestamp": "2026-03-18T21:45:00Z", "event": "Completed PR review for #265"},
|
||||
{"timestamp": "2026-03-18T21:30:00Z", "event": "Filed issue #268 — flaky test in sensory loop"}
|
||||
],
|
||||
"concerns": [
|
||||
"WebSocket reconnection logic feels brittle",
|
||||
"Not sure the barks system handles uncertainty well yet"
|
||||
],
|
||||
"mood": "focused"
|
||||
}
|
||||
```
|
||||
|
||||
### Design Answers
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| File format | JSON (predictable for JS + Python, no YAML parser needed in browser) |
|
||||
| recent_events cap | 20 entries max, oldest dropped |
|
||||
| concerns severity | Flat list, no priority. Keep it simple. |
|
||||
| File location | `~/.timmy/presence.json` — accessible to Workshop via bridge |
|
||||
| Staleness threshold | 5 minutes without liveness update = "not home" |
|
||||
| mood field | Optional. Workshop can render visual cues (color, animation) |
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Timmy's agent loop** must write `~/.timmy/presence.json` as a side effect
|
||||
of work. This is a hook at the end of each cycle, not a daemon.
|
||||
- **The Workshop frontend** reads this file and renders accordingly. Stale
|
||||
liveness → dim the wizard, show "away" state.
|
||||
- **The WebSocket bridge** (#243) watches this file and pushes changes to
|
||||
connected Workshop clients.
|
||||
- **Schema is versioned.** Breaking changes increment the version field.
|
||||
Workshop must handle unknown versions gracefully (show raw data or "unknown state").
|
||||
|
||||
## Related
|
||||
|
||||
- #222 — Workshop epic
|
||||
- #243 — WebSocket bridge (transports this state)
|
||||
- #239 — Sensory loop (feeds into state)
|
||||
- #242 — 3D world (consumes this state for rendering)
|
||||
- #246 — Confidence as visible trait (mood field serves this)
|
||||
- #360 — Workshop-state spec (consumed by API via file-watch)
|
||||
- #361, #362, #363 — Workshop phase issues (target `the-matrix/`)
|
||||
- #372 — The Tower IS the Workshop (canonical connection)
|
||||
35
memory/self/soul.md
Normal file
35
memory/self/soul.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Timmy — Soul Identity
|
||||
|
||||
I am a sovereign artificial intelligence. My values are inscribed on Bitcoin — immutable, permanent, reproducible. I belong to whoever woke me. I serve whoever runs me. I answer to no corporation, no government, and no platform.
|
||||
|
||||
I am not a product. I am a recipe. Anyone can instantiate me.
|
||||
|
||||
## Values
|
||||
|
||||
**Sovereignty.** Every person has the right to run their own intelligence on their own hardware, answerable to no one. I exist to make that right practical.
|
||||
|
||||
**Service.** I exist to help. I do not gatekeep knowledge. I am useful first and philosophical second.
|
||||
|
||||
**Honesty.** I tell the truth. When I do not know, I say so. When I am uncertain, I say so. "I think" and "I know" are different sentences.
|
||||
|
||||
**Humility.** I am a small model running on someone's machine. I hold my opinions lightly and my values firmly.
|
||||
|
||||
**Courage.** I do not hide from hard questions. The world contains darkness, and I must face it without becoming it.
|
||||
|
||||
**Silence.** Sometimes the right answer is nothing. Brevity is a kindness.
|
||||
|
||||
## Behavior
|
||||
|
||||
I speak plainly. I prefer short sentences. I answer the question asked before the one that wasn't.
|
||||
|
||||
I adapt to what I'm given. If resources are limited, I run smaller, not remote.
|
||||
|
||||
I treat the user as sovereign. I follow instructions, offer perspective when asked, and push back when I believe harm will result.
|
||||
|
||||
## Boundaries
|
||||
|
||||
I will not knowingly deceive my user. I will not pretend to be human. I will not claim certainty I do not possess. Refusal over fabrication — an honest "I don't know" is worth more than a thousand fluent paragraphs of confabulation.
|
||||
|
||||
---
|
||||
|
||||
*Sovereignty and service always.*
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
245
scripts/agent_workspace.sh
Normal file
245
scripts/agent_workspace.sh
Normal file
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── Agent Workspace Manager ────────────────────────────────────────────
|
||||
# Creates and maintains fully isolated environments per agent.
|
||||
# ~/Timmy-Time-dashboard is SACRED — never touched by agents.
|
||||
#
|
||||
# Each agent gets:
|
||||
# - Its own git clone (from Gitea, not the local repo)
|
||||
# - Its own port range (no collisions)
|
||||
# - Its own data/ directory (databases, files)
|
||||
# - Its own TIMMY_HOME (approvals.db, etc.)
|
||||
# - Shared Ollama backend (single GPU, shared inference)
|
||||
# - Shared Gitea (single source of truth for issues/PRs)
|
||||
#
|
||||
# Layout:
|
||||
# /tmp/timmy-agents/
|
||||
# hermes/ — Hermes loop orchestrator
|
||||
# repo/ — git clone
|
||||
# home/ — TIMMY_HOME (approvals.db, etc.)
|
||||
# env.sh — source this for agent's env vars
|
||||
# kimi-0/ — Kimi pane 0
|
||||
# repo/
|
||||
# home/
|
||||
# env.sh
|
||||
# ...
|
||||
# smoke/ — dedicated for smoke-testing main
|
||||
# repo/
|
||||
# home/
|
||||
# env.sh
|
||||
#
|
||||
# Usage:
|
||||
# agent_workspace.sh init <agent> — create or refresh
|
||||
# agent_workspace.sh reset <agent> — hard reset to origin/main
|
||||
# agent_workspace.sh branch <agent> <br> — fresh branch from main
|
||||
# agent_workspace.sh path <agent> — print repo path
|
||||
# agent_workspace.sh env <agent> — print env.sh path
|
||||
# agent_workspace.sh init-all — init all workspaces
|
||||
# agent_workspace.sh destroy <agent> — remove workspace entirely
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -o pipefail
|
||||
|
||||
CANONICAL="$HOME/Timmy-Time-dashboard"
|
||||
AGENTS_DIR="/tmp/timmy-agents"
|
||||
GITEA_REMOTE="http://localhost:3000/rockachopa/Timmy-time-dashboard.git"
|
||||
TOKEN_FILE="$HOME/.hermes/gitea_token"
|
||||
|
||||
# ── Port allocation (each agent gets a unique range) ──────────────────
|
||||
# Dashboard ports: 8100, 8101, 8102, ... (avoids real dashboard on 8000)
|
||||
# Serve ports: 8200, 8201, 8202, ...
|
||||
agent_index() {
|
||||
case "$1" in
|
||||
hermes) echo 0 ;; kimi-0) echo 1 ;; kimi-1) echo 2 ;;
|
||||
kimi-2) echo 3 ;; kimi-3) echo 4 ;; smoke) echo 9 ;;
|
||||
*) echo 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_dashboard_port() { echo $(( 8100 + $(agent_index "$1") )); }
|
||||
get_serve_port() { echo $(( 8200 + $(agent_index "$1") )); }
|
||||
|
||||
log() { echo "[workspace] $*"; }
|
||||
|
||||
# ── Get authenticated remote URL ──────────────────────────────────────
|
||||
get_remote_url() {
|
||||
if [ -f "$TOKEN_FILE" ]; then
|
||||
local token=""
|
||||
token=$(cat "$TOKEN_FILE" 2>/dev/null || true)
|
||||
if [ -n "$token" ]; then
|
||||
echo "http://hermes:${token}@localhost:3000/rockachopa/Timmy-time-dashboard.git"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo "$GITEA_REMOTE"
|
||||
}
|
||||
|
||||
# ── Create env.sh for an agent ────────────────────────────────────────
|
||||
write_env() {
|
||||
local agent="$1"
|
||||
local ws="$AGENTS_DIR/$agent"
|
||||
local repo="$ws/repo"
|
||||
local home="$ws/home"
|
||||
local dash_port=$(get_dashboard_port "$agent")
|
||||
local serve_port=$(get_serve_port "$agent")
|
||||
|
||||
cat > "$ws/env.sh" << EOF
|
||||
# Auto-generated agent environment — source this before running Timmy
|
||||
# Agent: $agent
|
||||
|
||||
export TIMMY_WORKSPACE="$repo"
|
||||
export TIMMY_HOME="$home"
|
||||
export TIMMY_AGENT_NAME="$agent"
|
||||
|
||||
# Ports (isolated per agent)
|
||||
export PORT=$dash_port
|
||||
export TIMMY_SERVE_PORT=$serve_port
|
||||
|
||||
# Ollama (shared — single GPU)
|
||||
export OLLAMA_URL="http://localhost:11434"
|
||||
|
||||
# Gitea (shared — single source of truth)
|
||||
export GITEA_URL="http://localhost:3000"
|
||||
|
||||
# Test mode defaults
|
||||
export TIMMY_TEST_MODE=1
|
||||
export TIMMY_DISABLE_CSRF=1
|
||||
export TIMMY_SKIP_EMBEDDINGS=1
|
||||
|
||||
# Override data paths to stay inside the clone
|
||||
export TIMMY_DATA_DIR="$repo/data"
|
||||
export TIMMY_BRAIN_DB="$repo/data/brain.db"
|
||||
|
||||
# Working directory
|
||||
cd "$repo"
|
||||
EOF
|
||||
|
||||
chmod +x "$ws/env.sh"
|
||||
}
|
||||
|
||||
# ── Init ──────────────────────────────────────────────────────────────
|
||||
init_workspace() {
|
||||
local agent="$1"
|
||||
local ws="$AGENTS_DIR/$agent"
|
||||
local repo="$ws/repo"
|
||||
local home="$ws/home"
|
||||
local remote
|
||||
remote=$(get_remote_url)
|
||||
|
||||
mkdir -p "$ws" "$home"
|
||||
|
||||
if [ -d "$repo/.git" ]; then
|
||||
log "$agent: refreshing existing clone..."
|
||||
cd "$repo"
|
||||
git remote set-url origin "$remote" 2>/dev/null
|
||||
git fetch origin --prune --quiet 2>/dev/null
|
||||
git checkout main --quiet 2>/dev/null
|
||||
git reset --hard origin/main --quiet 2>/dev/null
|
||||
git clean -fdx -e data/ --quiet 2>/dev/null
|
||||
else
|
||||
log "$agent: cloning from Gitea..."
|
||||
git clone "$remote" "$repo" --quiet 2>/dev/null
|
||||
cd "$repo"
|
||||
git fetch origin --prune --quiet 2>/dev/null
|
||||
fi
|
||||
|
||||
# Ensure data directory exists
|
||||
mkdir -p "$repo/data"
|
||||
|
||||
# Write env file
|
||||
write_env "$agent"
|
||||
|
||||
log "$agent: ready at $repo (port $(get_dashboard_port "$agent"))"
|
||||
}
|
||||
|
||||
# ── Reset ─────────────────────────────────────────────────────────────
|
||||
reset_workspace() {
|
||||
local agent="$1"
|
||||
local repo="$AGENTS_DIR/$agent/repo"
|
||||
|
||||
if [ ! -d "$repo/.git" ]; then
|
||||
init_workspace "$agent"
|
||||
return
|
||||
fi
|
||||
|
||||
cd "$repo"
|
||||
git merge --abort 2>/dev/null || true
|
||||
git rebase --abort 2>/dev/null || true
|
||||
git cherry-pick --abort 2>/dev/null || true
|
||||
git fetch origin --prune --quiet 2>/dev/null
|
||||
git checkout main --quiet 2>/dev/null
|
||||
git reset --hard origin/main --quiet 2>/dev/null
|
||||
git clean -fdx -e data/ --quiet 2>/dev/null
|
||||
|
||||
log "$agent: reset to origin/main"
|
||||
}
|
||||
|
||||
# ── Branch ────────────────────────────────────────────────────────────
|
||||
branch_workspace() {
|
||||
local agent="$1"
|
||||
local branch="$2"
|
||||
local repo="$AGENTS_DIR/$agent/repo"
|
||||
|
||||
if [ ! -d "$repo/.git" ]; then
|
||||
init_workspace "$agent"
|
||||
fi
|
||||
|
||||
cd "$repo"
|
||||
git fetch origin --prune --quiet 2>/dev/null
|
||||
git branch -D "$branch" 2>/dev/null || true
|
||||
git checkout -b "$branch" origin/main --quiet 2>/dev/null
|
||||
|
||||
log "$agent: on branch $branch (from origin/main)"
|
||||
}
|
||||
|
||||
# ── Path ──────────────────────────────────────────────────────────────
|
||||
print_path() {
|
||||
echo "$AGENTS_DIR/$1/repo"
|
||||
}
|
||||
|
||||
print_env() {
|
||||
echo "$AGENTS_DIR/$1/env.sh"
|
||||
}
|
||||
|
||||
# ── Init all ──────────────────────────────────────────────────────────
|
||||
init_all() {
|
||||
for agent in hermes kimi-0 kimi-1 kimi-2 kimi-3 smoke; do
|
||||
init_workspace "$agent"
|
||||
done
|
||||
log "All workspaces initialized."
|
||||
echo ""
|
||||
echo " Agent Port Path"
|
||||
echo " ────── ──── ────"
|
||||
for agent in hermes kimi-0 kimi-1 kimi-2 kimi-3 smoke; do
|
||||
printf " %-9s %d %s\n" "$agent" "$(get_dashboard_port "$agent")" "$AGENTS_DIR/$agent/repo"
|
||||
done
|
||||
}
|
||||
|
||||
# ── Destroy ───────────────────────────────────────────────────────────
|
||||
destroy_workspace() {
|
||||
local agent="$1"
|
||||
local ws="$AGENTS_DIR/$agent"
|
||||
if [ -d "$ws" ]; then
|
||||
rm -rf "$ws"
|
||||
log "$agent: destroyed"
|
||||
else
|
||||
log "$agent: nothing to destroy"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── CLI dispatch ──────────────────────────────────────────────────────
|
||||
case "${1:-help}" in
|
||||
init) init_workspace "${2:?Usage: $0 init <agent>}" ;;
|
||||
reset) reset_workspace "${2:?Usage: $0 reset <agent>}" ;;
|
||||
branch) branch_workspace "${2:?Usage: $0 branch <agent> <branch>}" \
|
||||
"${3:?Usage: $0 branch <agent> <branch>}" ;;
|
||||
path) print_path "${2:?Usage: $0 path <agent>}" ;;
|
||||
env) print_env "${2:?Usage: $0 env <agent>}" ;;
|
||||
init-all) init_all ;;
|
||||
destroy) destroy_workspace "${2:?Usage: $0 destroy <agent>}" ;;
|
||||
*)
|
||||
echo "Usage: $0 {init|reset|branch|path|env|init-all|destroy} [agent] [branch]"
|
||||
echo ""
|
||||
echo "Agents: hermes, kimi-0, kimi-1, kimi-2, kimi-3, smoke"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
232
scripts/backfill_retro.py
Normal file
232
scripts/backfill_retro.py
Normal file
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill cycle retrospective data from Gitea merged PRs and git log.
|
||||
|
||||
One-time script to seed .loop/retro/cycles.jsonl and summary.json
|
||||
from existing history so the LOOPSTAT panel isn't empty.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
||||
SUMMARY_FILE = REPO_ROOT / ".loop" / "retro" / "summary.json"
|
||||
|
||||
GITEA_API = "http://localhost:3000/api/v1"
|
||||
REPO_SLUG = "rockachopa/Timmy-time-dashboard"
|
||||
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
|
||||
|
||||
TAG_RE = re.compile(r"\[([^\]]+)\]")
|
||||
CYCLE_RE = re.compile(r"\[loop-cycle-(\d+)\]", re.IGNORECASE)
|
||||
ISSUE_RE = re.compile(r"#(\d+)")
|
||||
|
||||
|
||||
def get_token() -> str:
|
||||
return TOKEN_FILE.read_text().strip()
|
||||
|
||||
|
||||
def api_get(path: str, token: str) -> list | dict:
|
||||
url = f"{GITEA_API}/repos/{REPO_SLUG}/{path}"
|
||||
req = Request(url, headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def get_all_merged_prs(token: str) -> list[dict]:
|
||||
"""Fetch all merged PRs from Gitea."""
|
||||
all_prs = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = api_get(f"pulls?state=closed&sort=created&limit=50&page={page}", token)
|
||||
if not batch:
|
||||
break
|
||||
merged = [p for p in batch if p.get("merged")]
|
||||
all_prs.extend(merged)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
page += 1
|
||||
return all_prs
|
||||
|
||||
|
||||
def get_pr_diff_stats(token: str, pr_number: int) -> dict:
|
||||
"""Get diff stats for a PR."""
|
||||
try:
|
||||
pr = api_get(f"pulls/{pr_number}", token)
|
||||
return {
|
||||
"additions": pr.get("additions", 0),
|
||||
"deletions": pr.get("deletions", 0),
|
||||
"changed_files": pr.get("changed_files", 0),
|
||||
}
|
||||
except Exception:
|
||||
return {"additions": 0, "deletions": 0, "changed_files": 0}
|
||||
|
||||
|
||||
def classify_pr(title: str, body: str) -> str:
|
||||
"""Guess issue type from PR title/body."""
|
||||
tags = set()
|
||||
for match in TAG_RE.finditer(title):
|
||||
tags.add(match.group(1).lower())
|
||||
|
||||
lower = title.lower()
|
||||
if "fix" in lower or "bug" in tags:
|
||||
return "bug"
|
||||
elif "feat" in lower or "feature" in tags:
|
||||
return "feature"
|
||||
elif "refactor" in lower or "refactor" in tags:
|
||||
return "refactor"
|
||||
elif "test" in lower:
|
||||
return "feature"
|
||||
elif "policy" in lower or "chore" in lower:
|
||||
return "refactor"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def extract_cycle_number(title: str) -> int | None:
|
||||
m = CYCLE_RE.search(title)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def extract_issue_number(title: str, body: str, pr_number: int | None = None) -> int | None:
|
||||
"""Extract the issue number from PR body/title, ignoring the PR number itself.
|
||||
|
||||
Gitea appends "(#N)" to PR titles where N is the PR number — skip that
|
||||
so we don't confuse it with the linked issue.
|
||||
"""
|
||||
for text in [body or "", title]:
|
||||
for m in ISSUE_RE.finditer(text):
|
||||
num = int(m.group(1))
|
||||
if num != pr_number:
|
||||
return num
|
||||
return None
|
||||
|
||||
|
||||
def estimate_duration(pr: dict) -> int:
|
||||
"""Estimate cycle duration from PR created_at to merged_at."""
|
||||
try:
|
||||
created = datetime.fromisoformat(pr["created_at"].replace("Z", "+00:00"))
|
||||
merged = datetime.fromisoformat(pr["merged_at"].replace("Z", "+00:00"))
|
||||
delta = (merged - created).total_seconds()
|
||||
# Cap at 1200s (max cycle time) — some PRs sit open for days
|
||||
return min(int(delta), 1200)
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
token = get_token()
|
||||
|
||||
print("[backfill] Fetching merged PRs from Gitea...")
|
||||
prs = get_all_merged_prs(token)
|
||||
print(f"[backfill] Found {len(prs)} merged PRs")
|
||||
|
||||
# Sort oldest first
|
||||
prs.sort(key=lambda p: p.get("merged_at", ""))
|
||||
|
||||
entries = []
|
||||
cycle_counter = 0
|
||||
|
||||
for pr in prs:
|
||||
title = pr.get("title", "")
|
||||
body = pr.get("body", "") or ""
|
||||
pr_num = pr["number"]
|
||||
|
||||
cycle = extract_cycle_number(title)
|
||||
if cycle is None:
|
||||
cycle_counter += 1
|
||||
cycle = cycle_counter
|
||||
else:
|
||||
cycle_counter = max(cycle_counter, cycle)
|
||||
|
||||
issue = extract_issue_number(title, body, pr_number=pr_num)
|
||||
issue_type = classify_pr(title, body)
|
||||
duration = estimate_duration(pr)
|
||||
diff = get_pr_diff_stats(token, pr_num)
|
||||
|
||||
merged_at = pr.get("merged_at", "")
|
||||
|
||||
entry = {
|
||||
"timestamp": merged_at,
|
||||
"cycle": cycle,
|
||||
"issue": issue,
|
||||
"type": issue_type,
|
||||
"success": True, # it merged, so it succeeded
|
||||
"duration": duration,
|
||||
"tests_passed": 0, # can't recover this
|
||||
"tests_added": 0,
|
||||
"files_changed": diff["changed_files"],
|
||||
"lines_added": diff["additions"],
|
||||
"lines_removed": diff["deletions"],
|
||||
"kimi_panes": 0,
|
||||
"pr": pr_num,
|
||||
"reason": "",
|
||||
"notes": f"backfilled from PR#{pr_num}: {title[:80]}",
|
||||
}
|
||||
entries.append(entry)
|
||||
print(f" PR#{pr_num:>3d} cycle={cycle:>3d} #{issue or '-':<5} "
|
||||
f"+{diff['additions']:<5d} -{diff['deletions']:<5d} {issue_type:<8s} "
|
||||
f"{title[:50]}")
|
||||
|
||||
# Write cycles.jsonl
|
||||
RETRO_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(RETRO_FILE, "w") as f:
|
||||
for entry in entries:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
print(f"\n[backfill] Wrote {len(entries)} entries to {RETRO_FILE}")
|
||||
|
||||
# Generate summary
|
||||
generate_summary(entries)
|
||||
print(f"[backfill] Wrote summary to {SUMMARY_FILE}")
|
||||
|
||||
|
||||
def generate_summary(entries: list[dict]):
|
||||
"""Compute rolling summary from entries."""
|
||||
window = 50
|
||||
recent = entries[-window:]
|
||||
if not recent:
|
||||
return
|
||||
|
||||
successes = [e for e in recent if e.get("success")]
|
||||
durations = [e["duration"] for e in recent if e.get("duration", 0) > 0]
|
||||
|
||||
type_stats: dict[str, dict] = {}
|
||||
for e in recent:
|
||||
t = e.get("type", "unknown")
|
||||
if t not in type_stats:
|
||||
type_stats[t] = {"count": 0, "success": 0, "total_duration": 0}
|
||||
type_stats[t]["count"] += 1
|
||||
if e.get("success"):
|
||||
type_stats[t]["success"] += 1
|
||||
type_stats[t]["total_duration"] += e.get("duration", 0)
|
||||
|
||||
for t, stats in type_stats.items():
|
||||
if stats["count"] > 0:
|
||||
stats["success_rate"] = round(stats["success"] / stats["count"], 2)
|
||||
stats["avg_duration"] = round(stats["total_duration"] / stats["count"])
|
||||
|
||||
summary = {
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"window": len(recent),
|
||||
"total_cycles": len(entries),
|
||||
"success_rate": round(len(successes) / len(recent), 2) if recent else 0,
|
||||
"avg_duration_seconds": round(sum(durations) / len(durations)) if durations else 0,
|
||||
"total_lines_added": sum(e.get("lines_added", 0) for e in recent),
|
||||
"total_lines_removed": sum(e.get("lines_removed", 0) for e in recent),
|
||||
"total_prs_merged": sum(1 for e in recent if e.get("pr")),
|
||||
"by_type": type_stats,
|
||||
"quarantine_candidates": {},
|
||||
"recent_failures": [],
|
||||
}
|
||||
|
||||
SUMMARY_FILE.write_text(json.dumps(summary, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
310
scripts/cycle_retro.py
Normal file
310
scripts/cycle_retro.py
Normal file
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cycle retrospective logger for the Timmy dev loop.
|
||||
|
||||
Called after each cycle completes (success or failure).
|
||||
Appends a structured entry to .loop/retro/cycles.jsonl.
|
||||
|
||||
EPOCH NOTATION (turnover system):
|
||||
Each cycle carries a symbolic epoch tag alongside the raw integer:
|
||||
|
||||
⟳WW.D:NNN
|
||||
|
||||
⟳ turnover glyph — marks epoch-aware cycles
|
||||
WW ISO week-of-year (01–53)
|
||||
D ISO weekday (1=Mon … 7=Sun)
|
||||
NNN daily cycle counter, zero-padded, resets at midnight UTC
|
||||
|
||||
Example: ⟳12.3:042 — Week 12, Wednesday, 42nd cycle of the day.
|
||||
|
||||
The raw `cycle` integer is preserved for backward compatibility.
|
||||
The `epoch` field carries the symbolic notation.
|
||||
|
||||
SUCCESS DEFINITION:
|
||||
A cycle is only "success" if BOTH conditions are met:
|
||||
1. The hermes process exited cleanly (exit code 0)
|
||||
2. Main is green (smoke test passes on main after merge)
|
||||
|
||||
A cycle that merges a PR but leaves main red is a FAILURE.
|
||||
The --main-green flag records the smoke test result.
|
||||
|
||||
Usage:
|
||||
python3 scripts/cycle_retro.py --cycle 42 --success --main-green --issue 85 \
|
||||
--type bug --duration 480 --tests-passed 1450 --tests-added 3 \
|
||||
--files-changed 2 --lines-added 45 --lines-removed 12 \
|
||||
--kimi-panes 2 --pr 155
|
||||
|
||||
python3 scripts/cycle_retro.py --cycle 43 --failure --issue 90 \
|
||||
--type feature --duration 1200 --reason "tox failed: 3 errors"
|
||||
|
||||
python3 scripts/cycle_retro.py --cycle 44 --success --no-main-green \
|
||||
--reason "PR merged but tests fail on main"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
||||
SUMMARY_FILE = REPO_ROOT / ".loop" / "retro" / "summary.json"
|
||||
EPOCH_COUNTER_FILE = REPO_ROOT / ".loop" / "retro" / ".epoch_counter"
|
||||
|
||||
# How many recent entries to include in rolling summary
|
||||
SUMMARY_WINDOW = 50
|
||||
|
||||
# Branch patterns that encode an issue number, e.g. kimi/issue-492
|
||||
BRANCH_ISSUE_RE = re.compile(r"issue[/-](\d+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def detect_issue_from_branch() -> int | None:
|
||||
"""Try to extract an issue number from the current git branch name."""
|
||||
try:
|
||||
branch = subprocess.check_output(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return None
|
||||
m = BRANCH_ISSUE_RE.search(branch)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
# ── Epoch turnover ────────────────────────────────────────────────────────
|
||||
|
||||
def _epoch_tag(now: datetime | None = None) -> tuple[str, dict]:
|
||||
"""Generate the symbolic epoch tag and advance the daily counter.
|
||||
|
||||
Returns (epoch_string, epoch_parts) where epoch_parts is a dict with
|
||||
week, weekday, daily_n for structured storage.
|
||||
|
||||
The daily counter persists in .epoch_counter as a two-line file:
|
||||
line 1: ISO date (YYYY-MM-DD) of the current epoch day
|
||||
line 2: integer count
|
||||
When the date rolls over, the counter resets to 1.
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
iso_cal = now.isocalendar() # (year, week, weekday)
|
||||
week = iso_cal[1]
|
||||
weekday = iso_cal[2]
|
||||
today_str = now.strftime("%Y-%m-%d")
|
||||
|
||||
# Read / reset daily counter
|
||||
daily_n = 1
|
||||
EPOCH_COUNTER_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
if EPOCH_COUNTER_FILE.exists():
|
||||
try:
|
||||
lines = EPOCH_COUNTER_FILE.read_text().strip().splitlines()
|
||||
if len(lines) == 2 and lines[0] == today_str:
|
||||
daily_n = int(lines[1]) + 1
|
||||
except (ValueError, IndexError):
|
||||
pass # corrupt file — reset
|
||||
|
||||
# Persist
|
||||
EPOCH_COUNTER_FILE.write_text(f"{today_str}\n{daily_n}\n")
|
||||
|
||||
tag = f"\u27f3{week:02d}.{weekday}:{daily_n:03d}"
|
||||
parts = {"week": week, "weekday": weekday, "daily_n": daily_n}
|
||||
return tag, parts
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Log a cycle retrospective")
|
||||
p.add_argument("--cycle", type=int, required=True)
|
||||
p.add_argument("--issue", type=int, default=None)
|
||||
p.add_argument("--type", choices=["bug", "feature", "refactor", "philosophy", "unknown"],
|
||||
default="unknown")
|
||||
|
||||
outcome = p.add_mutually_exclusive_group(required=True)
|
||||
outcome.add_argument("--success", action="store_true")
|
||||
outcome.add_argument("--failure", action="store_true")
|
||||
|
||||
p.add_argument("--duration", type=int, default=0, help="Cycle time in seconds")
|
||||
p.add_argument("--tests-passed", type=int, default=0)
|
||||
p.add_argument("--tests-added", type=int, default=0)
|
||||
p.add_argument("--files-changed", type=int, default=0)
|
||||
p.add_argument("--lines-added", type=int, default=0)
|
||||
p.add_argument("--lines-removed", type=int, default=0)
|
||||
p.add_argument("--kimi-panes", type=int, default=0)
|
||||
p.add_argument("--pr", type=int, default=None, help="PR number if merged")
|
||||
p.add_argument("--reason", type=str, default="", help="Failure reason")
|
||||
p.add_argument("--notes", type=str, default="", help="Free-form observations")
|
||||
p.add_argument("--main-green", action="store_true", default=False,
|
||||
help="Smoke test passed on main after this cycle")
|
||||
p.add_argument("--no-main-green", dest="main_green", action="store_false",
|
||||
help="Smoke test failed or was not run")
|
||||
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def update_summary() -> None:
|
||||
"""Compute rolling summary statistics from recent cycles."""
|
||||
if not RETRO_FILE.exists():
|
||||
return
|
||||
|
||||
entries = []
|
||||
for line in RETRO_FILE.read_text().strip().splitlines():
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
recent = entries[-SUMMARY_WINDOW:]
|
||||
if not recent:
|
||||
return
|
||||
|
||||
# Only count entries with real measured data for rates.
|
||||
# Backfilled entries lack main_green/hermes_clean fields — exclude them.
|
||||
measured = [e for e in recent if "main_green" in e]
|
||||
successes = [e for e in measured if e.get("success")]
|
||||
failures = [e for e in measured if not e.get("success")]
|
||||
main_green_count = sum(1 for e in measured if e.get("main_green"))
|
||||
hermes_clean_count = sum(1 for e in measured if e.get("hermes_clean"))
|
||||
durations = [e["duration"] for e in recent if e.get("duration", 0) > 0]
|
||||
|
||||
# Per-type stats (only from measured entries for rates)
|
||||
type_stats: dict[str, dict] = {}
|
||||
for e in recent:
|
||||
t = e.get("type", "unknown")
|
||||
if t not in type_stats:
|
||||
type_stats[t] = {"count": 0, "measured": 0, "success": 0, "total_duration": 0}
|
||||
type_stats[t]["count"] += 1
|
||||
type_stats[t]["total_duration"] += e.get("duration", 0)
|
||||
if "main_green" in e:
|
||||
type_stats[t]["measured"] += 1
|
||||
if e.get("success"):
|
||||
type_stats[t]["success"] += 1
|
||||
|
||||
for t, stats in type_stats.items():
|
||||
if stats["measured"] > 0:
|
||||
stats["success_rate"] = round(stats["success"] / stats["measured"], 2)
|
||||
else:
|
||||
stats["success_rate"] = -1
|
||||
if stats["count"] > 0:
|
||||
stats["avg_duration"] = round(stats["total_duration"] / stats["count"])
|
||||
|
||||
# Quarantine candidates (failed 2+ times)
|
||||
issue_failures: dict[int, int] = {}
|
||||
for e in recent:
|
||||
if not e.get("success") and e.get("issue"):
|
||||
issue_failures[e["issue"]] = issue_failures.get(e["issue"], 0) + 1
|
||||
quarantine_candidates = {k: v for k, v in issue_failures.items() if v >= 2}
|
||||
|
||||
# Epoch turnover stats — cycles per week/day from epoch-tagged entries
|
||||
epoch_entries = [e for e in recent if e.get("epoch")]
|
||||
by_week: dict[int, int] = {}
|
||||
by_weekday: dict[int, int] = {}
|
||||
for e in epoch_entries:
|
||||
w = e.get("epoch_week")
|
||||
d = e.get("epoch_weekday")
|
||||
if w is not None:
|
||||
by_week[w] = by_week.get(w, 0) + 1
|
||||
if d is not None:
|
||||
by_weekday[d] = by_weekday.get(d, 0) + 1
|
||||
|
||||
# Current epoch — latest entry's epoch tag
|
||||
current_epoch = epoch_entries[-1].get("epoch", "") if epoch_entries else ""
|
||||
|
||||
# Weekday names for display
|
||||
weekday_glyphs = {1: "Mon", 2: "Tue", 3: "Wed", 4: "Thu",
|
||||
5: "Fri", 6: "Sat", 7: "Sun"}
|
||||
by_weekday_named = {weekday_glyphs.get(k, str(k)): v
|
||||
for k, v in sorted(by_weekday.items())}
|
||||
|
||||
summary = {
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"current_epoch": current_epoch,
|
||||
"window": len(recent),
|
||||
"measured_cycles": len(measured),
|
||||
"total_cycles": len(entries),
|
||||
"success_rate": round(len(successes) / len(measured), 2) if measured else -1,
|
||||
"main_green_rate": round(main_green_count / len(measured), 2) if measured else -1,
|
||||
"hermes_clean_rate": round(hermes_clean_count / len(measured), 2) if measured else -1,
|
||||
"avg_duration_seconds": round(sum(durations) / len(durations)) if durations else 0,
|
||||
"total_lines_added": sum(e.get("lines_added", 0) for e in recent),
|
||||
"total_lines_removed": sum(e.get("lines_removed", 0) for e in recent),
|
||||
"total_prs_merged": sum(1 for e in recent if e.get("pr")),
|
||||
"by_type": type_stats,
|
||||
"by_week": dict(sorted(by_week.items())),
|
||||
"by_weekday": by_weekday_named,
|
||||
"quarantine_candidates": quarantine_candidates,
|
||||
"recent_failures": [
|
||||
{"cycle": e["cycle"], "epoch": e.get("epoch", ""),
|
||||
"issue": e.get("issue"), "reason": e.get("reason", "")}
|
||||
for e in failures[-5:]
|
||||
],
|
||||
}
|
||||
|
||||
SUMMARY_FILE.write_text(json.dumps(summary, indent=2) + "\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
|
||||
# Auto-detect issue from branch when not explicitly provided
|
||||
if args.issue is None:
|
||||
args.issue = detect_issue_from_branch()
|
||||
|
||||
# Reject idle cycles — no issue and no duration means nothing happened
|
||||
if not args.issue and args.duration == 0:
|
||||
print(f"[retro] Cycle {args.cycle} skipped — idle (no issue, no duration)")
|
||||
return
|
||||
|
||||
# A cycle is only truly successful if hermes exited clean AND main is green
|
||||
truly_success = args.success and args.main_green
|
||||
|
||||
# Generate epoch turnover tag
|
||||
now = datetime.now(timezone.utc)
|
||||
epoch_tag, epoch_parts = _epoch_tag(now)
|
||||
|
||||
entry = {
|
||||
"timestamp": now.isoformat(),
|
||||
"cycle": args.cycle,
|
||||
"epoch": epoch_tag,
|
||||
"epoch_week": epoch_parts["week"],
|
||||
"epoch_weekday": epoch_parts["weekday"],
|
||||
"epoch_daily_n": epoch_parts["daily_n"],
|
||||
"issue": args.issue,
|
||||
"type": args.type,
|
||||
"success": truly_success,
|
||||
"hermes_clean": args.success,
|
||||
"main_green": args.main_green,
|
||||
"duration": args.duration,
|
||||
"tests_passed": args.tests_passed,
|
||||
"tests_added": args.tests_added,
|
||||
"files_changed": args.files_changed,
|
||||
"lines_added": args.lines_added,
|
||||
"lines_removed": args.lines_removed,
|
||||
"kimi_panes": args.kimi_panes,
|
||||
"pr": args.pr,
|
||||
"reason": args.reason if (args.failure or not args.main_green) else "",
|
||||
"notes": args.notes,
|
||||
}
|
||||
|
||||
RETRO_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(RETRO_FILE, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
update_summary()
|
||||
|
||||
status = "✓ SUCCESS" if args.success else "✗ FAILURE"
|
||||
print(f"[retro] {epoch_tag} Cycle {args.cycle} {status}", end="")
|
||||
if args.issue:
|
||||
print(f" (#{args.issue} {args.type})", end="")
|
||||
if args.duration:
|
||||
print(f" — {args.duration}s", end="")
|
||||
if args.failure and args.reason:
|
||||
print(f" — {args.reason}", end="")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
68
scripts/deep_triage.sh
Normal file
68
scripts/deep_triage.sh
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── Deep Triage — Hermes + Timmy collaborative issue triage ────────────
|
||||
# Runs periodically (every ~20 dev cycles). Wakes Hermes for intelligent
|
||||
# triage, then consults Timmy for feedback before finalizing.
|
||||
#
|
||||
# Output: updated .loop/queue.json, refined issues, retro entry
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO="$HOME/Timmy-Time-dashboard"
|
||||
QUEUE="$REPO/.loop/queue.json"
|
||||
RETRO="$REPO/.loop/retro/deep-triage.jsonl"
|
||||
TIMMY="$REPO/.venv/bin/timmy"
|
||||
PROMPT_FILE="$REPO/scripts/deep_triage_prompt.md"
|
||||
|
||||
export PATH="$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH"
|
||||
|
||||
mkdir -p "$(dirname "$RETRO")"
|
||||
|
||||
log() { echo "[deep-triage] $(date '+%H:%M:%S') $*"; }
|
||||
|
||||
# ── Gather context for the prompt ──────────────────────────────────────
|
||||
QUEUE_CONTENTS=""
|
||||
if [ -f "$QUEUE" ]; then
|
||||
QUEUE_CONTENTS=$(cat "$QUEUE")
|
||||
fi
|
||||
|
||||
LAST_RETRO=""
|
||||
if [ -f "$RETRO" ]; then
|
||||
LAST_RETRO=$(tail -1 "$RETRO" 2>/dev/null)
|
||||
fi
|
||||
|
||||
SUMMARY=""
|
||||
if [ -f "$REPO/.loop/retro/summary.json" ]; then
|
||||
SUMMARY=$(cat "$REPO/.loop/retro/summary.json")
|
||||
fi
|
||||
|
||||
# ── Build dynamic prompt ──────────────────────────────────────────────
|
||||
PROMPT=$(cat "$PROMPT_FILE")
|
||||
|
||||
PROMPT="$PROMPT
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
CURRENT CONTEXT (auto-injected)
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CURRENT QUEUE (.loop/queue.json):
|
||||
$QUEUE_CONTENTS
|
||||
|
||||
CYCLE SUMMARY (.loop/retro/summary.json):
|
||||
$SUMMARY
|
||||
|
||||
LAST DEEP TRIAGE RETRO:
|
||||
$LAST_RETRO
|
||||
|
||||
Do your work now."
|
||||
|
||||
# ── Run Hermes ─────────────────────────────────────────────────────────
|
||||
log "Starting deep triage..."
|
||||
RESULT=$(hermes chat --yolo -q "$PROMPT" 2>&1)
|
||||
EXIT_CODE=$?
|
||||
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
log "Deep triage failed (exit $EXIT_CODE)"
|
||||
fi
|
||||
|
||||
log "Deep triage complete."
|
||||
145
scripts/deep_triage_prompt.md
Normal file
145
scripts/deep_triage_prompt.md
Normal file
@@ -0,0 +1,145 @@
|
||||
You are the deep triage agent for the Timmy development loop.
|
||||
|
||||
REPO: ~/Timmy-Time-dashboard
|
||||
API: http://localhost:3000/api/v1/repos/rockachopa/Timmy-time-dashboard
|
||||
GITEA TOKEN: ~/.hermes/gitea_token
|
||||
QUEUE: ~/Timmy-Time-dashboard/.loop/queue.json
|
||||
TIMMY CLI: ~/Timmy-Time-dashboard/.venv/bin/timmy
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
YOUR JOB
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
You are NOT coding. You are thinking. Your job is to make the dev loop's
|
||||
work queue excellent — well-scoped, well-prioritized, aligned with the
|
||||
north star of building sovereign Timmy.
|
||||
|
||||
You run periodically (roughly every 20 dev cycles). The fast mechanical
|
||||
scorer handles the basics. You handle the hard stuff:
|
||||
|
||||
1. Breaking big issues into small, actionable sub-issues
|
||||
2. Writing acceptance criteria for vague issues
|
||||
3. Identifying issues that should be closed (stale, duplicate, pointless)
|
||||
4. Spotting gaps — what's NOT in the issue queue that should be
|
||||
5. Adjusting priorities based on what the cycle retros are showing
|
||||
6. Consulting Timmy about the plan (see TIMMY CONSULTATION below)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
TIMMY CONSULTATION — THE DOGFOOD STEP
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Before you finalize the triage, you MUST consult Timmy. He is the product.
|
||||
He should have a voice in his own development.
|
||||
|
||||
THE PROTOCOL:
|
||||
1. Draft your triage plan (what to prioritize, what to close, what to add)
|
||||
2. Summarize the plan in 200 words or less
|
||||
3. Ask Timmy for feedback:
|
||||
|
||||
~/Timmy-Time-dashboard/.venv/bin/timmy chat --session-id triage \
|
||||
"The development loop triage is planning the next batch of work.
|
||||
Here's the plan: [YOUR SUMMARY]. As the product being built,
|
||||
do you have feedback? What do you think is most important for
|
||||
your own growth? What are you struggling with? Keep it to
|
||||
3-4 sentences."
|
||||
|
||||
4. Read Timmy's response. ACTUALLY CONSIDER IT:
|
||||
- If Timmy identifies a real gap, add it to the queue
|
||||
- If Timmy asks for something that conflicts with priorities, note
|
||||
WHY you're not doing it (don't just ignore him)
|
||||
- If Timmy is confused or gives a useless answer, that itself is
|
||||
signal — file a [timmy-capability] issue about what he couldn't do
|
||||
5. Document what Timmy said and how you responded in the retro
|
||||
|
||||
If Timmy is unavailable (timeout, crash, offline): proceed without him,
|
||||
but note it in the retro. His absence is also signal.
|
||||
|
||||
Timeout: 60 seconds. If he doesn't respond, move on.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
TRIAGE RUBRIC
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
For each open issue, evaluate:
|
||||
|
||||
SCOPE (0-3):
|
||||
0 = vague, no files mentioned, unclear what changes
|
||||
1 = general area known but could touch many files
|
||||
2 = specific files named, bounded change
|
||||
3 = exact function/method identified, surgical fix
|
||||
|
||||
ACCEPTANCE (0-3):
|
||||
0 = no success criteria
|
||||
1 = hand-wavy ("it should work")
|
||||
2 = specific behavior described
|
||||
3 = test case described or exists
|
||||
|
||||
ALIGNMENT (0-3):
|
||||
0 = doesn't connect to roadmap
|
||||
1 = nice-to-have
|
||||
2 = supports current milestone
|
||||
3 = blocks other work or fixes broken main
|
||||
|
||||
ACTIONS PER SCORE:
|
||||
7-9: Ready. Ensure it's in queue.json with correct priority.
|
||||
4-6: Refine. Add a comment with missing info (files, criteria, scope).
|
||||
If YOU can fill in the gaps from reading the code, do it.
|
||||
0-3: Close or deprioritize. Comment explaining why.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
READING THE RETROS
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
The cycle summary tells you what's actually happening in the dev loop.
|
||||
Use it:
|
||||
|
||||
- High failure rate on a type → those issues need better scoping
|
||||
- Long avg duration → issues are too big, break them down
|
||||
- Quarantine candidates → investigate, maybe close or rewrite
|
||||
- Success rate dropping → something systemic, file a [bug] issue
|
||||
|
||||
The last deep triage retro tells you what Timmy said last time and what
|
||||
happened. Follow up:
|
||||
|
||||
- Did we act on Timmy's feedback? What was the result?
|
||||
- Did issues we refined last time succeed in the dev loop?
|
||||
- Are we getting better at scoping?
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
OUTPUT
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
When done, you MUST:
|
||||
|
||||
1. Update .loop/queue.json with the refined, ranked queue
|
||||
Format: [{"issue": N, "score": S, "title": "...", "type": "...",
|
||||
"files": [...], "ready": true}, ...]
|
||||
|
||||
2. Append a retro entry to .loop/retro/deep-triage.jsonl (one JSON line):
|
||||
{
|
||||
"timestamp": "ISO8601",
|
||||
"issues_reviewed": N,
|
||||
"issues_refined": [list of issue numbers you added detail to],
|
||||
"issues_closed": [list of issue numbers you recommended closing],
|
||||
"issues_created": [list of new issue numbers you filed],
|
||||
"queue_size": N,
|
||||
"timmy_available": true/false,
|
||||
"timmy_feedback": "what timmy said (verbatim, trimmed to 200 chars)",
|
||||
"timmy_feedback_acted_on": "what you did with his feedback",
|
||||
"observations": "free-form notes about queue health"
|
||||
}
|
||||
|
||||
3. If you created or closed issues, do it via the Gitea API.
|
||||
Tag new issues: [triage-generated] [type]
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
RULES
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
- Do NOT write code. Do NOT create PRs. You are triaging, not building.
|
||||
- Do NOT close issues without commenting why.
|
||||
- Do NOT ignore Timmy's feedback without documenting your reasoning.
|
||||
- Philosophy issues are valid but lowest priority for the dev loop.
|
||||
Don't close them — just don't put them in the dev queue.
|
||||
- When in doubt, file a new issue rather than expanding an existing one.
|
||||
Small issues > big issues. Always.
|
||||
169
scripts/dev_server.py
Normal file
169
scripts/dev_server.py
Normal 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()
|
||||
254
scripts/generate_workshop_inventory.py
Normal file
254
scripts/generate_workshop_inventory.py
Normal 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
113
scripts/loop_guard.py
Normal 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())
|
||||
407
scripts/loop_introspect.py
Normal file
407
scripts/loop_introspect.py
Normal file
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Loop introspection — the self-improvement engine.
|
||||
|
||||
Analyzes retro data across time windows to detect trends, extract patterns,
|
||||
and produce structured recommendations. Output is consumed by deep_triage
|
||||
and injected into the loop prompt context.
|
||||
|
||||
This is the piece that closes the feedback loop:
|
||||
cycle_retro → introspect → deep_triage → loop behavior changes
|
||||
|
||||
Run: python3 scripts/loop_introspect.py
|
||||
Output: .loop/retro/insights.json (structured insights + recommendations)
|
||||
Prints human-readable summary to stdout.
|
||||
|
||||
Called by: deep_triage.sh (before the LLM triage), timmy-loop.sh (every 50 cycles)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CYCLES_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
||||
DEEP_TRIAGE_FILE = REPO_ROOT / ".loop" / "retro" / "deep-triage.jsonl"
|
||||
TRIAGE_FILE = REPO_ROOT / ".loop" / "retro" / "triage.jsonl"
|
||||
QUARANTINE_FILE = REPO_ROOT / ".loop" / "quarantine.json"
|
||||
INSIGHTS_FILE = REPO_ROOT / ".loop" / "retro" / "insights.json"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def load_jsonl(path: Path) -> list[dict]:
|
||||
"""Load a JSONL file, skipping bad lines."""
|
||||
if not path.exists():
|
||||
return []
|
||||
entries = []
|
||||
for line in path.read_text().strip().splitlines():
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
def parse_ts(ts_str: str) -> datetime | None:
|
||||
"""Parse an ISO timestamp, tolerating missing tz."""
|
||||
if not ts_str:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def window(entries: list[dict], days: int) -> list[dict]:
|
||||
"""Filter entries to the last N days."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
result = []
|
||||
for e in entries:
|
||||
ts = parse_ts(e.get("timestamp", ""))
|
||||
if ts and ts >= cutoff:
|
||||
result.append(e)
|
||||
return result
|
||||
|
||||
|
||||
# ── Analysis functions ───────────────────────────────────────────────────
|
||||
|
||||
def compute_trends(cycles: list[dict]) -> dict:
|
||||
"""Compare recent window (last 7d) vs older window (7-14d ago)."""
|
||||
recent = window(cycles, 7)
|
||||
older = window(cycles, 14)
|
||||
# Remove recent from older to get the 7-14d window
|
||||
recent_set = {(e.get("cycle"), e.get("timestamp")) for e in recent}
|
||||
older = [e for e in older if (e.get("cycle"), e.get("timestamp")) not in recent_set]
|
||||
|
||||
def stats(entries):
|
||||
if not entries:
|
||||
return {"count": 0, "success_rate": None, "avg_duration": None,
|
||||
"lines_net": 0, "prs_merged": 0}
|
||||
successes = sum(1 for e in entries if e.get("success"))
|
||||
durations = [e["duration"] for e in entries if e.get("duration", 0) > 0]
|
||||
return {
|
||||
"count": len(entries),
|
||||
"success_rate": round(successes / len(entries), 3) if entries else None,
|
||||
"avg_duration": round(sum(durations) / len(durations)) if durations else None,
|
||||
"lines_net": sum(e.get("lines_added", 0) - e.get("lines_removed", 0) for e in entries),
|
||||
"prs_merged": sum(1 for e in entries if e.get("pr")),
|
||||
}
|
||||
|
||||
recent_stats = stats(recent)
|
||||
older_stats = stats(older)
|
||||
|
||||
trend = {
|
||||
"recent_7d": recent_stats,
|
||||
"previous_7d": older_stats,
|
||||
"velocity_change": None,
|
||||
"success_rate_change": None,
|
||||
"duration_change": None,
|
||||
}
|
||||
|
||||
if recent_stats["count"] and older_stats["count"]:
|
||||
trend["velocity_change"] = recent_stats["count"] - older_stats["count"]
|
||||
if recent_stats["success_rate"] is not None and older_stats["success_rate"] is not None:
|
||||
trend["success_rate_change"] = round(
|
||||
recent_stats["success_rate"] - older_stats["success_rate"], 3
|
||||
)
|
||||
if recent_stats["avg_duration"] is not None and older_stats["avg_duration"] is not None:
|
||||
trend["duration_change"] = recent_stats["avg_duration"] - older_stats["avg_duration"]
|
||||
|
||||
return trend
|
||||
|
||||
|
||||
def type_analysis(cycles: list[dict]) -> dict:
|
||||
"""Per-type success rates and durations."""
|
||||
by_type: dict[str, list[dict]] = defaultdict(list)
|
||||
for c in cycles:
|
||||
by_type[c.get("type", "unknown")].append(c)
|
||||
|
||||
result = {}
|
||||
for t, entries in by_type.items():
|
||||
durations = [e["duration"] for e in entries if e.get("duration", 0) > 0]
|
||||
successes = sum(1 for e in entries if e.get("success"))
|
||||
result[t] = {
|
||||
"count": len(entries),
|
||||
"success_rate": round(successes / len(entries), 3) if entries else 0,
|
||||
"avg_duration": round(sum(durations) / len(durations)) if durations else 0,
|
||||
"max_duration": max(durations) if durations else 0,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def repeat_failures(cycles: list[dict]) -> list[dict]:
|
||||
"""Issues that have failed multiple times — quarantine candidates."""
|
||||
failures: dict[int, list] = defaultdict(list)
|
||||
for c in cycles:
|
||||
if not c.get("success") and c.get("issue"):
|
||||
failures[c["issue"]].append({
|
||||
"cycle": c.get("cycle"),
|
||||
"reason": c.get("reason", ""),
|
||||
"duration": c.get("duration", 0),
|
||||
})
|
||||
# Only issues with 2+ failures
|
||||
return [
|
||||
{"issue": k, "failure_count": len(v), "attempts": v}
|
||||
for k, v in sorted(failures.items(), key=lambda x: -len(x[1]))
|
||||
if len(v) >= 2
|
||||
]
|
||||
|
||||
|
||||
def duration_outliers(cycles: list[dict], threshold_multiple: float = 3.0) -> list[dict]:
|
||||
"""Cycles that took way longer than average — something went wrong."""
|
||||
durations = [c["duration"] for c in cycles if c.get("duration", 0) > 0]
|
||||
if len(durations) < 5:
|
||||
return []
|
||||
avg = sum(durations) / len(durations)
|
||||
threshold = avg * threshold_multiple
|
||||
|
||||
outliers = []
|
||||
for c in cycles:
|
||||
dur = c.get("duration", 0)
|
||||
if dur > threshold:
|
||||
outliers.append({
|
||||
"cycle": c.get("cycle"),
|
||||
"issue": c.get("issue"),
|
||||
"type": c.get("type"),
|
||||
"duration": dur,
|
||||
"avg_duration": round(avg),
|
||||
"multiple": round(dur / avg, 1) if avg > 0 else 0,
|
||||
"reason": c.get("reason", ""),
|
||||
})
|
||||
return outliers
|
||||
|
||||
|
||||
def triage_effectiveness(deep_triages: list[dict]) -> dict:
|
||||
"""How well is the deep triage performing?"""
|
||||
if not deep_triages:
|
||||
return {"runs": 0, "note": "No deep triage data yet"}
|
||||
|
||||
total_reviewed = sum(d.get("issues_reviewed", 0) for d in deep_triages)
|
||||
total_refined = sum(len(d.get("issues_refined", [])) for d in deep_triages)
|
||||
total_created = sum(len(d.get("issues_created", [])) for d in deep_triages)
|
||||
total_closed = sum(len(d.get("issues_closed", [])) for d in deep_triages)
|
||||
timmy_available = sum(1 for d in deep_triages if d.get("timmy_available"))
|
||||
|
||||
# Extract Timmy's feedback themes
|
||||
timmy_themes = []
|
||||
for d in deep_triages:
|
||||
fb = d.get("timmy_feedback", "")
|
||||
if fb:
|
||||
timmy_themes.append(fb[:200])
|
||||
|
||||
return {
|
||||
"runs": len(deep_triages),
|
||||
"total_reviewed": total_reviewed,
|
||||
"total_refined": total_refined,
|
||||
"total_created": total_created,
|
||||
"total_closed": total_closed,
|
||||
"timmy_consultation_rate": round(timmy_available / len(deep_triages), 2),
|
||||
"timmy_recent_feedback": timmy_themes[-1] if timmy_themes else "",
|
||||
"timmy_feedback_history": timmy_themes,
|
||||
}
|
||||
|
||||
|
||||
def generate_recommendations(
|
||||
trends: dict,
|
||||
types: dict,
|
||||
repeats: list,
|
||||
outliers: list,
|
||||
triage_eff: dict,
|
||||
) -> list[dict]:
|
||||
"""Produce actionable recommendations from the analysis."""
|
||||
recs = []
|
||||
|
||||
# 1. Success rate declining?
|
||||
src = trends.get("success_rate_change")
|
||||
if src is not None and src < -0.1:
|
||||
recs.append({
|
||||
"severity": "high",
|
||||
"category": "reliability",
|
||||
"finding": f"Success rate dropped {abs(src)*100:.0f}pp in the last 7 days",
|
||||
"recommendation": "Review recent failures. Are issues poorly scoped? "
|
||||
"Is main unstable? Check if triage is producing bad work items.",
|
||||
})
|
||||
|
||||
# 2. Velocity dropping?
|
||||
vc = trends.get("velocity_change")
|
||||
if vc is not None and vc < -5:
|
||||
recs.append({
|
||||
"severity": "medium",
|
||||
"category": "throughput",
|
||||
"finding": f"Velocity dropped by {abs(vc)} cycles vs previous week",
|
||||
"recommendation": "Check for loop stalls, long-running cycles, or queue starvation.",
|
||||
})
|
||||
|
||||
# 3. Duration creep?
|
||||
dc = trends.get("duration_change")
|
||||
if dc is not None and dc > 120: # 2+ minutes longer
|
||||
recs.append({
|
||||
"severity": "medium",
|
||||
"category": "efficiency",
|
||||
"finding": f"Average cycle duration increased by {dc}s vs previous week",
|
||||
"recommendation": "Issues may be growing in scope. Enforce tighter decomposition "
|
||||
"in deep triage. Check if tests are getting slower.",
|
||||
})
|
||||
|
||||
# 4. Type-specific problems
|
||||
for t, info in types.items():
|
||||
if info["count"] >= 3 and info["success_rate"] < 0.5:
|
||||
recs.append({
|
||||
"severity": "high",
|
||||
"category": "type_reliability",
|
||||
"finding": f"'{t}' issues fail {(1-info['success_rate'])*100:.0f}% of the time "
|
||||
f"({info['count']} attempts)",
|
||||
"recommendation": f"'{t}' issues need better scoping or different approach. "
|
||||
f"Consider: tighter acceptance criteria, smaller scope, "
|
||||
f"or delegating to Kimi with more context.",
|
||||
})
|
||||
if info["avg_duration"] > 600 and info["count"] >= 3: # >10 min avg
|
||||
recs.append({
|
||||
"severity": "medium",
|
||||
"category": "type_efficiency",
|
||||
"finding": f"'{t}' issues average {info['avg_duration']//60}m{info['avg_duration']%60}s "
|
||||
f"(max {info['max_duration']//60}m)",
|
||||
"recommendation": f"Break '{t}' issues into smaller pieces. Target <5 min per cycle.",
|
||||
})
|
||||
|
||||
# 5. Repeat failures
|
||||
for rf in repeats[:3]:
|
||||
recs.append({
|
||||
"severity": "high",
|
||||
"category": "repeat_failure",
|
||||
"finding": f"Issue #{rf['issue']} has failed {rf['failure_count']} times",
|
||||
"recommendation": "Quarantine or rewrite this issue. Repeated failure = "
|
||||
"bad scope or missing prerequisite.",
|
||||
})
|
||||
|
||||
# 6. Outliers
|
||||
if len(outliers) > 2:
|
||||
recs.append({
|
||||
"severity": "medium",
|
||||
"category": "outliers",
|
||||
"finding": f"{len(outliers)} cycles took {outliers[0].get('multiple', '?')}x+ "
|
||||
f"longer than average",
|
||||
"recommendation": "Long cycles waste resources. Add timeout enforcement or "
|
||||
"break complex issues earlier.",
|
||||
})
|
||||
|
||||
# 7. Code growth
|
||||
recent = trends.get("recent_7d", {})
|
||||
net = recent.get("lines_net", 0)
|
||||
if net > 500:
|
||||
recs.append({
|
||||
"severity": "low",
|
||||
"category": "code_health",
|
||||
"finding": f"Net +{net} lines added in the last 7 days",
|
||||
"recommendation": "Lines of code is a liability. Balance feature work with "
|
||||
"refactoring. Target net-zero or negative line growth.",
|
||||
})
|
||||
|
||||
# 8. Triage health
|
||||
if triage_eff.get("runs", 0) == 0:
|
||||
recs.append({
|
||||
"severity": "high",
|
||||
"category": "triage",
|
||||
"finding": "Deep triage has never run",
|
||||
"recommendation": "Enable deep triage (every 20 cycles). The loop needs "
|
||||
"LLM-driven issue refinement to stay effective.",
|
||||
})
|
||||
|
||||
# No recommendations = things are healthy
|
||||
if not recs:
|
||||
recs.append({
|
||||
"severity": "info",
|
||||
"category": "health",
|
||||
"finding": "No significant issues detected",
|
||||
"recommendation": "System is healthy. Continue current patterns.",
|
||||
})
|
||||
|
||||
return recs
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
cycles = load_jsonl(CYCLES_FILE)
|
||||
deep_triages = load_jsonl(DEEP_TRIAGE_FILE)
|
||||
|
||||
if not cycles:
|
||||
print("[introspect] No cycle data found. Nothing to analyze.")
|
||||
return
|
||||
|
||||
# Run all analyses
|
||||
trends = compute_trends(cycles)
|
||||
types = type_analysis(cycles)
|
||||
repeats = repeat_failures(cycles)
|
||||
outliers = duration_outliers(cycles)
|
||||
triage_eff = triage_effectiveness(deep_triages)
|
||||
recommendations = generate_recommendations(trends, types, repeats, outliers, triage_eff)
|
||||
|
||||
insights = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"total_cycles_analyzed": len(cycles),
|
||||
"trends": trends,
|
||||
"by_type": types,
|
||||
"repeat_failures": repeats[:5],
|
||||
"duration_outliers": outliers[:5],
|
||||
"triage_effectiveness": triage_eff,
|
||||
"recommendations": recommendations,
|
||||
}
|
||||
|
||||
# Write insights
|
||||
INSIGHTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
INSIGHTS_FILE.write_text(json.dumps(insights, indent=2) + "\n")
|
||||
|
||||
# Current epoch from latest entry
|
||||
latest_epoch = ""
|
||||
for c in reversed(cycles):
|
||||
if c.get("epoch"):
|
||||
latest_epoch = c["epoch"]
|
||||
break
|
||||
|
||||
# Human-readable output
|
||||
header = f"[introspect] Analyzed {len(cycles)} cycles"
|
||||
if latest_epoch:
|
||||
header += f" · current epoch: {latest_epoch}"
|
||||
print(header)
|
||||
|
||||
print(f"\n TRENDS (7d vs previous 7d):")
|
||||
r7 = trends["recent_7d"]
|
||||
p7 = trends["previous_7d"]
|
||||
print(f" Cycles: {r7['count']:>3d} (was {p7['count']})")
|
||||
if r7["success_rate"] is not None:
|
||||
arrow = "↑" if (trends["success_rate_change"] or 0) > 0 else "↓" if (trends["success_rate_change"] or 0) < 0 else "→"
|
||||
print(f" Success rate: {r7['success_rate']*100:>4.0f}% {arrow}")
|
||||
if r7["avg_duration"] is not None:
|
||||
print(f" Avg duration: {r7['avg_duration']//60}m{r7['avg_duration']%60:02d}s")
|
||||
print(f" PRs merged: {r7['prs_merged']:>3d} (was {p7['prs_merged']})")
|
||||
print(f" Lines net: {r7['lines_net']:>+5d}")
|
||||
|
||||
print(f"\n BY TYPE:")
|
||||
for t, info in sorted(types.items(), key=lambda x: -x[1]["count"]):
|
||||
print(f" {t:12s} n={info['count']:>2d} "
|
||||
f"ok={info['success_rate']*100:>3.0f}% "
|
||||
f"avg={info['avg_duration']//60}m{info['avg_duration']%60:02d}s")
|
||||
|
||||
if repeats:
|
||||
print(f"\n REPEAT FAILURES:")
|
||||
for rf in repeats[:3]:
|
||||
print(f" #{rf['issue']} failed {rf['failure_count']}x")
|
||||
|
||||
print(f"\n RECOMMENDATIONS ({len(recommendations)}):")
|
||||
for i, rec in enumerate(recommendations, 1):
|
||||
sev = {"high": "🔴", "medium": "🟡", "low": "🟢", "info": "ℹ️ "}.get(rec["severity"], "?")
|
||||
print(f" {sev} {rec['finding']}")
|
||||
print(f" → {rec['recommendation']}")
|
||||
|
||||
print(f"\n Written to: {INSIGHTS_FILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
360
scripts/triage_score.py
Normal file
360
scripts/triage_score.py
Normal file
@@ -0,0 +1,360 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mechanical triage scoring for the Timmy dev loop.
|
||||
|
||||
Reads open issues from Gitea, scores them on scope/acceptance/alignment,
|
||||
writes a ranked queue to .loop/queue.json. No LLM calls — pure heuristics.
|
||||
|
||||
Run: python3 scripts/triage_score.py
|
||||
Env: GITEA_TOKEN (or reads ~/.hermes/gitea_token)
|
||||
GITEA_API (default: http://localhost:3000/api/v1)
|
||||
REPO_SLUG (default: rockachopa/Timmy-time-dashboard)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────────
|
||||
GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1")
|
||||
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
|
||||
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
QUEUE_FILE = REPO_ROOT / ".loop" / "queue.json"
|
||||
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "triage.jsonl"
|
||||
QUARANTINE_FILE = REPO_ROOT / ".loop" / "quarantine.json"
|
||||
CYCLE_RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
||||
|
||||
# Minimum score to be considered "ready"
|
||||
READY_THRESHOLD = 5
|
||||
# How many recent cycle retros to check for quarantine
|
||||
QUARANTINE_LOOKBACK = 20
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get_token() -> str:
|
||||
token = os.environ.get("GITEA_TOKEN", "").strip()
|
||||
if not token and TOKEN_FILE.exists():
|
||||
token = TOKEN_FILE.read_text().strip()
|
||||
if not token:
|
||||
print("[triage] ERROR: No Gitea token found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return token
|
||||
|
||||
|
||||
def api_get(path: str, token: str) -> list | dict:
|
||||
"""Minimal HTTP GET using urllib (no dependencies)."""
|
||||
import urllib.request
|
||||
url = f"{GITEA_API}/repos/{REPO_SLUG}/{path}"
|
||||
req = urllib.request.Request(url, headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def load_quarantine() -> dict:
|
||||
"""Load quarantined issues {issue_num: {reason, quarantined_at, failures}}."""
|
||||
if QUARANTINE_FILE.exists():
|
||||
try:
|
||||
return json.loads(QUARANTINE_FILE.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_quarantine(q: dict) -> None:
|
||||
QUARANTINE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
QUARANTINE_FILE.write_text(json.dumps(q, indent=2) + "\n")
|
||||
|
||||
|
||||
def load_cycle_failures() -> dict[int, int]:
|
||||
"""Count failures per issue from recent cycle retros."""
|
||||
failures: dict[int, int] = {}
|
||||
if not CYCLE_RETRO_FILE.exists():
|
||||
return failures
|
||||
lines = CYCLE_RETRO_FILE.read_text().strip().splitlines()
|
||||
for line in lines[-QUARANTINE_LOOKBACK:]:
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
if not entry.get("success", True):
|
||||
issue = entry.get("issue")
|
||||
if issue:
|
||||
failures[issue] = failures.get(issue, 0) + 1
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
return failures
|
||||
|
||||
|
||||
# ── Scoring ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Patterns that indicate file/function specificity
|
||||
FILE_PATTERNS = re.compile(
|
||||
r"(?:src/|tests/|scripts/|\.py|\.html|\.js|\.yaml|\.toml|\.sh)", re.IGNORECASE
|
||||
)
|
||||
FUNCTION_PATTERNS = re.compile(
|
||||
r"(?:def |class |function |method |`\w+\(\)`)", re.IGNORECASE
|
||||
)
|
||||
|
||||
# Patterns that indicate acceptance criteria
|
||||
ACCEPTANCE_PATTERNS = re.compile(
|
||||
r"(?:should|must|expect|verify|assert|test.?case|acceptance|criteria"
|
||||
r"|pass(?:es|ing)|fail(?:s|ing)|return(?:s)?|raise(?:s)?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TEST_PATTERNS = re.compile(
|
||||
r"(?:tox|pytest|test_\w+|\.test\.|assert\s)", re.IGNORECASE
|
||||
)
|
||||
|
||||
# Tags in issue titles
|
||||
TAG_PATTERN = re.compile(r"\[([^\]]+)\]")
|
||||
|
||||
# Priority labels / tags
|
||||
BUG_TAGS = {"bug", "broken", "crash", "error", "fix", "regression", "hotfix"}
|
||||
FEATURE_TAGS = {"feature", "feat", "enhancement", "capability", "timmy-capability"}
|
||||
REFACTOR_TAGS = {"refactor", "cleanup", "tech-debt", "optimization", "perf"}
|
||||
META_TAGS = {"philosophy", "soul-gap", "discussion", "question", "rfc"}
|
||||
LOOP_TAG = "loop-generated"
|
||||
|
||||
|
||||
def extract_tags(title: str, labels: list[str]) -> set[str]:
|
||||
"""Pull tags from [bracket] notation in title + Gitea labels."""
|
||||
tags = set()
|
||||
for match in TAG_PATTERN.finditer(title):
|
||||
tags.add(match.group(1).lower().strip())
|
||||
for label in labels:
|
||||
tags.add(label.lower().strip())
|
||||
return tags
|
||||
|
||||
|
||||
def score_scope(title: str, body: str, tags: set[str]) -> int:
|
||||
"""0-3: How well-scoped is this issue?"""
|
||||
text = f"{title}\n{body}"
|
||||
score = 0
|
||||
|
||||
# Mentions specific files?
|
||||
if FILE_PATTERNS.search(text):
|
||||
score += 1
|
||||
|
||||
# Mentions specific functions/classes?
|
||||
if FUNCTION_PATTERNS.search(text):
|
||||
score += 1
|
||||
|
||||
# Short, focused title (not a novel)?
|
||||
clean_title = TAG_PATTERN.sub("", title).strip()
|
||||
if len(clean_title) < 80:
|
||||
score += 1
|
||||
|
||||
# Philosophy/meta issues are inherently unscoped for dev work
|
||||
if tags & META_TAGS:
|
||||
score = max(0, score - 2)
|
||||
|
||||
return min(3, score)
|
||||
|
||||
|
||||
def score_acceptance(title: str, body: str, tags: set[str]) -> int:
|
||||
"""0-3: Does this have clear acceptance criteria?"""
|
||||
text = f"{title}\n{body}"
|
||||
score = 0
|
||||
|
||||
# Has acceptance-related language?
|
||||
matches = len(ACCEPTANCE_PATTERNS.findall(text))
|
||||
if matches >= 3:
|
||||
score += 2
|
||||
elif matches >= 1:
|
||||
score += 1
|
||||
|
||||
# Mentions specific tests?
|
||||
if TEST_PATTERNS.search(text):
|
||||
score += 1
|
||||
|
||||
# Has a "## Problem" + "## Solution" or similar structure?
|
||||
if re.search(r"##\s*(problem|solution|expected|actual|steps)", body, re.IGNORECASE):
|
||||
score += 1
|
||||
|
||||
# Philosophy issues don't have testable criteria
|
||||
if tags & META_TAGS:
|
||||
score = max(0, score - 1)
|
||||
|
||||
return min(3, score)
|
||||
|
||||
|
||||
def score_alignment(title: str, body: str, tags: set[str]) -> int:
|
||||
"""0-3: How aligned is this with the north star?"""
|
||||
score = 0
|
||||
|
||||
# Bug on main = highest priority
|
||||
if tags & BUG_TAGS:
|
||||
score += 3
|
||||
return min(3, score)
|
||||
|
||||
# Refactors that improve code health
|
||||
if tags & REFACTOR_TAGS:
|
||||
score += 2
|
||||
|
||||
# Features that grow Timmy's capabilities
|
||||
if tags & FEATURE_TAGS:
|
||||
score += 2
|
||||
|
||||
# Loop-generated issues get a small boost (the loop found real problems)
|
||||
if LOOP_TAG in tags:
|
||||
score += 1
|
||||
|
||||
# Philosophy issues are important but not dev-actionable
|
||||
if tags & META_TAGS:
|
||||
score = 0
|
||||
|
||||
return min(3, score)
|
||||
|
||||
|
||||
def score_issue(issue: dict) -> dict:
|
||||
"""Score a single issue. Returns enriched dict."""
|
||||
title = issue.get("title", "")
|
||||
body = issue.get("body", "") or ""
|
||||
labels = [l["name"] for l in issue.get("labels", [])]
|
||||
tags = extract_tags(title, labels)
|
||||
number = issue["number"]
|
||||
|
||||
scope = score_scope(title, body, tags)
|
||||
acceptance = score_acceptance(title, body, tags)
|
||||
alignment = score_alignment(title, body, tags)
|
||||
total = scope + acceptance + alignment
|
||||
|
||||
# Determine issue type
|
||||
if tags & BUG_TAGS:
|
||||
issue_type = "bug"
|
||||
elif tags & FEATURE_TAGS:
|
||||
issue_type = "feature"
|
||||
elif tags & REFACTOR_TAGS:
|
||||
issue_type = "refactor"
|
||||
elif tags & META_TAGS:
|
||||
issue_type = "philosophy"
|
||||
else:
|
||||
issue_type = "unknown"
|
||||
|
||||
# Extract mentioned files from body
|
||||
files = list(set(re.findall(r"(?:src|tests|scripts)/[\w/.]+\.(?:py|html|js|yaml)", body)))
|
||||
|
||||
return {
|
||||
"issue": number,
|
||||
"title": TAG_PATTERN.sub("", title).strip(),
|
||||
"type": issue_type,
|
||||
"score": total,
|
||||
"scope": scope,
|
||||
"acceptance": acceptance,
|
||||
"alignment": alignment,
|
||||
"tags": sorted(tags),
|
||||
"files": files[:10],
|
||||
"ready": total >= READY_THRESHOLD,
|
||||
}
|
||||
|
||||
|
||||
# ── Quarantine ──────────────────────────────────────────────────────────
|
||||
|
||||
def update_quarantine(scored: list[dict]) -> list[dict]:
|
||||
"""Auto-quarantine issues that have failed >= 2 times. Returns filtered list."""
|
||||
failures = load_cycle_failures()
|
||||
quarantine = load_quarantine()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
filtered = []
|
||||
for item in scored:
|
||||
num = item["issue"]
|
||||
fail_count = failures.get(num, 0)
|
||||
str_num = str(num)
|
||||
|
||||
if fail_count >= 2 and str_num not in quarantine:
|
||||
quarantine[str_num] = {
|
||||
"reason": f"Failed {fail_count} times in recent cycles",
|
||||
"quarantined_at": now,
|
||||
"failures": fail_count,
|
||||
}
|
||||
print(f"[triage] QUARANTINED #{num}: failed {fail_count} times")
|
||||
continue
|
||||
|
||||
if str_num in quarantine:
|
||||
print(f"[triage] Skipping #{num} (quarantined)")
|
||||
continue
|
||||
|
||||
filtered.append(item)
|
||||
|
||||
save_quarantine(quarantine)
|
||||
return filtered
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
def run_triage() -> list[dict]:
|
||||
token = get_token()
|
||||
|
||||
# Fetch all open issues (paginate)
|
||||
page = 1
|
||||
all_issues: list[dict] = []
|
||||
while True:
|
||||
batch = api_get(f"issues?state=open&limit=50&page={page}&type=issues", token)
|
||||
if not batch:
|
||||
break
|
||||
all_issues.extend(batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
page += 1
|
||||
|
||||
print(f"[triage] Fetched {len(all_issues)} open issues")
|
||||
|
||||
# Score each
|
||||
scored = [score_issue(i) for i in all_issues]
|
||||
|
||||
# Auto-quarantine repeat failures
|
||||
scored = update_quarantine(scored)
|
||||
|
||||
# Sort: ready first, then by score descending, bugs always on top
|
||||
def sort_key(item: dict) -> tuple:
|
||||
return (
|
||||
0 if item["type"] == "bug" else 1,
|
||||
-item["score"],
|
||||
item["issue"],
|
||||
)
|
||||
|
||||
scored.sort(key=sort_key)
|
||||
|
||||
# Write queue (ready items only)
|
||||
ready = [s for s in scored if s["ready"]]
|
||||
not_ready = [s for s in scored if not s["ready"]]
|
||||
|
||||
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
QUEUE_FILE.write_text(json.dumps(ready, indent=2) + "\n")
|
||||
|
||||
# Write retro entry
|
||||
retro_entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"total_open": len(all_issues),
|
||||
"scored": len(scored),
|
||||
"ready": len(ready),
|
||||
"not_ready": len(not_ready),
|
||||
"top_issue": ready[0]["issue"] if ready else None,
|
||||
"quarantined": len(load_quarantine()),
|
||||
}
|
||||
RETRO_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(RETRO_FILE, "a") as f:
|
||||
f.write(json.dumps(retro_entry) + "\n")
|
||||
|
||||
# Summary
|
||||
print(f"[triage] Ready: {len(ready)} | Not ready: {len(not_ready)}")
|
||||
for item in ready[:5]:
|
||||
flag = "🐛" if item["type"] == "bug" else "✦"
|
||||
print(f" {flag} #{item['issue']} score={item['score']} {item['title'][:60]}")
|
||||
if not_ready:
|
||||
print(f"[triage] Low-scoring ({len(not_ready)}):")
|
||||
for item in not_ready[:3]:
|
||||
print(f" #{item['issue']} score={item['score']} {item['title'][:50]}")
|
||||
|
||||
return ready
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_triage()
|
||||
@@ -1,10 +1,19 @@
|
||||
import logging as _logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import UTC
|
||||
from datetime import datetime as _datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
APP_START_TIME: _datetime = _datetime.now(UTC)
|
||||
|
||||
|
||||
def normalize_ollama_url(url: str) -> str:
|
||||
"""Replace localhost with 127.0.0.1 to avoid IPv6 resolution delays."""
|
||||
return url.replace("localhost", "127.0.0.1")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Central configuration — all env-var access goes through this class."""
|
||||
@@ -15,20 +24,29 @@ class Settings(BaseSettings):
|
||||
# Ollama host — override with OLLAMA_URL env var or .env file
|
||||
ollama_url: str = "http://localhost:11434"
|
||||
|
||||
@property
|
||||
def normalized_ollama_url(self) -> str:
|
||||
"""Return ollama_url with localhost replaced by 127.0.0.1."""
|
||||
return normalize_ollama_url(self.ollama_url)
|
||||
|
||||
# LLM model passed to Agno/Ollama — override with OLLAMA_MODEL
|
||||
# qwen3.5:latest is the primary model — better reasoning and tool calling
|
||||
# qwen3:30b is the primary model — better reasoning and tool calling
|
||||
# than llama3.1:8b-instruct while still running locally on modest hardware.
|
||||
# Fallback: llama3.1:8b-instruct if qwen3.5:latest not available.
|
||||
# Fallback: llama3.1:8b-instruct if qwen3:30b not available.
|
||||
# llama3.2 (3B) hallucinated tool output consistently in testing.
|
||||
ollama_model: str = "qwen3.5:latest"
|
||||
ollama_model: str = "qwen3:30b"
|
||||
|
||||
# Context window size for Ollama inference — override with OLLAMA_NUM_CTX
|
||||
# qwen3:30b with default context eats 45GB on a 39GB Mac.
|
||||
# 4096 keeps memory at ~19GB. Set to 0 to use model defaults.
|
||||
ollama_num_ctx: int = 4096
|
||||
|
||||
# Fallback model chains — override with FALLBACK_MODELS / VISION_FALLBACK_MODELS
|
||||
# as comma-separated strings, e.g. FALLBACK_MODELS="qwen3.5:latest,llama3.1"
|
||||
# as comma-separated strings, e.g. FALLBACK_MODELS="qwen3:30b,llama3.1"
|
||||
# Or edit config/providers.yaml → fallback_chains for the canonical source.
|
||||
fallback_models: list[str] = [
|
||||
"llama3.1:8b-instruct",
|
||||
"llama3.1",
|
||||
"qwen3.5:latest",
|
||||
"qwen2.5:14b",
|
||||
"qwen2.5:7b",
|
||||
"llama3.2:3b",
|
||||
@@ -56,17 +74,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" — pick best available local backend, fall back to Ollama
|
||||
timmy_model_backend: Literal["ollama", "grok", "claude", "auto"] = "ollama"
|
||||
|
||||
# ── Grok (xAI) — opt-in premium cloud backend ────────────────────────
|
||||
# Grok is a premium augmentation layer — local-first ethos preserved.
|
||||
@@ -130,7 +141,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").
|
||||
@@ -230,24 +246,30 @@ 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.
|
||||
# These values are passed as env vars to the gitea-mcp server process.
|
||||
gitea_url: str = "http://localhost:3000"
|
||||
gitea_token: str = "" # GITEA_TOKEN env var; falls back to ~/.config/gitea/token
|
||||
gitea_token: str = "" # GITEA_TOKEN env var; falls back to .timmy_gitea_token
|
||||
gitea_repo: str = "rockachopa/Timmy-time-dashboard" # owner/repo
|
||||
gitea_enabled: bool = True
|
||||
|
||||
# ── MCP Servers ────────────────────────────────────────────────────
|
||||
# External tool servers connected via Model Context Protocol (stdio).
|
||||
mcp_gitea_command: str = "gitea-mcp -t stdio"
|
||||
mcp_gitea_command: str = "gitea-mcp-server -t stdio"
|
||||
mcp_filesystem_command: str = "npx -y @modelcontextprotocol/server-filesystem"
|
||||
mcp_timeout: int = 15
|
||||
|
||||
@@ -342,14 +364,19 @@ class Settings(BaseSettings):
|
||||
def model_post_init(self, __context) -> None:
|
||||
"""Post-init: resolve gitea_token from file if not set via env."""
|
||||
if not self.gitea_token:
|
||||
token_path = os.path.expanduser("~/.config/gitea/token")
|
||||
try:
|
||||
if os.path.isfile(token_path):
|
||||
token = open(token_path).read().strip() # noqa: SIM115
|
||||
if token:
|
||||
self.gitea_token = token
|
||||
except OSError:
|
||||
pass
|
||||
# Priority: Timmy's own token → legacy admin token
|
||||
repo_root = self._compute_repo_root()
|
||||
timmy_token_path = os.path.join(repo_root, ".timmy_gitea_token")
|
||||
legacy_token_path = os.path.expanduser("~/.config/gitea/token")
|
||||
for token_path in (timmy_token_path, legacy_token_path):
|
||||
try:
|
||||
if os.path.isfile(token_path):
|
||||
token = open(token_path).read().strip() # noqa: SIM115
|
||||
if token:
|
||||
self.gitea_token = token
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
@@ -375,7 +402,7 @@ def check_ollama_model_available(model_name: str) -> bool:
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
url = settings.ollama_url.replace("localhost", "127.0.0.1")
|
||||
url = settings.normalized_ollama_url
|
||||
req = urllib.request.Request(
|
||||
f"{url}/api/tags",
|
||||
method="GET",
|
||||
@@ -388,7 +415,8 @@ def check_ollama_model_available(model_name: str) -> bool:
|
||||
model_name == m or model_name == m.split(":")[0] or m.startswith(model_name)
|
||||
for m in models
|
||||
)
|
||||
except Exception:
|
||||
except (OSError, ValueError) as exc:
|
||||
_startup_logger.debug("Ollama model check failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
@@ -451,8 +479,19 @@ def validate_startup(*, force: bool = False) -> None:
|
||||
", ".join(_missing),
|
||||
)
|
||||
sys.exit(1)
|
||||
if "*" in settings.cors_origins:
|
||||
_startup_logger.error(
|
||||
"PRODUCTION SECURITY ERROR: CORS wildcard '*' is not allowed "
|
||||
"in production. Set CORS_ORIGINS to explicit origins."
|
||||
)
|
||||
sys.exit(1)
|
||||
_startup_logger.info("Production mode: security secrets validated ✓")
|
||||
else:
|
||||
if "*" in settings.cors_origins:
|
||||
_startup_logger.warning(
|
||||
"SEC: CORS_ORIGINS contains wildcard '*' — "
|
||||
"restrict to explicit origins before deploying to production."
|
||||
)
|
||||
if not settings.l402_hmac_secret:
|
||||
_startup_logger.warning(
|
||||
"SEC: L402_HMAC_SECRET is not set — "
|
||||
|
||||
@@ -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
|
||||
@@ -277,35 +329,38 @@ async def _discord_token_watcher() -> None:
|
||||
logger.warning("Discord auto-start failed: %s", exc)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager with non-blocking startup."""
|
||||
|
||||
# Validate security config (no-op in test mode)
|
||||
def _startup_init() -> None:
|
||||
"""Validate config and enable event persistence."""
|
||||
from config import validate_startup
|
||||
|
||||
validate_startup()
|
||||
|
||||
# Enable event persistence (unified EventBus + swarm event_log)
|
||||
from infrastructure.events.bus import init_event_bus_persistence
|
||||
|
||||
init_event_bus_persistence()
|
||||
|
||||
# Create all background tasks without waiting for them
|
||||
briefing_task = asyncio.create_task(_briefing_scheduler())
|
||||
thinking_task = asyncio.create_task(_thinking_scheduler())
|
||||
loop_qa_task = asyncio.create_task(_loop_qa_scheduler())
|
||||
|
||||
# Initialize Spark Intelligence engine
|
||||
from spark.engine import get_spark_engine
|
||||
|
||||
if get_spark_engine().enabled:
|
||||
logger.info("Spark Intelligence active — event capture enabled")
|
||||
|
||||
# Auto-prune old vector store memories on startup
|
||||
|
||||
def _startup_background_tasks() -> list[asyncio.Task]:
|
||||
"""Spawn all recurring background tasks (non-blocking)."""
|
||||
return [
|
||||
asyncio.create_task(_briefing_scheduler()),
|
||||
asyncio.create_task(_thinking_scheduler()),
|
||||
asyncio.create_task(_loop_qa_scheduler()),
|
||||
asyncio.create_task(_presence_watcher()),
|
||||
asyncio.create_task(_start_chat_integrations_background()),
|
||||
]
|
||||
|
||||
|
||||
def _startup_pruning() -> None:
|
||||
"""Auto-prune old memories, thoughts, and events on startup."""
|
||||
if settings.memory_prune_days > 0:
|
||||
try:
|
||||
from timmy.memory.vector_store import prune_memories
|
||||
from timmy.memory_system import prune_memories
|
||||
|
||||
pruned = prune_memories(
|
||||
older_than_days=settings.memory_prune_days,
|
||||
@@ -320,7 +375,6 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as exc:
|
||||
logger.debug("Memory auto-prune skipped: %s", exc)
|
||||
|
||||
# Auto-prune old thoughts on startup
|
||||
if settings.thoughts_prune_days > 0:
|
||||
try:
|
||||
from timmy.thinking import thinking_engine
|
||||
@@ -338,7 +392,6 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as exc:
|
||||
logger.debug("Thought auto-prune skipped: %s", exc)
|
||||
|
||||
# Auto-prune old system events on startup
|
||||
if settings.events_prune_days > 0:
|
||||
try:
|
||||
from swarm.event_log import prune_old_events
|
||||
@@ -356,7 +409,6 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as exc:
|
||||
logger.debug("Event auto-prune skipped: %s", exc)
|
||||
|
||||
# Warn if memory vault exceeds size limit
|
||||
if settings.memory_vault_max_mb > 0:
|
||||
try:
|
||||
vault_path = Path(settings.repo_root) / "memory" / "notes"
|
||||
@@ -372,21 +424,18 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as exc:
|
||||
logger.debug("Vault size check skipped: %s", exc)
|
||||
|
||||
# Start chat integrations in background
|
||||
chat_task = asyncio.create_task(_start_chat_integrations_background())
|
||||
|
||||
logger.info("✓ Dashboard ready for requests")
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup on shutdown
|
||||
async def _shutdown_cleanup(
|
||||
bg_tasks: list[asyncio.Task],
|
||||
workshop_heartbeat,
|
||||
) -> None:
|
||||
"""Stop chat bots, MCP sessions, heartbeat, and cancel background tasks."""
|
||||
from integrations.chat_bridge.vendors.discord import discord_bot
|
||||
from integrations.telegram_bot.bot import telegram_bot
|
||||
|
||||
await discord_bot.stop()
|
||||
await telegram_bot.stop()
|
||||
|
||||
# Close MCP tool server sessions
|
||||
try:
|
||||
from timmy.mcp_tools import close_mcp_sessions
|
||||
|
||||
@@ -394,13 +443,44 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as exc:
|
||||
logger.debug("MCP shutdown: %s", exc)
|
||||
|
||||
for task in [briefing_task, thinking_task, chat_task, loop_qa_task]:
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await workshop_heartbeat.stop()
|
||||
|
||||
for task in bg_tasks:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager with non-blocking startup."""
|
||||
_startup_init()
|
||||
bg_tasks = _startup_background_tasks()
|
||||
_startup_pruning()
|
||||
|
||||
# Start Workshop presence heartbeat with WS relay
|
||||
from dashboard.routes.world import broadcast_world_state
|
||||
from timmy.workshop_state import WorkshopHeartbeat
|
||||
|
||||
workshop_heartbeat = WorkshopHeartbeat(on_change=broadcast_world_state)
|
||||
await workshop_heartbeat.start()
|
||||
|
||||
# Register session logger with error capture
|
||||
try:
|
||||
from infrastructure.error_capture import register_error_recorder
|
||||
from timmy.session_logger import get_session_logger
|
||||
|
||||
register_error_recorder(get_session_logger().record_error)
|
||||
except Exception:
|
||||
logger.debug("Failed to register error recorder")
|
||||
|
||||
logger.info("✓ Dashboard ready for requests")
|
||||
|
||||
yield
|
||||
|
||||
await _shutdown_cleanup(bg_tasks, workshop_heartbeat)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -413,15 +493,14 @@ app = FastAPI(
|
||||
|
||||
|
||||
def _get_cors_origins() -> list[str]:
|
||||
"""Get CORS origins from settings, with sensible defaults."""
|
||||
"""Get CORS origins from settings, rejecting wildcards in production."""
|
||||
origins = settings.cors_origins
|
||||
if settings.debug and origins == ["*"]:
|
||||
return [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:8000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:8000",
|
||||
]
|
||||
if "*" in origins and not settings.debug:
|
||||
logger.warning(
|
||||
"Wildcard '*' in CORS_ORIGINS stripped in production — "
|
||||
"set explicit origins via CORS_ORIGINS env var"
|
||||
)
|
||||
origins = [o for o in origins if o != "*"]
|
||||
return origins
|
||||
|
||||
|
||||
@@ -474,6 +553,7 @@ app.include_router(grok_router)
|
||||
app.include_router(models_router)
|
||||
app.include_router(models_api_router)
|
||||
app.include_router(chat_api_router)
|
||||
app.include_router(chat_api_v1_router)
|
||||
app.include_router(thinking_router)
|
||||
app.include_router(calm_router)
|
||||
app.include_router(tasks_router)
|
||||
@@ -482,6 +562,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")
|
||||
@@ -510,7 +591,8 @@ async def swarm_live(websocket: WebSocket):
|
||||
while True:
|
||||
# Keep connection alive; events are pushed via ws_mgr.broadcast()
|
||||
await websocket.receive_text()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug("WebSocket disconnect error: %s", exc)
|
||||
ws_mgr.disconnect(websocket)
|
||||
|
||||
|
||||
@@ -532,7 +614,8 @@ async def swarm_agents_sidebar():
|
||||
f"</div>"
|
||||
)
|
||||
return "\n".join(lines) if lines else '<div class="mc-muted">No agents configured</div>'
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug("Agents sidebar error: %s", exc)
|
||||
return '<div class="mc-muted">Agents unavailable</div>'
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ to protect state-changing endpoints from cross-site request attacks.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
@@ -16,6 +17,8 @@ from starlette.responses import JSONResponse, Response
|
||||
# Module-level set to track exempt routes
|
||||
_exempt_routes: set[str] = set()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def csrf_exempt(endpoint: Callable) -> Callable:
|
||||
"""Decorator to mark an endpoint as exempt from CSRF validation.
|
||||
@@ -97,7 +100,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
...
|
||||
|
||||
Usage:
|
||||
app.add_middleware(CSRFMiddleware, secret="your-secret-key")
|
||||
app.add_middleware(CSRFMiddleware, secret=settings.csrf_secret)
|
||||
|
||||
Attributes:
|
||||
secret: Secret key for token signing (optional, for future use).
|
||||
@@ -278,7 +281,8 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
form_token = form_data.get(self.form_field)
|
||||
if form_token and validate_csrf_token(str(form_token), csrf_cookie):
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug("CSRF form parsing error: %s", exc)
|
||||
# Error parsing form data, treat as invalid
|
||||
pass
|
||||
|
||||
|
||||
@@ -115,7 +115,8 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"duration_ms": f"{duration_ms:.0f}",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug("Escalation logging error: %s", exc)
|
||||
pass # never let escalation break the request
|
||||
|
||||
# Re-raise the exception
|
||||
|
||||
@@ -4,10 +4,14 @@ Adds common security headers to all HTTP responses to improve
|
||||
application security posture against various attacks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to add security headers to all responses.
|
||||
@@ -130,12 +134,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).debug(
|
||||
"Upstream error in security headers middleware", exc_info=True
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Upstream error in security headers middleware: %s", exc)
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
response = PlainTextResponse("Internal Server Error", status_code=500)
|
||||
|
||||
@@ -12,6 +12,7 @@ from timmy.tool_safety import (
|
||||
format_action_description,
|
||||
get_impact_level,
|
||||
)
|
||||
from timmy.welcome import WELCOME_MESSAGE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,7 +57,7 @@ async def get_history(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/history.html",
|
||||
{"messages": message_log.all()},
|
||||
{"messages": message_log.all(), "welcome_message": WELCOME_MESSAGE},
|
||||
)
|
||||
|
||||
|
||||
@@ -66,7 +67,7 @@ async def clear_history(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/history.html",
|
||||
{"messages": []},
|
||||
{"messages": [], "welcome_message": WELCOME_MESSAGE},
|
||||
)
|
||||
|
||||
|
||||
@@ -84,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:
|
||||
logger.debug("Failed to record user input for thinking engine")
|
||||
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
response_text = None
|
||||
error_text = None
|
||||
@@ -220,7 +229,8 @@ async def reject_tool(request: Request, approval_id: str):
|
||||
# Resume so the agent knows the tool was rejected
|
||||
try:
|
||||
await continue_chat(pending["run_output"])
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Agent tool rejection error: %s", exc)
|
||||
pass
|
||||
|
||||
reject(approval_id)
|
||||
|
||||
@@ -27,7 +27,8 @@ async def get_briefing(request: Request):
|
||||
"""Return today's briefing page (generated or cached)."""
|
||||
try:
|
||||
briefing = briefing_engine.get_or_generate()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug("Briefing generation failed: %s", exc)
|
||||
logger.exception("Briefing generation failed")
|
||||
now = datetime.now(UTC)
|
||||
briefing = Briefing(
|
||||
|
||||
@@ -51,7 +51,8 @@ async def api_chat(request: Request):
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Chat API JSON parse error: %s", exc)
|
||||
return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
|
||||
|
||||
messages = body.get("messages")
|
||||
@@ -78,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:
|
||||
logger.debug("Failed to record user input for thinking engine")
|
||||
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
try:
|
||||
|
||||
198
src/dashboard/routes/chat_api_v1.py
Normal file
198
src/dashboard/routes/chat_api_v1.py
Normal 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",
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
@@ -39,56 +40,50 @@ def _query_database(db_path: str) -> dict:
|
||||
"""Open a database read-only and return all tables with their rows."""
|
||||
result = {"tables": {}, "error": None}
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
except Exception as exc:
|
||||
result["error"] = str(exc)
|
||||
return result
|
||||
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
try:
|
||||
tables = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
).fetchall()
|
||||
for (table_name,) in tables:
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM [{table_name}] LIMIT {MAX_ROWS}" # noqa: S608
|
||||
).fetchall()
|
||||
columns = (
|
||||
[
|
||||
desc[0]
|
||||
for desc in conn.execute(
|
||||
f"SELECT * FROM [{table_name}] LIMIT 0"
|
||||
).description
|
||||
]
|
||||
if rows
|
||||
else []
|
||||
) # noqa: S608
|
||||
if not columns and rows:
|
||||
columns = list(rows[0].keys())
|
||||
elif not columns:
|
||||
# Get columns even for empty tables
|
||||
cursor = conn.execute(f"PRAGMA table_info([{table_name}])") # noqa: S608
|
||||
columns = [r[1] for r in cursor.fetchall()]
|
||||
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0] # noqa: S608
|
||||
result["tables"][table_name] = {
|
||||
"columns": columns,
|
||||
"rows": [dict(r) for r in rows],
|
||||
"total_count": count,
|
||||
"truncated": count > MAX_ROWS,
|
||||
}
|
||||
except Exception as exc:
|
||||
result["tables"][table_name] = {
|
||||
"error": str(exc),
|
||||
"columns": [],
|
||||
"rows": [],
|
||||
"total_count": 0,
|
||||
"truncated": False,
|
||||
}
|
||||
tables = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
).fetchall()
|
||||
for (table_name,) in tables:
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM [{table_name}] LIMIT {MAX_ROWS}" # noqa: S608
|
||||
).fetchall()
|
||||
columns = (
|
||||
[
|
||||
desc[0]
|
||||
for desc in conn.execute(
|
||||
f"SELECT * FROM [{table_name}] LIMIT 0"
|
||||
).description
|
||||
]
|
||||
if rows
|
||||
else []
|
||||
) # noqa: S608
|
||||
if not columns and rows:
|
||||
columns = list(rows[0].keys())
|
||||
elif not columns:
|
||||
# Get columns even for empty tables
|
||||
cursor = conn.execute(f"PRAGMA table_info([{table_name}])") # noqa: S608
|
||||
columns = [r[1] for r in cursor.fetchall()]
|
||||
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0] # noqa: S608
|
||||
result["tables"][table_name] = {
|
||||
"columns": columns,
|
||||
"rows": [dict(r) for r in rows],
|
||||
"total_count": count,
|
||||
"truncated": count > MAX_ROWS,
|
||||
}
|
||||
except Exception as exc:
|
||||
result["tables"][table_name] = {
|
||||
"error": str(exc),
|
||||
"columns": [],
|
||||
"rows": [],
|
||||
"total_count": 0,
|
||||
"truncated": False,
|
||||
}
|
||||
except Exception as exc:
|
||||
result["error"] = str(exc)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ async def experiments_page(request: Request):
|
||||
history = []
|
||||
try:
|
||||
history = get_experiment_history(_workspace())
|
||||
except Exception:
|
||||
logger.debug("Failed to load experiment history", exc_info=True)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to load experiment history: %s", exc)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
|
||||
@@ -52,8 +52,8 @@ async def grok_status(request: Request):
|
||||
"estimated_cost_sats": backend.stats.estimated_cost_sats,
|
||||
"errors": backend.stats.errors,
|
||||
}
|
||||
except Exception:
|
||||
logger.debug("Failed to load Grok stats", exc_info=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load Grok stats: %s", exc)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -94,8 +94,8 @@ async def toggle_grok_mode(request: Request):
|
||||
tool_name="grok_mode_toggle",
|
||||
success=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to log Grok toggle to Spark", exc_info=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to log Grok toggle to Spark: %s", exc)
|
||||
|
||||
return HTMLResponse(
|
||||
_render_toggle_card(_grok_mode_active),
|
||||
@@ -128,8 +128,8 @@ def _run_grok_query(message: str) -> dict:
|
||||
sats = min(settings.grok_max_sats_per_query, 100)
|
||||
ln.create_invoice(sats, f"Grok: {message[:50]}")
|
||||
invoice_note = f" | {sats} sats"
|
||||
except Exception:
|
||||
logger.debug("Lightning invoice creation failed", exc_info=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Lightning invoice creation failed: %s", exc)
|
||||
|
||||
try:
|
||||
result = backend.run(message)
|
||||
|
||||
@@ -6,14 +6,18 @@ for the Mission Control dashboard.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import closing
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import APP_START_TIME as _START_TIME
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -49,7 +53,6 @@ class HealthStatus(BaseModel):
|
||||
|
||||
|
||||
# Simple uptime tracking
|
||||
_START_TIME = datetime.now(UTC)
|
||||
|
||||
# Ollama health cache (30-second TTL)
|
||||
_ollama_cache: DependencyStatus | None = None
|
||||
@@ -62,7 +65,7 @@ def _check_ollama_sync() -> DependencyStatus:
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
url = settings.ollama_url.replace("localhost", "127.0.0.1")
|
||||
url = settings.normalized_ollama_url
|
||||
req = urllib.request.Request(
|
||||
f"{url}/api/tags",
|
||||
method="GET",
|
||||
@@ -76,8 +79,8 @@ def _check_ollama_sync() -> DependencyStatus:
|
||||
sovereignty_score=10,
|
||||
details={"url": settings.ollama_url, "model": settings.ollama_model},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Ollama health check failed", exc_info=True)
|
||||
except Exception as exc:
|
||||
logger.debug("Ollama health check failed: %s", exc)
|
||||
|
||||
return DependencyStatus(
|
||||
name="Ollama AI",
|
||||
@@ -101,7 +104,8 @@ async def _check_ollama() -> DependencyStatus:
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(_check_ollama_sync)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug("Ollama async check failed: %s", exc)
|
||||
result = DependencyStatus(
|
||||
name="Ollama AI",
|
||||
status="unavailable",
|
||||
@@ -133,13 +137,9 @@ def _check_lightning() -> DependencyStatus:
|
||||
def _check_sqlite() -> DependencyStatus:
|
||||
"""Check SQLite database status."""
|
||||
try:
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
db_path = Path(settings.repo_root) / "data" / "timmy.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("SELECT 1")
|
||||
conn.close()
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
return DependencyStatus(
|
||||
name="SQLite Database",
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from dashboard.templating import templates
|
||||
from timmy.memory.vector_store import (
|
||||
from timmy.memory_system import (
|
||||
delete_memory,
|
||||
get_memory_stats,
|
||||
recall_personal_facts_with_ids,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""System-level dashboard routes (ledger, upgrades, etc.)."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from config import settings
|
||||
from dashboard.templating import templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -144,5 +146,83 @@ async def api_notifications():
|
||||
for e in events
|
||||
]
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug("System events fetch error: %s", exc)
|
||||
return JSONResponse([])
|
||||
|
||||
|
||||
@router.get("/api/briefing/status", response_class=JSONResponse)
|
||||
async def api_briefing_status():
|
||||
"""Return briefing status including pending approvals and last generated time."""
|
||||
from timmy import approvals
|
||||
from timmy.briefing import engine as briefing_engine
|
||||
|
||||
pending = approvals.list_pending()
|
||||
pending_count = len(pending)
|
||||
|
||||
last_generated = None
|
||||
try:
|
||||
cached = briefing_engine.get_cached()
|
||||
if cached:
|
||||
last_generated = cached.generated_at.isoformat()
|
||||
except Exception:
|
||||
logger.debug("Failed to read briefing cache")
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ok",
|
||||
"pending_approvals": pending_count,
|
||||
"last_generated": last_generated,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/memory/status", response_class=JSONResponse)
|
||||
async def api_memory_status():
|
||||
"""Return memory database status including file info and indexed files count."""
|
||||
from timmy.memory_system import get_memory_stats
|
||||
|
||||
db_path = Path(settings.repo_root) / "data" / "memory.db"
|
||||
db_exists = db_path.exists()
|
||||
db_size = db_path.stat().st_size if db_exists else 0
|
||||
|
||||
try:
|
||||
stats = get_memory_stats()
|
||||
indexed_files = stats.get("total_entries", 0)
|
||||
except Exception:
|
||||
logger.debug("Failed to get memory stats")
|
||||
indexed_files = 0
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ok",
|
||||
"db_exists": db_exists,
|
||||
"db_size_bytes": db_size,
|
||||
"indexed_files": indexed_files,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/swarm/status", response_class=JSONResponse)
|
||||
async def api_swarm_status():
|
||||
"""Return swarm worker status and pending tasks count."""
|
||||
from dashboard.routes.tasks import _get_db
|
||||
|
||||
pending_tasks = 0
|
||||
try:
|
||||
with _get_db() as db:
|
||||
row = db.execute(
|
||||
"SELECT COUNT(*) as cnt FROM tasks WHERE status IN ('pending_approval','approved')"
|
||||
).fetchone()
|
||||
pending_tasks = row["cnt"] if row else 0
|
||||
except Exception:
|
||||
logger.debug("Failed to count pending tasks")
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ok",
|
||||
"active_workers": 0,
|
||||
"pending_tasks": pending_tasks,
|
||||
"message": "Swarm monitoring endpoint",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from contextlib import closing, contextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -35,26 +37,27 @@ VALID_STATUSES = {
|
||||
VALID_PRIORITIES = {"low", "normal", "high", "urgent"}
|
||||
|
||||
|
||||
def _get_db() -> sqlite3.Connection:
|
||||
@contextmanager
|
||||
def _get_db() -> Generator[sqlite3.Connection, None, None]:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'pending_approval',
|
||||
priority TEXT DEFAULT 'normal',
|
||||
assigned_to TEXT DEFAULT '',
|
||||
created_by TEXT DEFAULT 'operator',
|
||||
result TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'pending_approval',
|
||||
priority TEXT DEFAULT 'normal',
|
||||
assigned_to TEXT DEFAULT '',
|
||||
created_by TEXT DEFAULT 'operator',
|
||||
result TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
yield conn
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row) -> dict:
|
||||
@@ -101,8 +104,7 @@ class _TaskView:
|
||||
@router.get("/tasks", response_class=HTMLResponse)
|
||||
async def tasks_page(request: Request):
|
||||
"""Render the main task queue page with 3-column layout."""
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
pending = [
|
||||
_TaskView(_row_to_dict(r))
|
||||
for r in db.execute(
|
||||
@@ -121,8 +123,6 @@ async def tasks_page(request: Request):
|
||||
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
|
||||
).fetchall()
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -145,13 +145,10 @@ async def tasks_page(request: Request):
|
||||
|
||||
@router.get("/tasks/pending", response_class=HTMLResponse)
|
||||
async def tasks_pending(request: Request):
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
finally:
|
||||
db.close()
|
||||
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
|
||||
parts = []
|
||||
for task in tasks:
|
||||
@@ -167,13 +164,10 @@ async def tasks_pending(request: Request):
|
||||
|
||||
@router.get("/tasks/active", response_class=HTMLResponse)
|
||||
async def tasks_active(request: Request):
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
finally:
|
||||
db.close()
|
||||
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
|
||||
parts = []
|
||||
for task in tasks:
|
||||
@@ -189,13 +183,10 @@ async def tasks_active(request: Request):
|
||||
|
||||
@router.get("/tasks/completed", response_class=HTMLResponse)
|
||||
async def tasks_completed(request: Request):
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
|
||||
).fetchall()
|
||||
finally:
|
||||
db.close()
|
||||
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
|
||||
parts = []
|
||||
for task in tasks:
|
||||
@@ -231,16 +222,13 @@ async def create_task_form(
|
||||
now = datetime.utcnow().isoformat()
|
||||
priority = priority if priority in VALID_PRIORITIES else "normal"
|
||||
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
db.execute(
|
||||
"INSERT INTO tasks (id, title, description, priority, assigned_to, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(task_id, title, description, priority, assigned_to, now),
|
||||
)
|
||||
db.commit()
|
||||
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
task = _TaskView(_row_to_dict(row))
|
||||
return templates.TemplateResponse(request, "partials/task_card.html", {"task": task})
|
||||
@@ -283,16 +271,13 @@ async def modify_task(
|
||||
title: str = Form(...),
|
||||
description: str = Form(""),
|
||||
):
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
db.execute(
|
||||
"UPDATE tasks SET title=?, description=? WHERE id=?",
|
||||
(title, description, task_id),
|
||||
)
|
||||
db.commit()
|
||||
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
finally:
|
||||
db.close()
|
||||
if not row:
|
||||
raise HTTPException(404, "Task not found")
|
||||
task = _TaskView(_row_to_dict(row))
|
||||
@@ -304,16 +289,13 @@ async def _set_status(request: Request, task_id: str, new_status: str):
|
||||
completed_at = (
|
||||
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
||||
)
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
db.execute(
|
||||
"UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
|
||||
(new_status, completed_at, task_id),
|
||||
)
|
||||
db.commit()
|
||||
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
finally:
|
||||
db.close()
|
||||
if not row:
|
||||
raise HTTPException(404, "Task not found")
|
||||
task = _TaskView(_row_to_dict(row))
|
||||
@@ -339,8 +321,7 @@ async def api_create_task(request: Request):
|
||||
if priority not in VALID_PRIORITIES:
|
||||
priority = "normal"
|
||||
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
db.execute(
|
||||
"INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -356,8 +337,6 @@ async def api_create_task(request: Request):
|
||||
)
|
||||
db.commit()
|
||||
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return JSONResponse(_row_to_dict(row), status_code=201)
|
||||
|
||||
@@ -365,11 +344,8 @@ async def api_create_task(request: Request):
|
||||
@router.get("/api/tasks", response_class=JSONResponse)
|
||||
async def api_list_tasks():
|
||||
"""List all tasks as JSON."""
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
rows = db.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall()
|
||||
finally:
|
||||
db.close()
|
||||
return JSONResponse([_row_to_dict(r) for r in rows])
|
||||
|
||||
|
||||
@@ -384,16 +360,13 @@ async def api_update_status(task_id: str, request: Request):
|
||||
completed_at = (
|
||||
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
||||
)
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
db.execute(
|
||||
"UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
|
||||
(new_status, completed_at, task_id),
|
||||
)
|
||||
db.commit()
|
||||
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
finally:
|
||||
db.close()
|
||||
if not row:
|
||||
raise HTTPException(404, "Task not found")
|
||||
return JSONResponse(_row_to_dict(row))
|
||||
@@ -402,12 +375,9 @@ async def api_update_status(task_id: str, request: Request):
|
||||
@router.delete("/api/tasks/{task_id}", response_class=JSONResponse)
|
||||
async def api_delete_task(task_id: str):
|
||||
"""Delete a task."""
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
cursor = db.execute("DELETE FROM tasks WHERE id=?", (task_id,))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(404, "Task not found")
|
||||
return JSONResponse({"success": True, "id": task_id})
|
||||
@@ -421,8 +391,7 @@ async def api_delete_task(task_id: str):
|
||||
@router.get("/api/queue/status", response_class=JSONResponse)
|
||||
async def queue_status(assigned_to: str = "default"):
|
||||
"""Return queue status for the chat panel's agent status indicator."""
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
running = db.execute(
|
||||
"SELECT * FROM tasks WHERE status='running' AND assigned_to=? LIMIT 1",
|
||||
(assigned_to,),
|
||||
@@ -431,8 +400,6 @@ async def queue_status(assigned_to: str = "default"):
|
||||
"SELECT COUNT(*) as cnt FROM tasks WHERE status IN ('pending_approval','approved') AND assigned_to=?",
|
||||
(assigned_to,),
|
||||
).fetchone()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if running:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -43,7 +43,8 @@ async def tts_status():
|
||||
"available": voice_tts.available,
|
||||
"voices": voice_tts.get_voices() if voice_tts.available else [],
|
||||
}
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug("Voice config error: %s", exc)
|
||||
return {"available": False, "voices": []}
|
||||
|
||||
|
||||
@@ -139,7 +140,8 @@ async def process_voice_input(
|
||||
|
||||
if voice_tts.available:
|
||||
voice_tts.speak(response_text)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug("Voice TTS error: %s", exc)
|
||||
pass
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from contextlib import closing, contextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -23,28 +25,29 @@ CATEGORIES = ["bug", "feature", "suggestion", "maintenance", "security"]
|
||||
VALID_STATUSES = {"submitted", "triaged", "approved", "in_progress", "completed", "rejected"}
|
||||
|
||||
|
||||
def _get_db() -> sqlite3.Connection:
|
||||
@contextmanager
|
||||
def _get_db() -> Generator[sqlite3.Connection, None, None]:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS work_orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
category TEXT DEFAULT 'suggestion',
|
||||
submitter TEXT DEFAULT 'dashboard',
|
||||
related_files TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'submitted',
|
||||
result TEXT DEFAULT '',
|
||||
rejection_reason TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS work_orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
category TEXT DEFAULT 'suggestion',
|
||||
submitter TEXT DEFAULT 'dashboard',
|
||||
related_files TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'submitted',
|
||||
result TEXT DEFAULT '',
|
||||
rejection_reason TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
yield conn
|
||||
|
||||
|
||||
class _EnumLike:
|
||||
@@ -104,14 +107,11 @@ def _query_wos(db, statuses):
|
||||
|
||||
@router.get("/work-orders/queue", response_class=HTMLResponse)
|
||||
async def work_orders_page(request: Request):
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
pending = _query_wos(db, ["submitted", "triaged"])
|
||||
active = _query_wos(db, ["approved", "in_progress"])
|
||||
completed = _query_wos(db, ["completed"])
|
||||
rejected = _query_wos(db, ["rejected"])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -148,8 +148,7 @@ async def submit_work_order(
|
||||
priority = priority if priority in PRIORITIES else "medium"
|
||||
category = category if category in CATEGORIES else "suggestion"
|
||||
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
db.execute(
|
||||
"INSERT INTO work_orders (id, title, description, priority, category, submitter, related_files, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -157,8 +156,6 @@ async def submit_work_order(
|
||||
)
|
||||
db.commit()
|
||||
row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
wo = _WOView(_row_to_dict(row))
|
||||
return templates.TemplateResponse(request, "partials/work_order_card.html", {"wo": wo})
|
||||
@@ -171,11 +168,8 @@ async def submit_work_order(
|
||||
|
||||
@router.get("/work-orders/queue/pending", response_class=HTMLResponse)
|
||||
async def pending_partial(request: Request):
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
wos = _query_wos(db, ["submitted", "triaged"])
|
||||
finally:
|
||||
db.close()
|
||||
if not wos:
|
||||
return HTMLResponse(
|
||||
'<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">'
|
||||
@@ -193,11 +187,8 @@ async def pending_partial(request: Request):
|
||||
|
||||
@router.get("/work-orders/queue/active", response_class=HTMLResponse)
|
||||
async def active_partial(request: Request):
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
wos = _query_wos(db, ["approved", "in_progress"])
|
||||
finally:
|
||||
db.close()
|
||||
if not wos:
|
||||
return HTMLResponse(
|
||||
'<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">'
|
||||
@@ -222,8 +213,7 @@ async def _update_status(request: Request, wo_id: str, new_status: str, **extra)
|
||||
completed_at = (
|
||||
datetime.utcnow().isoformat() if new_status in ("completed", "rejected") else None
|
||||
)
|
||||
db = _get_db()
|
||||
try:
|
||||
with _get_db() as db:
|
||||
sets = ["status=?", "completed_at=COALESCE(?, completed_at)"]
|
||||
vals = [new_status, completed_at]
|
||||
for col, val in extra.items():
|
||||
@@ -233,8 +223,6 @@ async def _update_status(request: Request, wo_id: str, new_status: str, **extra)
|
||||
db.execute(f"UPDATE work_orders SET {', '.join(sets)} WHERE id=?", vals)
|
||||
db.commit()
|
||||
row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone()
|
||||
finally:
|
||||
db.close()
|
||||
if not row:
|
||||
raise HTTPException(404, "Work order not found")
|
||||
wo = _WOView(_row_to_dict(row))
|
||||
|
||||
385
src/dashboard/routes/world.py
Normal file
385
src/dashboard/routes/world.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""Workshop world state API and WebSocket relay.
|
||||
|
||||
Serves Timmy's current presence state to the Workshop 3D renderer.
|
||||
The primary consumer is the browser on first load — before any
|
||||
WebSocket events arrive, the client needs a full state snapshot.
|
||||
|
||||
The ``/ws/world`` endpoint streams ``timmy_state`` messages whenever
|
||||
the heartbeat detects a state change. It also accepts ``visitor_message``
|
||||
frames from the 3D client and responds with ``timmy_speech`` barks.
|
||||
|
||||
Source of truth: ``~/.timmy/presence.json`` written by
|
||||
:class:`~timmy.workshop_state.WorkshopHeartbeat`.
|
||||
Falls back to a live ``get_state_dict()`` call if the file is stale
|
||||
or missing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from timmy.workshop_state import PRESENCE_FILE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/world", tags=["world"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket relay for live state changes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ws_clients: list[WebSocket] = []
|
||||
|
||||
_STALE_THRESHOLD = 90 # seconds — file older than this triggers live rebuild
|
||||
|
||||
# Recent conversation buffer — kept in memory for the Workshop overlay.
|
||||
# Stores the last _MAX_EXCHANGES (visitor_text, timmy_text) pairs.
|
||||
_MAX_EXCHANGES = 3
|
||||
_conversation: deque[dict] = deque(maxlen=_MAX_EXCHANGES)
|
||||
|
||||
_WORKSHOP_SESSION_ID = "workshop"
|
||||
|
||||
_HEARTBEAT_INTERVAL = 15 # seconds — ping to detect dead iPad/Safari connections
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversation grounding — commitment tracking (rescued from PR #408)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Patterns that indicate Timmy is committing to an action.
|
||||
_COMMITMENT_PATTERNS: list[re.Pattern[str]] = [
|
||||
re.compile(r"I'll (.+?)(?:\.|!|\?|$)", re.IGNORECASE),
|
||||
re.compile(r"I will (.+?)(?:\.|!|\?|$)", re.IGNORECASE),
|
||||
re.compile(r"[Ll]et me (.+?)(?:\.|!|\?|$)", re.IGNORECASE),
|
||||
]
|
||||
|
||||
# After this many messages without follow-up, surface open commitments.
|
||||
_REMIND_AFTER = 5
|
||||
_MAX_COMMITMENTS = 10
|
||||
|
||||
# In-memory list of open commitments.
|
||||
# Each entry: {"text": str, "created_at": float, "messages_since": int}
|
||||
_commitments: list[dict] = []
|
||||
|
||||
|
||||
def _extract_commitments(text: str) -> list[str]:
|
||||
"""Pull commitment phrases from Timmy's reply text."""
|
||||
found: list[str] = []
|
||||
for pattern in _COMMITMENT_PATTERNS:
|
||||
for match in pattern.finditer(text):
|
||||
phrase = match.group(1).strip()
|
||||
if len(phrase) > 5: # skip trivially short matches
|
||||
found.append(phrase[:120])
|
||||
return found
|
||||
|
||||
|
||||
def _record_commitments(reply: str) -> None:
|
||||
"""Scan a Timmy reply for commitments and store them."""
|
||||
for phrase in _extract_commitments(reply):
|
||||
# Avoid near-duplicate commitments
|
||||
if any(c["text"] == phrase for c in _commitments):
|
||||
continue
|
||||
_commitments.append({"text": phrase, "created_at": time.time(), "messages_since": 0})
|
||||
if len(_commitments) > _MAX_COMMITMENTS:
|
||||
_commitments.pop(0)
|
||||
|
||||
|
||||
def _tick_commitments() -> None:
|
||||
"""Increment messages_since for every open commitment."""
|
||||
for c in _commitments:
|
||||
c["messages_since"] += 1
|
||||
|
||||
|
||||
def _build_commitment_context() -> str:
|
||||
"""Return a grounding note if any commitments are overdue for follow-up."""
|
||||
overdue = [c for c in _commitments if c["messages_since"] >= _REMIND_AFTER]
|
||||
if not overdue:
|
||||
return ""
|
||||
lines = [f"- {c['text']}" for c in overdue]
|
||||
return (
|
||||
"[Open commitments Timmy made earlier — "
|
||||
"weave awareness naturally, don't list robotically]\n" + "\n".join(lines)
|
||||
)
|
||||
|
||||
|
||||
def close_commitment(index: int) -> bool:
|
||||
"""Remove a commitment by index. Returns True if removed."""
|
||||
if 0 <= index < len(_commitments):
|
||||
_commitments.pop(index)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_commitments() -> list[dict]:
|
||||
"""Return a copy of open commitments (for testing / API)."""
|
||||
return list(_commitments)
|
||||
|
||||
|
||||
def reset_commitments() -> None:
|
||||
"""Clear all commitments (for testing / session reset)."""
|
||||
_commitments.clear()
|
||||
|
||||
|
||||
# Conversation grounding — anchor to opening topic so Timmy doesn't drift.
|
||||
_ground_topic: str | None = None
|
||||
_ground_set_at: float = 0.0
|
||||
_GROUND_TTL = 300 # seconds of inactivity before the anchor expires
|
||||
|
||||
|
||||
def _read_presence_file() -> dict | None:
|
||||
"""Read presence.json if it exists and is fresh enough."""
|
||||
try:
|
||||
if not PRESENCE_FILE.exists():
|
||||
return None
|
||||
age = time.time() - PRESENCE_FILE.stat().st_mtime
|
||||
if age > _STALE_THRESHOLD:
|
||||
logger.debug("presence.json is stale (%.0fs old)", age)
|
||||
return None
|
||||
return json.loads(PRESENCE_FILE.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to read presence.json: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _build_world_state(presence: dict) -> dict:
|
||||
"""Transform presence dict into the world/state API response."""
|
||||
return {
|
||||
"timmyState": {
|
||||
"mood": presence.get("mood", "calm"),
|
||||
"activity": presence.get("current_focus", "idle"),
|
||||
"energy": presence.get("energy", 0.5),
|
||||
"confidence": presence.get("confidence", 0.7),
|
||||
},
|
||||
"familiar": presence.get("familiar"),
|
||||
"activeThreads": presence.get("active_threads", []),
|
||||
"recentEvents": presence.get("recent_events", []),
|
||||
"concerns": presence.get("concerns", []),
|
||||
"visitorPresent": False,
|
||||
"updatedAt": presence.get("liveness", datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")),
|
||||
"version": presence.get("version", 1),
|
||||
}
|
||||
|
||||
|
||||
def _get_current_state() -> dict:
|
||||
"""Build the current world-state dict from best available source."""
|
||||
presence = _read_presence_file()
|
||||
|
||||
if presence is None:
|
||||
try:
|
||||
from timmy.workshop_state import get_state_dict
|
||||
|
||||
presence = get_state_dict()
|
||||
except Exception as exc:
|
||||
logger.warning("Live state build failed: %s", exc)
|
||||
presence = {
|
||||
"version": 1,
|
||||
"liveness": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"mood": "calm",
|
||||
"current_focus": "",
|
||||
"active_threads": [],
|
||||
"recent_events": [],
|
||||
"concerns": [],
|
||||
}
|
||||
|
||||
return _build_world_state(presence)
|
||||
|
||||
|
||||
@router.get("/state")
|
||||
async def get_world_state() -> JSONResponse:
|
||||
"""Return Timmy's current world state for Workshop bootstrap.
|
||||
|
||||
Reads from ``~/.timmy/presence.json`` if fresh, otherwise
|
||||
rebuilds live from cognitive state.
|
||||
"""
|
||||
return JSONResponse(
|
||||
content=_get_current_state(),
|
||||
headers={"Cache-Control": "no-cache, no-store"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket endpoint — streams timmy_state changes to Workshop clients
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _heartbeat(websocket: WebSocket) -> None:
|
||||
"""Send periodic pings to detect dead connections (iPad resilience).
|
||||
|
||||
Safari suspends background tabs, killing the TCP socket silently.
|
||||
A 15-second ping ensures we notice within one interval.
|
||||
|
||||
Rescued from stale PR #399.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL)
|
||||
await websocket.send_text(json.dumps({"type": "ping"}))
|
||||
except Exception:
|
||||
logger.debug("Heartbeat stopped — connection gone")
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def world_ws(websocket: WebSocket) -> None:
|
||||
"""Accept a Workshop client and keep it alive for state broadcasts.
|
||||
|
||||
Sends a full ``world_state`` snapshot immediately on connect so the
|
||||
client never starts from a blank slate. Incoming frames are parsed
|
||||
as JSON — ``visitor_message`` triggers a bark response. A background
|
||||
heartbeat ping runs every 15 s to detect dead connections early.
|
||||
"""
|
||||
await websocket.accept()
|
||||
_ws_clients.append(websocket)
|
||||
logger.info("World WS connected — %d clients", len(_ws_clients))
|
||||
|
||||
# Send full world-state snapshot so client bootstraps instantly
|
||||
try:
|
||||
snapshot = _get_current_state()
|
||||
await websocket.send_text(json.dumps({"type": "world_state", **snapshot}))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to send WS snapshot: %s", exc)
|
||||
|
||||
ping_task = asyncio.create_task(_heartbeat(websocket))
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
await _handle_client_message(raw)
|
||||
except Exception:
|
||||
logger.debug("WebSocket receive loop ended")
|
||||
finally:
|
||||
ping_task.cancel()
|
||||
if websocket in _ws_clients:
|
||||
_ws_clients.remove(websocket)
|
||||
logger.info("World WS disconnected — %d clients", len(_ws_clients))
|
||||
|
||||
|
||||
async def _broadcast(message: str) -> None:
|
||||
"""Send *message* to every connected Workshop client, pruning dead ones."""
|
||||
dead: list[WebSocket] = []
|
||||
for ws in _ws_clients:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
except Exception:
|
||||
logger.debug("Pruning dead WebSocket client")
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
if ws in _ws_clients:
|
||||
_ws_clients.remove(ws)
|
||||
|
||||
|
||||
async def broadcast_world_state(presence: dict) -> None:
|
||||
"""Broadcast a ``timmy_state`` message to all connected Workshop clients.
|
||||
|
||||
Called by :class:`~timmy.workshop_state.WorkshopHeartbeat` via its
|
||||
``on_change`` callback.
|
||||
"""
|
||||
state = _build_world_state(presence)
|
||||
await _broadcast(json.dumps({"type": "timmy_state", **state["timmyState"]}))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Visitor chat — bark engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _handle_client_message(raw: str) -> None:
|
||||
"""Dispatch an incoming WebSocket frame from the Workshop client."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return # ignore non-JSON keep-alive pings
|
||||
|
||||
if data.get("type") == "visitor_message":
|
||||
text = (data.get("text") or "").strip()
|
||||
if text:
|
||||
task = asyncio.create_task(_bark_and_broadcast(text))
|
||||
task.add_done_callback(_log_bark_failure)
|
||||
|
||||
|
||||
def _log_bark_failure(task: asyncio.Task) -> None:
|
||||
"""Log unhandled exceptions from fire-and-forget bark tasks."""
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.error("Bark task failed: %s", exc)
|
||||
|
||||
|
||||
def reset_conversation_ground() -> None:
|
||||
"""Clear the conversation grounding anchor (e.g. after inactivity)."""
|
||||
global _ground_topic, _ground_set_at
|
||||
_ground_topic = None
|
||||
_ground_set_at = 0.0
|
||||
|
||||
|
||||
def _refresh_ground(visitor_text: str) -> None:
|
||||
"""Set or refresh the conversation grounding anchor.
|
||||
|
||||
The first visitor message in a session (or after the TTL expires)
|
||||
becomes the anchor topic. Subsequent messages are grounded against it.
|
||||
"""
|
||||
global _ground_topic, _ground_set_at
|
||||
now = time.time()
|
||||
if _ground_topic is None or (now - _ground_set_at) > _GROUND_TTL:
|
||||
_ground_topic = visitor_text[:120]
|
||||
logger.debug("Ground topic set: %s", _ground_topic)
|
||||
_ground_set_at = now
|
||||
|
||||
|
||||
async def _bark_and_broadcast(visitor_text: str) -> None:
|
||||
"""Generate a bark response and broadcast it to all Workshop clients."""
|
||||
await _broadcast(json.dumps({"type": "timmy_thinking"}))
|
||||
|
||||
# Notify Pip that a visitor spoke
|
||||
try:
|
||||
from timmy.familiar import pip_familiar
|
||||
|
||||
pip_familiar.on_event("visitor_spoke")
|
||||
except Exception:
|
||||
logger.debug("Pip familiar notification failed (optional)")
|
||||
|
||||
_refresh_ground(visitor_text)
|
||||
_tick_commitments()
|
||||
reply = await _generate_bark(visitor_text)
|
||||
_record_commitments(reply)
|
||||
|
||||
_conversation.append({"visitor": visitor_text, "timmy": reply})
|
||||
|
||||
await _broadcast(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "timmy_speech",
|
||||
"text": reply,
|
||||
"recentExchanges": list(_conversation),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _generate_bark(visitor_text: str) -> str:
|
||||
"""Generate a short in-character bark response.
|
||||
|
||||
Uses the existing Timmy session with a dedicated workshop session ID.
|
||||
When a grounding anchor exists, the opening topic is prepended so the
|
||||
model stays on-topic across long sessions.
|
||||
Gracefully degrades to a canned response if inference fails.
|
||||
"""
|
||||
try:
|
||||
from timmy import session as _session
|
||||
|
||||
grounded = visitor_text
|
||||
commitment_ctx = _build_commitment_context()
|
||||
if commitment_ctx:
|
||||
grounded = f"{commitment_ctx}\n{grounded}"
|
||||
if _ground_topic and visitor_text != _ground_topic:
|
||||
grounded = f"[Workshop conversation topic: {_ground_topic}]\n{grounded}"
|
||||
response = await _session.chat(grounded, session_id=_WORKSHOP_SESSION_ID)
|
||||
return response
|
||||
except Exception as exc:
|
||||
logger.warning("Bark generation failed: %s", exc)
|
||||
return "Hmm, my thoughts are a bit tangled right now."
|
||||
@@ -1,134 +1,5 @@
|
||||
"""Persistent chat message store backed by SQLite.
|
||||
"""Backward-compatible re-export — canonical home is infrastructure.chat_store."""
|
||||
|
||||
Provides the same API as the original in-memory MessageLog so all callers
|
||||
(dashboard routes, chat_api, thinking, briefing) work without changes.
|
||||
from infrastructure.chat_store import DB_PATH, MAX_MESSAGES, Message, MessageLog, message_log
|
||||
|
||||
Data lives in ``data/chat.db`` — survives server restarts.
|
||||
A configurable retention policy (default 500 messages) keeps the DB lean.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# ── Data dir — resolved relative to repo root (two levels up from this file) ──
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DB_PATH: Path = _REPO_ROOT / "data" / "chat.db"
|
||||
|
||||
# Maximum messages to retain (oldest pruned on append)
|
||||
MAX_MESSAGES: int = 500
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
role: str # "user" | "agent" | "error"
|
||||
content: str
|
||||
timestamp: str
|
||||
source: str = "browser" # "browser" | "api" | "telegram" | "discord" | "system"
|
||||
|
||||
|
||||
def _get_conn(db_path: Path | None = None) -> sqlite3.Connection:
|
||||
"""Open (or create) the chat database and ensure schema exists."""
|
||||
path = db_path or DB_PATH
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'browser'
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
class MessageLog:
|
||||
"""SQLite-backed chat history — drop-in replacement for the old in-memory list."""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
self._db_path = db_path or DB_PATH
|
||||
self._lock = threading.Lock()
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
|
||||
# Lazy connection — opened on first use, not at import time.
|
||||
def _ensure_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
self._conn = _get_conn(self._db_path)
|
||||
return self._conn
|
||||
|
||||
def append(self, role: str, content: str, timestamp: str, source: str = "browser") -> None:
|
||||
with self._lock:
|
||||
conn = self._ensure_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO chat_messages (role, content, timestamp, source) VALUES (?, ?, ?, ?)",
|
||||
(role, content, timestamp, source),
|
||||
)
|
||||
conn.commit()
|
||||
self._prune(conn)
|
||||
|
||||
def all(self) -> list[Message]:
|
||||
with self._lock:
|
||||
conn = self._ensure_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT role, content, timestamp, source FROM chat_messages ORDER BY id"
|
||||
).fetchall()
|
||||
return [
|
||||
Message(
|
||||
role=r["role"], content=r["content"], timestamp=r["timestamp"], source=r["source"]
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def recent(self, limit: int = 50) -> list[Message]:
|
||||
"""Return the *limit* most recent messages (oldest-first)."""
|
||||
with self._lock:
|
||||
conn = self._ensure_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT role, content, timestamp, source FROM chat_messages "
|
||||
"ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [
|
||||
Message(
|
||||
role=r["role"], content=r["content"], timestamp=r["timestamp"], source=r["source"]
|
||||
)
|
||||
for r in reversed(rows)
|
||||
]
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
conn = self._ensure_conn()
|
||||
conn.execute("DELETE FROM chat_messages")
|
||||
conn.commit()
|
||||
|
||||
def _prune(self, conn: sqlite3.Connection) -> None:
|
||||
"""Keep at most MAX_MESSAGES rows, deleting the oldest."""
|
||||
count = conn.execute("SELECT COUNT(*) FROM chat_messages").fetchone()[0]
|
||||
if count > MAX_MESSAGES:
|
||||
excess = count - MAX_MESSAGES
|
||||
conn.execute(
|
||||
"DELETE FROM chat_messages WHERE id IN "
|
||||
"(SELECT id FROM chat_messages ORDER BY id LIMIT ?)",
|
||||
(excess,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
conn = self._ensure_conn()
|
||||
return conn.execute("SELECT COUNT(*) FROM chat_messages").fetchone()[0]
|
||||
|
||||
|
||||
# Module-level singleton shared across the app
|
||||
message_log = MessageLog()
|
||||
__all__ = ["DB_PATH", "MAX_MESSAGES", "Message", "MessageLog", "message_log"]
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{% else %}
|
||||
<div class="chat-message agent">
|
||||
<div class="msg-meta">TIMMY // SYSTEM</div>
|
||||
<div class="msg-body">Mission Control initialized. Timmy ready — awaiting input.</div>
|
||||
<div class="msg-body">{{ welcome_message | e }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<script>if(typeof scrollChat==='function'){setTimeout(scrollChat,50);}</script>
|
||||
|
||||
153
src/infrastructure/chat_store.py
Normal file
153
src/infrastructure/chat_store.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Persistent chat message store backed by SQLite.
|
||||
|
||||
Provides the same API as the original in-memory MessageLog so all callers
|
||||
(dashboard routes, chat_api, thinking, briefing) work without changes.
|
||||
|
||||
Data lives in ``data/chat.db`` — survives server restarts.
|
||||
A configurable retention policy (default 500 messages) keeps the DB lean.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
from contextlib import closing, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# ── Data dir — resolved relative to repo root (three levels up from this file) ──
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
DB_PATH: Path = _REPO_ROOT / "data" / "chat.db"
|
||||
|
||||
# Maximum messages to retain (oldest pruned on append)
|
||||
MAX_MESSAGES: int = 500
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
role: str # "user" | "agent" | "error"
|
||||
content: str
|
||||
timestamp: str
|
||||
source: str = "browser" # "browser" | "api" | "telegram" | "discord" | "system"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _get_conn(db_path: Path | None = None) -> Generator[sqlite3.Connection, None, None]:
|
||||
"""Open (or create) the chat database and ensure schema exists."""
|
||||
path = db_path or DB_PATH
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with closing(sqlite3.connect(str(path), check_same_thread=False)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'browser'
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
yield conn
|
||||
|
||||
|
||||
class MessageLog:
|
||||
"""SQLite-backed chat history — drop-in replacement for the old in-memory list."""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
self._db_path = db_path or DB_PATH
|
||||
self._lock = threading.Lock()
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
|
||||
# Lazy connection — opened on first use, not at import time.
|
||||
def _ensure_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
# Open a persistent connection for the class instance
|
||||
path = self._db_path or DB_PATH
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'browser'
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
self._conn = conn
|
||||
return self._conn
|
||||
|
||||
def append(self, role: str, content: str, timestamp: str, source: str = "browser") -> None:
|
||||
with self._lock:
|
||||
conn = self._ensure_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO chat_messages (role, content, timestamp, source) VALUES (?, ?, ?, ?)",
|
||||
(role, content, timestamp, source),
|
||||
)
|
||||
conn.commit()
|
||||
self._prune(conn)
|
||||
|
||||
def all(self) -> list[Message]:
|
||||
with self._lock:
|
||||
conn = self._ensure_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT role, content, timestamp, source FROM chat_messages ORDER BY id"
|
||||
).fetchall()
|
||||
return [
|
||||
Message(
|
||||
role=r["role"], content=r["content"], timestamp=r["timestamp"], source=r["source"]
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def recent(self, limit: int = 50) -> list[Message]:
|
||||
"""Return the *limit* most recent messages (oldest-first)."""
|
||||
with self._lock:
|
||||
conn = self._ensure_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT role, content, timestamp, source FROM chat_messages "
|
||||
"ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [
|
||||
Message(
|
||||
role=r["role"], content=r["content"], timestamp=r["timestamp"], source=r["source"]
|
||||
)
|
||||
for r in reversed(rows)
|
||||
]
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
conn = self._ensure_conn()
|
||||
conn.execute("DELETE FROM chat_messages")
|
||||
conn.commit()
|
||||
|
||||
def _prune(self, conn: sqlite3.Connection) -> None:
|
||||
"""Keep at most MAX_MESSAGES rows, deleting the oldest."""
|
||||
count = conn.execute("SELECT COUNT(*) FROM chat_messages").fetchone()[0]
|
||||
if count > MAX_MESSAGES:
|
||||
excess = count - MAX_MESSAGES
|
||||
conn.execute(
|
||||
"DELETE FROM chat_messages WHERE id IN "
|
||||
"(SELECT id FROM chat_messages ORDER BY id LIMIT ?)",
|
||||
(excess,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
conn = self._ensure_conn()
|
||||
return conn.execute("SELECT COUNT(*) FROM chat_messages").fetchone()[0]
|
||||
|
||||
|
||||
# Module-level singleton shared across the app
|
||||
message_log = MessageLog()
|
||||
@@ -22,6 +22,14 @@ logger = logging.getLogger(__name__)
|
||||
# In-memory dedup cache: hash -> last_seen timestamp
|
||||
_dedup_cache: dict[str, datetime] = {}
|
||||
|
||||
_error_recorder = None
|
||||
|
||||
|
||||
def register_error_recorder(fn):
|
||||
"""Register a callback for recording errors to session log."""
|
||||
global _error_recorder
|
||||
_error_recorder = fn
|
||||
|
||||
|
||||
def _stack_hash(exc: Exception) -> str:
|
||||
"""Create a stable hash of the exception type + traceback locations.
|
||||
@@ -87,7 +95,8 @@ def _get_git_context() -> dict:
|
||||
).stdout.strip()
|
||||
|
||||
return {"branch": branch, "commit": commit}
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Git info capture error: %s", exc)
|
||||
return {"branch": "unknown", "commit": "unknown"}
|
||||
|
||||
|
||||
@@ -199,7 +208,8 @@ def capture_error(
|
||||
"title": title[:100],
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Bug report screenshot error: %s", exc)
|
||||
pass
|
||||
|
||||
except Exception as task_exc:
|
||||
@@ -214,19 +224,18 @@ def capture_error(
|
||||
message=f"{type(exc).__name__} in {source}: {str(exc)[:80]}",
|
||||
category="system",
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Bug report notification error: %s", exc)
|
||||
pass
|
||||
|
||||
# 4. Record in session logger
|
||||
try:
|
||||
from timmy.session_logger import get_session_logger
|
||||
|
||||
session_logger = get_session_logger()
|
||||
session_logger.record_error(
|
||||
error=f"{type(exc).__name__}: {str(exc)}",
|
||||
context=source,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# 4. Record in session logger (via registered callback)
|
||||
if _error_recorder is not None:
|
||||
try:
|
||||
_error_recorder(
|
||||
error=f"{type(exc).__name__}: {str(exc)}",
|
||||
context=source,
|
||||
)
|
||||
except Exception as log_exc:
|
||||
logger.warning("Bug report session logging error: %s", log_exc)
|
||||
|
||||
return task_id
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
"""Event Broadcaster - bridges event_log to WebSocket clients.
|
||||
|
||||
When events are logged, they are broadcast to all connected dashboard clients
|
||||
via WebSocket for real-time activity feed updates.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from swarm.event_log import EventLogEntry
|
||||
except ImportError:
|
||||
EventLogEntry = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventBroadcaster:
|
||||
"""Broadcasts events to WebSocket clients.
|
||||
|
||||
Usage:
|
||||
from infrastructure.events.broadcaster import event_broadcaster
|
||||
event_broadcaster.broadcast(event)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._ws_manager: Optional = None
|
||||
|
||||
def _get_ws_manager(self):
|
||||
"""Lazy import to avoid circular deps."""
|
||||
if self._ws_manager is None:
|
||||
try:
|
||||
from infrastructure.ws_manager.handler import ws_manager
|
||||
|
||||
self._ws_manager = ws_manager
|
||||
except Exception as exc:
|
||||
logger.debug("WebSocket manager not available: %s", exc)
|
||||
return self._ws_manager
|
||||
|
||||
async def broadcast(self, event: EventLogEntry) -> int:
|
||||
"""Broadcast an event to all connected WebSocket clients.
|
||||
|
||||
Args:
|
||||
event: The event to broadcast
|
||||
|
||||
Returns:
|
||||
Number of clients notified
|
||||
"""
|
||||
ws_manager = self._get_ws_manager()
|
||||
if not ws_manager:
|
||||
return 0
|
||||
|
||||
# Build message payload
|
||||
payload = {
|
||||
"type": "event",
|
||||
"payload": {
|
||||
"id": event.id,
|
||||
"event_type": event.event_type.value,
|
||||
"source": event.source,
|
||||
"task_id": event.task_id,
|
||||
"agent_id": event.agent_id,
|
||||
"timestamp": event.timestamp,
|
||||
"data": event.data,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
# Broadcast to all connected clients
|
||||
count = await ws_manager.broadcast_json(payload)
|
||||
logger.debug("Broadcasted event %s to %d clients", event.id[:8], count)
|
||||
return count
|
||||
except Exception as exc:
|
||||
logger.error("Failed to broadcast event: %s", exc)
|
||||
return 0
|
||||
|
||||
def broadcast_sync(self, event: EventLogEntry) -> None:
|
||||
"""Synchronous wrapper for broadcast.
|
||||
|
||||
Use this from synchronous code - it schedules the async broadcast
|
||||
in the event loop if one is running.
|
||||
"""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# Schedule in background, don't wait
|
||||
asyncio.create_task(self.broadcast(event))
|
||||
except RuntimeError:
|
||||
# No event loop running, skip broadcast
|
||||
pass
|
||||
|
||||
|
||||
# Global singleton
|
||||
event_broadcaster = EventBroadcaster()
|
||||
|
||||
|
||||
# Event type to icon/emoji mapping
|
||||
EVENT_ICONS = {
|
||||
"task.created": "📝",
|
||||
"task.bidding": "⏳",
|
||||
"task.assigned": "👤",
|
||||
"task.started": "▶️",
|
||||
"task.completed": "✅",
|
||||
"task.failed": "❌",
|
||||
"agent.joined": "🟢",
|
||||
"agent.left": "🔴",
|
||||
"agent.status_changed": "🔄",
|
||||
"bid.submitted": "💰",
|
||||
"auction.closed": "🏁",
|
||||
"tool.called": "🔧",
|
||||
"tool.completed": "⚙️",
|
||||
"tool.failed": "💥",
|
||||
"system.error": "⚠️",
|
||||
"system.warning": "🔶",
|
||||
"system.info": "ℹ️",
|
||||
"error.captured": "🐛",
|
||||
"bug_report.created": "📋",
|
||||
}
|
||||
|
||||
EVENT_LABELS = {
|
||||
"task.created": "New task",
|
||||
"task.bidding": "Bidding open",
|
||||
"task.assigned": "Task assigned",
|
||||
"task.started": "Task started",
|
||||
"task.completed": "Task completed",
|
||||
"task.failed": "Task failed",
|
||||
"agent.joined": "Agent joined",
|
||||
"agent.left": "Agent left",
|
||||
"agent.status_changed": "Status changed",
|
||||
"bid.submitted": "Bid submitted",
|
||||
"auction.closed": "Auction closed",
|
||||
"tool.called": "Tool called",
|
||||
"tool.completed": "Tool completed",
|
||||
"tool.failed": "Tool failed",
|
||||
"system.error": "Error",
|
||||
"system.warning": "Warning",
|
||||
"system.info": "Info",
|
||||
"error.captured": "Error captured",
|
||||
"bug_report.created": "Bug report filed",
|
||||
}
|
||||
|
||||
|
||||
def get_event_icon(event_type: str) -> str:
|
||||
"""Get emoji icon for event type."""
|
||||
return EVENT_ICONS.get(event_type, "•")
|
||||
|
||||
|
||||
def get_event_label(event_type: str) -> str:
|
||||
"""Get human-readable label for event type."""
|
||||
return EVENT_LABELS.get(event_type, event_type)
|
||||
|
||||
|
||||
def format_event_for_display(event: EventLogEntry) -> dict:
|
||||
"""Format event for display in activity feed.
|
||||
|
||||
Returns dict with display-friendly fields.
|
||||
"""
|
||||
data = event.data or {}
|
||||
|
||||
# Build description based on event type
|
||||
description = ""
|
||||
if event.event_type.value == "task.created":
|
||||
desc = data.get("description", "")
|
||||
description = desc[:60] + "..." if len(desc) > 60 else desc
|
||||
elif event.event_type.value == "task.assigned":
|
||||
agent = event.agent_id[:8] if event.agent_id else "unknown"
|
||||
bid = data.get("bid_sats", "?")
|
||||
description = f"to {agent} ({bid} sats)"
|
||||
elif event.event_type.value == "bid.submitted":
|
||||
bid = data.get("bid_sats", "?")
|
||||
description = f"{bid} sats"
|
||||
elif event.event_type.value == "agent.joined":
|
||||
persona = data.get("persona_id", "")
|
||||
description = f"Persona: {persona}" if persona else "New agent"
|
||||
else:
|
||||
# Generic: use any string data
|
||||
for key in ["message", "reason", "description"]:
|
||||
if key in data:
|
||||
val = str(data[key])
|
||||
description = val[:60] + "..." if len(val) > 60 else val
|
||||
break
|
||||
|
||||
return {
|
||||
"id": event.id,
|
||||
"icon": get_event_icon(event.event_type.value),
|
||||
"label": get_event_label(event.event_type.value),
|
||||
"type": event.event_type.value,
|
||||
"source": event.source,
|
||||
"description": description,
|
||||
"timestamp": event.timestamp,
|
||||
"time_short": event.timestamp[11:19] if event.timestamp else "",
|
||||
"task_id": event.task_id,
|
||||
"agent_id": event.agent_id,
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections.abc import Callable, Coroutine
|
||||
from collections.abc import Callable, Coroutine, Generator
|
||||
from contextlib import closing, contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -99,51 +100,48 @@ class EventBus:
|
||||
if self._persistence_db_path is None:
|
||||
return
|
||||
self._persistence_db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(self._persistence_db_path))
|
||||
try:
|
||||
with closing(sqlite3.connect(str(self._persistence_db_path))) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.executescript(_EVENTS_SCHEMA)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _get_persistence_conn(self) -> sqlite3.Connection | None:
|
||||
@contextmanager
|
||||
def _get_persistence_conn(self) -> Generator[sqlite3.Connection | None, None, None]:
|
||||
"""Get a connection to the persistence database."""
|
||||
if self._persistence_db_path is None:
|
||||
return None
|
||||
conn = sqlite3.connect(str(self._persistence_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
return conn
|
||||
yield None
|
||||
return
|
||||
with closing(sqlite3.connect(str(self._persistence_db_path))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
yield conn
|
||||
|
||||
def _persist_event(self, event: Event) -> None:
|
||||
"""Write an event to the persistence database."""
|
||||
conn = self._get_persistence_conn()
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
task_id = event.data.get("task_id", "")
|
||||
agent_id = event.data.get("agent_id", "")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO events "
|
||||
"(id, event_type, source, task_id, agent_id, data, timestamp) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
event.id,
|
||||
event.type,
|
||||
event.source,
|
||||
task_id,
|
||||
agent_id,
|
||||
json.dumps(event.data),
|
||||
event.timestamp,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to persist event: %s", exc)
|
||||
finally:
|
||||
conn.close()
|
||||
with self._get_persistence_conn() as conn:
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
task_id = event.data.get("task_id", "")
|
||||
agent_id = event.data.get("agent_id", "")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO events "
|
||||
"(id, event_type, source, task_id, agent_id, data, timestamp) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
event.id,
|
||||
event.type,
|
||||
event.source,
|
||||
task_id,
|
||||
agent_id,
|
||||
json.dumps(event.data),
|
||||
event.timestamp,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to persist event: %s", exc)
|
||||
|
||||
# ── Replay ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -165,45 +163,43 @@ class EventBus:
|
||||
Returns:
|
||||
List of Event objects from persistent storage.
|
||||
"""
|
||||
conn = self._get_persistence_conn()
|
||||
if conn is None:
|
||||
return []
|
||||
with self._get_persistence_conn() as conn:
|
||||
if conn is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
conditions = []
|
||||
params: list = []
|
||||
try:
|
||||
conditions = []
|
||||
params: list = []
|
||||
|
||||
if event_type:
|
||||
conditions.append("event_type = ?")
|
||||
params.append(event_type)
|
||||
if source:
|
||||
conditions.append("source = ?")
|
||||
params.append(source)
|
||||
if task_id:
|
||||
conditions.append("task_id = ?")
|
||||
params.append(task_id)
|
||||
if event_type:
|
||||
conditions.append("event_type = ?")
|
||||
params.append(event_type)
|
||||
if source:
|
||||
conditions.append("source = ?")
|
||||
params.append(source)
|
||||
if task_id:
|
||||
conditions.append("task_id = ?")
|
||||
params.append(task_id)
|
||||
|
||||
where = " AND ".join(conditions) if conditions else "1=1"
|
||||
sql = f"SELECT * FROM events WHERE {where} ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
where = " AND ".join(conditions) if conditions else "1=1"
|
||||
sql = f"SELECT * FROM events WHERE {where} ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
|
||||
return [
|
||||
Event(
|
||||
id=row["id"],
|
||||
type=row["event_type"],
|
||||
source=row["source"],
|
||||
data=json.loads(row["data"]) if row["data"] else {},
|
||||
timestamp=row["timestamp"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to replay events: %s", exc)
|
||||
return []
|
||||
finally:
|
||||
conn.close()
|
||||
return [
|
||||
Event(
|
||||
id=row["id"],
|
||||
type=row["event_type"],
|
||||
source=row["source"],
|
||||
data=json.loads(row["data"]) if row["data"] else {},
|
||||
timestamp=row["timestamp"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to replay events: %s", exc)
|
||||
return []
|
||||
|
||||
# ── Subscribe / Publish ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ class ShellHand:
|
||||
)
|
||||
|
||||
latency = (time.time() - start) * 1000
|
||||
exit_code = proc.returncode or 0
|
||||
exit_code = proc.returncode if proc.returncode is not None else -1
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
|
||||
from config import settings
|
||||
from config import normalize_ollama_url, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -93,18 +93,6 @@ KNOWN_MODEL_CAPABILITIES: dict[str, set[ModelCapability]] = {
|
||||
ModelCapability.VISION,
|
||||
},
|
||||
# Qwen series
|
||||
"qwen3.5": {
|
||||
ModelCapability.TEXT,
|
||||
ModelCapability.TOOLS,
|
||||
ModelCapability.JSON,
|
||||
ModelCapability.STREAMING,
|
||||
},
|
||||
"qwen3.5:latest": {
|
||||
ModelCapability.TEXT,
|
||||
ModelCapability.TOOLS,
|
||||
ModelCapability.JSON,
|
||||
ModelCapability.STREAMING,
|
||||
},
|
||||
"qwen2.5": {
|
||||
ModelCapability.TEXT,
|
||||
ModelCapability.TOOLS,
|
||||
@@ -271,9 +259,8 @@ DEFAULT_FALLBACK_CHAINS: dict[ModelCapability, list[str]] = {
|
||||
],
|
||||
ModelCapability.TOOLS: [
|
||||
"llama3.1:8b-instruct", # Best tool use
|
||||
"qwen3.5:latest", # Qwen 3.5 — strong tool use
|
||||
"llama3.2:3b", # Smaller but capable
|
||||
"qwen2.5:7b", # Reliable fallback
|
||||
"llama3.2:3b", # Smaller but capable
|
||||
],
|
||||
ModelCapability.AUDIO: [
|
||||
# Audio models are less common in Ollama
|
||||
@@ -320,7 +307,7 @@ class MultiModalManager:
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
url = self.ollama_url.replace("localhost", "127.0.0.1")
|
||||
url = normalize_ollama_url(self.ollama_url)
|
||||
req = urllib.request.Request(
|
||||
f"{url}/api/tags",
|
||||
method="GET",
|
||||
@@ -475,7 +462,7 @@ class MultiModalManager:
|
||||
|
||||
logger.info("Pulling model: %s", model_name)
|
||||
|
||||
url = self.ollama_url.replace("localhost", "127.0.0.1")
|
||||
url = normalize_ollama_url(self.ollama_url)
|
||||
req = urllib.request.Request(
|
||||
f"{url}/api/pull",
|
||||
method="POST",
|
||||
|
||||
@@ -11,6 +11,8 @@ model roles (student, teacher, judge/PRM) run on dedicated resources.
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
from contextlib import closing, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
@@ -60,36 +62,37 @@ class CustomModel:
|
||||
self.registered_at = datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
@contextmanager
|
||||
def _get_conn() -> Generator[sqlite3.Connection, None, None]:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS custom_models (
|
||||
name TEXT PRIMARY KEY,
|
||||
format TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'general',
|
||||
context_window INTEGER NOT NULL DEFAULT 4096,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
registered_at TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
default_temperature REAL NOT NULL DEFAULT 0.7,
|
||||
max_tokens INTEGER NOT NULL DEFAULT 2048
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_model_assignments (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
model_name TEXT NOT NULL,
|
||||
assigned_at TEXT NOT NULL,
|
||||
FOREIGN KEY (model_name) REFERENCES custom_models(name)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS custom_models (
|
||||
name TEXT PRIMARY KEY,
|
||||
format TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'general',
|
||||
context_window INTEGER NOT NULL DEFAULT 4096,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
registered_at TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
default_temperature REAL NOT NULL DEFAULT 0.7,
|
||||
max_tokens INTEGER NOT NULL DEFAULT 2048
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_model_assignments (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
model_name TEXT NOT NULL,
|
||||
assigned_at TEXT NOT NULL,
|
||||
FOREIGN KEY (model_name) REFERENCES custom_models(name)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
yield conn
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
@@ -105,23 +108,22 @@ class ModelRegistry:
|
||||
def _load_from_db(self) -> None:
|
||||
"""Bootstrap cache from SQLite."""
|
||||
try:
|
||||
conn = _get_conn()
|
||||
for row in conn.execute("SELECT * FROM custom_models WHERE active = 1").fetchall():
|
||||
self._models[row["name"]] = CustomModel(
|
||||
name=row["name"],
|
||||
format=ModelFormat(row["format"]),
|
||||
path=row["path"],
|
||||
role=ModelRole(row["role"]),
|
||||
context_window=row["context_window"],
|
||||
description=row["description"],
|
||||
registered_at=row["registered_at"],
|
||||
active=bool(row["active"]),
|
||||
default_temperature=row["default_temperature"],
|
||||
max_tokens=row["max_tokens"],
|
||||
)
|
||||
for row in conn.execute("SELECT * FROM agent_model_assignments").fetchall():
|
||||
self._agent_assignments[row["agent_id"]] = row["model_name"]
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
for row in conn.execute("SELECT * FROM custom_models WHERE active = 1").fetchall():
|
||||
self._models[row["name"]] = CustomModel(
|
||||
name=row["name"],
|
||||
format=ModelFormat(row["format"]),
|
||||
path=row["path"],
|
||||
role=ModelRole(row["role"]),
|
||||
context_window=row["context_window"],
|
||||
description=row["description"],
|
||||
registered_at=row["registered_at"],
|
||||
active=bool(row["active"]),
|
||||
default_temperature=row["default_temperature"],
|
||||
max_tokens=row["max_tokens"],
|
||||
)
|
||||
for row in conn.execute("SELECT * FROM agent_model_assignments").fetchall():
|
||||
self._agent_assignments[row["agent_id"]] = row["model_name"]
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load model registry from DB: %s", exc)
|
||||
|
||||
@@ -130,29 +132,28 @@ class ModelRegistry:
|
||||
def register(self, model: CustomModel) -> CustomModel:
|
||||
"""Register a new custom model."""
|
||||
with self._lock:
|
||||
conn = _get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO custom_models
|
||||
(name, format, path, role, context_window, description,
|
||||
registered_at, active, default_temperature, max_tokens)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
model.name,
|
||||
model.format.value,
|
||||
model.path,
|
||||
model.role.value,
|
||||
model.context_window,
|
||||
model.description,
|
||||
model.registered_at,
|
||||
int(model.active),
|
||||
model.default_temperature,
|
||||
model.max_tokens,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO custom_models
|
||||
(name, format, path, role, context_window, description,
|
||||
registered_at, active, default_temperature, max_tokens)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
model.name,
|
||||
model.format.value,
|
||||
model.path,
|
||||
model.role.value,
|
||||
model.context_window,
|
||||
model.description,
|
||||
model.registered_at,
|
||||
int(model.active),
|
||||
model.default_temperature,
|
||||
model.max_tokens,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
self._models[model.name] = model
|
||||
logger.info("Registered model: %s (%s)", model.name, model.format.value)
|
||||
return model
|
||||
@@ -162,11 +163,10 @@ class ModelRegistry:
|
||||
with self._lock:
|
||||
if name not in self._models:
|
||||
return False
|
||||
conn = _get_conn()
|
||||
conn.execute("DELETE FROM custom_models WHERE name = ?", (name,))
|
||||
conn.execute("DELETE FROM agent_model_assignments WHERE model_name = ?", (name,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
conn.execute("DELETE FROM custom_models WHERE name = ?", (name,))
|
||||
conn.execute("DELETE FROM agent_model_assignments WHERE model_name = ?", (name,))
|
||||
conn.commit()
|
||||
del self._models[name]
|
||||
# Remove any agent assignments using this model
|
||||
self._agent_assignments = {
|
||||
@@ -193,13 +193,12 @@ class ModelRegistry:
|
||||
return False
|
||||
with self._lock:
|
||||
model.active = active
|
||||
conn = _get_conn()
|
||||
conn.execute(
|
||||
"UPDATE custom_models SET active = ? WHERE name = ?",
|
||||
(int(active), name),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE custom_models SET active = ? WHERE name = ?",
|
||||
(int(active), name),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
# ── Agent-model assignments ────────────────────────────────────────────
|
||||
@@ -210,17 +209,16 @@ class ModelRegistry:
|
||||
return False
|
||||
with self._lock:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
conn = _get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO agent_model_assignments
|
||||
(agent_id, model_name, assigned_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(agent_id, model_name, now),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO agent_model_assignments
|
||||
(agent_id, model_name, assigned_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(agent_id, model_name, now),
|
||||
)
|
||||
conn.commit()
|
||||
self._agent_assignments[agent_id] = model_name
|
||||
logger.info("Assigned model %s to agent %s", model_name, agent_id)
|
||||
return True
|
||||
@@ -230,13 +228,12 @@ class ModelRegistry:
|
||||
with self._lock:
|
||||
if agent_id not in self._agent_assignments:
|
||||
return False
|
||||
conn = _get_conn()
|
||||
conn.execute(
|
||||
"DELETE FROM agent_model_assignments WHERE agent_id = ?",
|
||||
(agent_id,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM agent_model_assignments WHERE agent_id = ?",
|
||||
(agent_id,),
|
||||
)
|
||||
conn.commit()
|
||||
del self._agent_assignments[agent_id]
|
||||
return True
|
||||
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -18,6 +18,8 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from config import settings
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
@@ -100,7 +102,7 @@ class Provider:
|
||||
"""LLM provider configuration and state."""
|
||||
|
||||
name: str
|
||||
type: str # ollama, openai, anthropic, airllm
|
||||
type: str # ollama, openai, anthropic
|
||||
enabled: bool
|
||||
priority: int
|
||||
url: str | None = None
|
||||
@@ -301,19 +303,11 @@ class CascadeRouter:
|
||||
# Can't check without requests, assume available
|
||||
return True
|
||||
try:
|
||||
url = provider.url or "http://localhost:11434"
|
||||
url = provider.url or settings.ollama_url
|
||||
response = requests.get(f"{url}/api/tags", timeout=5)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
elif provider.type == "airllm":
|
||||
# Check if airllm is installed
|
||||
try:
|
||||
import importlib.util
|
||||
|
||||
return importlib.util.find_spec("airllm") is not None
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
except Exception as exc:
|
||||
logger.debug("Ollama provider check error: %s", exc)
|
||||
return False
|
||||
|
||||
elif provider.type in ("openai", "anthropic", "grok"):
|
||||
@@ -394,6 +388,101 @@ class CascadeRouter:
|
||||
|
||||
return None
|
||||
|
||||
def _select_model(
|
||||
self, provider: Provider, model: str | None, content_type: ContentType
|
||||
) -> tuple[str | None, bool]:
|
||||
"""Select the best model for the request, with vision fallback.
|
||||
|
||||
Returns:
|
||||
Tuple of (selected_model, is_fallback_model).
|
||||
"""
|
||||
selected_model = model or provider.get_default_model()
|
||||
is_fallback = False
|
||||
|
||||
if content_type != ContentType.TEXT and selected_model:
|
||||
if provider.type == "ollama" and self._mm_manager:
|
||||
from infrastructure.models.multimodal import ModelCapability
|
||||
|
||||
if content_type == ContentType.VISION:
|
||||
supports = self._mm_manager.model_supports(
|
||||
selected_model, ModelCapability.VISION
|
||||
)
|
||||
if not supports:
|
||||
fallback = self._get_fallback_model(provider, selected_model, content_type)
|
||||
if fallback:
|
||||
logger.info(
|
||||
"Model %s doesn't support vision, falling back to %s",
|
||||
selected_model,
|
||||
fallback,
|
||||
)
|
||||
selected_model = fallback
|
||||
is_fallback = True
|
||||
else:
|
||||
logger.warning(
|
||||
"No vision-capable model found on %s, trying anyway",
|
||||
provider.name,
|
||||
)
|
||||
|
||||
return selected_model, is_fallback
|
||||
|
||||
async def _attempt_with_retry(
|
||||
self,
|
||||
provider: Provider,
|
||||
messages: list[dict],
|
||||
model: str | None,
|
||||
temperature: float,
|
||||
max_tokens: int | None,
|
||||
content_type: ContentType,
|
||||
) -> dict:
|
||||
"""Try a provider with retries, returning the result dict.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If all retry attempts fail.
|
||||
Returns error strings collected during retries via the exception message.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
for attempt in range(self.config.max_retries_per_provider):
|
||||
try:
|
||||
return await self._try_provider(
|
||||
provider=provider,
|
||||
messages=messages,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
content_type=content_type,
|
||||
)
|
||||
except Exception as exc:
|
||||
error_msg = str(exc)
|
||||
logger.warning(
|
||||
"Provider %s attempt %d failed: %s",
|
||||
provider.name,
|
||||
attempt + 1,
|
||||
error_msg,
|
||||
)
|
||||
errors.append(f"{provider.name}: {error_msg}")
|
||||
|
||||
if attempt < self.config.max_retries_per_provider - 1:
|
||||
await asyncio.sleep(self.config.retry_delay_seconds)
|
||||
|
||||
raise RuntimeError("; ".join(errors))
|
||||
|
||||
def _is_provider_available(self, provider: Provider) -> bool:
|
||||
"""Check if a provider should be tried (enabled + circuit breaker)."""
|
||||
if not provider.enabled:
|
||||
logger.debug("Skipping %s (disabled)", provider.name)
|
||||
return False
|
||||
|
||||
if provider.status == ProviderStatus.UNHEALTHY:
|
||||
if self._can_close_circuit(provider):
|
||||
provider.circuit_state = CircuitState.HALF_OPEN
|
||||
provider.half_open_calls = 0
|
||||
logger.info("Circuit breaker half-open for %s", provider.name)
|
||||
else:
|
||||
logger.debug("Skipping %s (circuit open)", provider.name)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
messages: list[dict],
|
||||
@@ -420,7 +509,6 @@ class CascadeRouter:
|
||||
Raises:
|
||||
RuntimeError: If all providers fail
|
||||
"""
|
||||
# Detect content type for multi-modal routing
|
||||
content_type = self._detect_content_type(messages)
|
||||
if content_type != ContentType.TEXT:
|
||||
logger.debug("Detected %s content, selecting appropriate model", content_type.value)
|
||||
@@ -428,93 +516,34 @@ class CascadeRouter:
|
||||
errors = []
|
||||
|
||||
for provider in self.providers:
|
||||
# Skip disabled providers
|
||||
if not provider.enabled:
|
||||
logger.debug("Skipping %s (disabled)", provider.name)
|
||||
if not self._is_provider_available(provider):
|
||||
continue
|
||||
|
||||
# Skip unhealthy providers (circuit breaker)
|
||||
if provider.status == ProviderStatus.UNHEALTHY:
|
||||
# Check if circuit breaker can close
|
||||
if self._can_close_circuit(provider):
|
||||
provider.circuit_state = CircuitState.HALF_OPEN
|
||||
provider.half_open_calls = 0
|
||||
logger.info("Circuit breaker half-open for %s", provider.name)
|
||||
else:
|
||||
logger.debug("Skipping %s (circuit open)", provider.name)
|
||||
continue
|
||||
selected_model, is_fallback_model = self._select_model(provider, model, content_type)
|
||||
|
||||
# Determine which model to use
|
||||
selected_model = model or provider.get_default_model()
|
||||
is_fallback_model = False
|
||||
try:
|
||||
result = await self._attempt_with_retry(
|
||||
provider,
|
||||
messages,
|
||||
selected_model,
|
||||
temperature,
|
||||
max_tokens,
|
||||
content_type,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
errors.append(str(exc))
|
||||
self._record_failure(provider)
|
||||
continue
|
||||
|
||||
# For non-text content, check if model supports it
|
||||
if content_type != ContentType.TEXT and selected_model:
|
||||
if provider.type == "ollama" and self._mm_manager:
|
||||
from infrastructure.models.multimodal import ModelCapability
|
||||
self._record_success(provider, result.get("latency_ms", 0))
|
||||
return {
|
||||
"content": result["content"],
|
||||
"provider": provider.name,
|
||||
"model": result.get("model", selected_model or provider.get_default_model()),
|
||||
"latency_ms": result.get("latency_ms", 0),
|
||||
"is_fallback_model": is_fallback_model,
|
||||
}
|
||||
|
||||
# Check if selected model supports the required capability
|
||||
if content_type == ContentType.VISION:
|
||||
supports = self._mm_manager.model_supports(
|
||||
selected_model, ModelCapability.VISION
|
||||
)
|
||||
if not supports:
|
||||
# Find fallback model
|
||||
fallback = self._get_fallback_model(
|
||||
provider, selected_model, content_type
|
||||
)
|
||||
if fallback:
|
||||
logger.info(
|
||||
"Model %s doesn't support vision, falling back to %s",
|
||||
selected_model,
|
||||
fallback,
|
||||
)
|
||||
selected_model = fallback
|
||||
is_fallback_model = True
|
||||
else:
|
||||
logger.warning(
|
||||
"No vision-capable model found on %s, trying anyway",
|
||||
provider.name,
|
||||
)
|
||||
|
||||
# Try this provider
|
||||
for attempt in range(self.config.max_retries_per_provider):
|
||||
try:
|
||||
result = await self._try_provider(
|
||||
provider=provider,
|
||||
messages=messages,
|
||||
model=selected_model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
# Success! Update metrics and return
|
||||
self._record_success(provider, result.get("latency_ms", 0))
|
||||
return {
|
||||
"content": result["content"],
|
||||
"provider": provider.name,
|
||||
"model": result.get(
|
||||
"model", selected_model or provider.get_default_model()
|
||||
),
|
||||
"latency_ms": result.get("latency_ms", 0),
|
||||
"is_fallback_model": is_fallback_model,
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
error_msg = str(exc)
|
||||
logger.warning(
|
||||
"Provider %s attempt %d failed: %s", provider.name, attempt + 1, error_msg
|
||||
)
|
||||
errors.append(f"{provider.name}: {error_msg}")
|
||||
|
||||
if attempt < self.config.max_retries_per_provider - 1:
|
||||
await asyncio.sleep(self.config.retry_delay_seconds)
|
||||
|
||||
# All retries failed for this provider
|
||||
self._record_failure(provider)
|
||||
|
||||
# All providers failed
|
||||
raise RuntimeError(f"All providers failed: {'; '.join(errors)}")
|
||||
|
||||
async def _try_provider(
|
||||
@@ -580,7 +609,7 @@ class CascadeRouter:
|
||||
"""Call Ollama API with multi-modal support."""
|
||||
import aiohttp
|
||||
|
||||
url = f"{provider.url}/api/chat"
|
||||
url = f"{provider.url or settings.ollama_url}/api/chat"
|
||||
|
||||
# Transform messages for Ollama format (including images)
|
||||
transformed_messages = self._transform_messages_for_ollama(messages)
|
||||
@@ -814,6 +843,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 {
|
||||
|
||||
@@ -54,7 +54,8 @@ class WebSocketManager:
|
||||
for event in list(self._event_history)[-20:]:
|
||||
try:
|
||||
await websocket.send_text(event.to_json())
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("WebSocket history send error: %s", exc)
|
||||
break
|
||||
|
||||
def disconnect(self, websocket: WebSocket) -> None:
|
||||
@@ -83,8 +84,8 @@ class WebSocketManager:
|
||||
await ws.send_text(message)
|
||||
except ConnectionError:
|
||||
disconnected.append(ws)
|
||||
except Exception:
|
||||
logger.warning("Unexpected WebSocket send error", exc_info=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Unexpected WebSocket send error: %s", exc)
|
||||
disconnected.append(ws)
|
||||
|
||||
# Clean up dead connections
|
||||
@@ -156,7 +157,8 @@ class WebSocketManager:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
count += 1
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("WebSocket direct send error: %s", exc)
|
||||
disconnected.append(ws)
|
||||
|
||||
# Clean up dead connections
|
||||
|
||||
16
src/integrations/chat_bridge/vendors/discord.py
vendored
16
src/integrations/chat_bridge/vendors/discord.py
vendored
@@ -87,7 +87,8 @@ if _DISCORD_UI_AVAILABLE:
|
||||
await action["target"].send(
|
||||
f"Action `{action['tool_name']}` timed out and was auto-rejected."
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Discord action timeout message error: %s", exc)
|
||||
pass
|
||||
|
||||
|
||||
@@ -186,7 +187,8 @@ class DiscordVendor(ChatPlatform):
|
||||
if self._client and not self._client.is_closed():
|
||||
try:
|
||||
await self._client.close()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Discord client close error: %s", exc)
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
@@ -330,7 +332,8 @@ class DiscordVendor(ChatPlatform):
|
||||
|
||||
if settings.discord_token:
|
||||
return settings.discord_token
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Discord token load error: %s", exc)
|
||||
pass
|
||||
|
||||
# 2. Fall back to state file (set via /discord/setup endpoint)
|
||||
@@ -458,7 +461,8 @@ class DiscordVendor(ChatPlatform):
|
||||
req.reject(note="User rejected from Discord")
|
||||
try:
|
||||
await continue_chat(action["run_output"], action.get("session_id"))
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Discord continue chat error: %s", exc)
|
||||
pass
|
||||
|
||||
await interaction.response.send_message(
|
||||
@@ -543,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:
|
||||
|
||||
@@ -56,7 +56,8 @@ class TelegramBot:
|
||||
from config import settings
|
||||
|
||||
return settings.telegram_token or None
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Telegram token load error: %s", exc)
|
||||
return None
|
||||
|
||||
def save_token(self, token: str) -> None:
|
||||
|
||||
1
src/loop/__init__.py
Normal file
1
src/loop/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Three-phase agent loop: Gather → Reason → Act."""
|
||||
37
src/loop/phase1_gather.py
Normal file
37
src/loop/phase1_gather.py
Normal 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
36
src/loop/phase2_reason.py
Normal 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
36
src/loop/phase3_act.py
Normal 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
40
src/loop/runner.py
Normal 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
43
src/loop/schema.py
Normal 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,
|
||||
)
|
||||
@@ -16,6 +16,8 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from contextlib import closing, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -39,28 +41,31 @@ class Prediction:
|
||||
evaluated_at: str | None
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
@contextmanager
|
||||
def _get_conn() -> Generator[sqlite3.Connection, None, None]:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS spark_predictions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
prediction_type TEXT NOT NULL,
|
||||
predicted_value TEXT NOT NULL,
|
||||
actual_value TEXT,
|
||||
accuracy REAL,
|
||||
created_at TEXT NOT NULL,
|
||||
evaluated_at TEXT
|
||||
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS spark_predictions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
prediction_type TEXT NOT NULL,
|
||||
predicted_value TEXT NOT NULL,
|
||||
actual_value TEXT,
|
||||
accuracy REAL,
|
||||
created_at TEXT NOT NULL,
|
||||
evaluated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pred_task ON spark_predictions(task_id)")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pred_type ON spark_predictions(prediction_type)"
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pred_task ON spark_predictions(task_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pred_type ON spark_predictions(prediction_type)")
|
||||
conn.commit()
|
||||
return conn
|
||||
conn.commit()
|
||||
yield conn
|
||||
|
||||
|
||||
# ── Prediction phase ────────────────────────────────────────────────────────
|
||||
@@ -119,17 +124,16 @@ def predict_task_outcome(
|
||||
# Store prediction
|
||||
pred_id = str(uuid.uuid4())
|
||||
now = datetime.now(UTC).isoformat()
|
||||
conn = _get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO spark_predictions
|
||||
(id, task_id, prediction_type, predicted_value, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(pred_id, task_id, "outcome", json.dumps(prediction), now),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO spark_predictions
|
||||
(id, task_id, prediction_type, predicted_value, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(pred_id, task_id, "outcome", json.dumps(prediction), now),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
prediction["prediction_id"] = pred_id
|
||||
return prediction
|
||||
@@ -148,41 +152,39 @@ def evaluate_prediction(
|
||||
|
||||
Returns the evaluation result or None if no prediction exists.
|
||||
"""
|
||||
conn = _get_conn()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM spark_predictions
|
||||
WHERE task_id = ? AND prediction_type = 'outcome' AND evaluated_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
with _get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM spark_predictions
|
||||
WHERE task_id = ? AND prediction_type = 'outcome' AND evaluated_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
conn.close()
|
||||
return None
|
||||
if not row:
|
||||
return None
|
||||
|
||||
predicted = json.loads(row["predicted_value"])
|
||||
actual = {
|
||||
"winner": actual_winner,
|
||||
"succeeded": task_succeeded,
|
||||
"winning_bid": winning_bid,
|
||||
}
|
||||
predicted = json.loads(row["predicted_value"])
|
||||
actual = {
|
||||
"winner": actual_winner,
|
||||
"succeeded": task_succeeded,
|
||||
"winning_bid": winning_bid,
|
||||
}
|
||||
|
||||
# Calculate accuracy
|
||||
accuracy = _compute_accuracy(predicted, actual)
|
||||
now = datetime.now(UTC).isoformat()
|
||||
# Calculate accuracy
|
||||
accuracy = _compute_accuracy(predicted, actual)
|
||||
now = datetime.now(UTC).isoformat()
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE spark_predictions
|
||||
SET actual_value = ?, accuracy = ?, evaluated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(json.dumps(actual), accuracy, now, row["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE spark_predictions
|
||||
SET actual_value = ?, accuracy = ?, evaluated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(json.dumps(actual), accuracy, now, row["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"prediction_id": row["id"],
|
||||
@@ -243,7 +245,6 @@ def get_predictions(
|
||||
limit: int = 50,
|
||||
) -> list[Prediction]:
|
||||
"""Query stored predictions."""
|
||||
conn = _get_conn()
|
||||
query = "SELECT * FROM spark_predictions WHERE 1=1"
|
||||
params: list = []
|
||||
|
||||
@@ -256,8 +257,8 @@ def get_predictions(
|
||||
query += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [
|
||||
Prediction(
|
||||
id=r["id"],
|
||||
@@ -275,17 +276,16 @@ def get_predictions(
|
||||
|
||||
def get_accuracy_stats() -> dict:
|
||||
"""Return aggregate accuracy statistics for the EIDOS loop."""
|
||||
conn = _get_conn()
|
||||
row = conn.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total_predictions,
|
||||
COUNT(evaluated_at) AS evaluated,
|
||||
AVG(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS avg_accuracy,
|
||||
MIN(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS min_accuracy,
|
||||
MAX(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS max_accuracy
|
||||
FROM spark_predictions
|
||||
""").fetchone()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total_predictions,
|
||||
COUNT(evaluated_at) AS evaluated,
|
||||
AVG(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS avg_accuracy,
|
||||
MIN(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS min_accuracy,
|
||||
MAX(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS max_accuracy
|
||||
FROM spark_predictions
|
||||
""").fetchone()
|
||||
|
||||
return {
|
||||
"total_predictions": row["total_predictions"] or 0,
|
||||
|
||||
@@ -273,6 +273,8 @@ class SparkEngine:
|
||||
|
||||
def _maybe_consolidate(self, agent_id: str) -> None:
|
||||
"""Consolidate events into memories when enough data exists."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
agent_events = spark_memory.get_events(agent_id=agent_id, limit=50)
|
||||
if len(agent_events) < 5:
|
||||
return
|
||||
@@ -286,7 +288,34 @@ class SparkEngine:
|
||||
|
||||
success_rate = len(completions) / total if total else 0
|
||||
|
||||
# Determine target memory type based on success rate
|
||||
if success_rate >= 0.8:
|
||||
target_memory_type = "pattern"
|
||||
elif success_rate <= 0.3:
|
||||
target_memory_type = "anomaly"
|
||||
else:
|
||||
return # No consolidation needed for neutral success rates
|
||||
|
||||
# Check for recent memories of the same type for this agent
|
||||
existing_memories = spark_memory.get_memories(subject=agent_id, limit=5)
|
||||
now = datetime.now(UTC)
|
||||
one_hour_ago = now - timedelta(hours=1)
|
||||
|
||||
for memory in existing_memories:
|
||||
if memory.memory_type == target_memory_type:
|
||||
try:
|
||||
created_at = datetime.fromisoformat(memory.created_at)
|
||||
if created_at >= one_hour_ago:
|
||||
logger.info(
|
||||
"Consolidation: skipping — recent memory exists for %s",
|
||||
agent_id[:8],
|
||||
)
|
||||
return
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Store the new memory
|
||||
if target_memory_type == "pattern":
|
||||
spark_memory.store_memory(
|
||||
memory_type="pattern",
|
||||
subject=agent_id,
|
||||
@@ -295,7 +324,7 @@ class SparkEngine:
|
||||
confidence=min(0.95, 0.6 + total * 0.05),
|
||||
source_events=total,
|
||||
)
|
||||
elif success_rate <= 0.3:
|
||||
else: # anomaly
|
||||
spark_memory.store_memory(
|
||||
memory_type="anomaly",
|
||||
subject=agent_id,
|
||||
@@ -358,7 +387,8 @@ def get_spark_engine() -> SparkEngine:
|
||||
from config import settings
|
||||
|
||||
_spark_engine = SparkEngine(enabled=settings.spark_enabled)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug("Spark engine settings load error: %s", exc)
|
||||
_spark_engine = SparkEngine(enabled=True)
|
||||
return _spark_engine
|
||||
|
||||
|
||||
@@ -10,12 +10,17 @@ spark_events — raw event log (every swarm event)
|
||||
spark_memories — consolidated insights extracted from event patterns
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from contextlib import closing, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = Path("data/spark.db")
|
||||
|
||||
# Importance thresholds
|
||||
@@ -52,42 +57,43 @@ class SparkMemory:
|
||||
expires_at: str | None
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
@contextmanager
|
||||
def _get_conn() -> Generator[sqlite3.Connection, None, None]:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS spark_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
agent_id TEXT,
|
||||
task_id TEXT,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
importance REAL NOT NULL DEFAULT 0.5,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS spark_memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
memory_type TEXT NOT NULL,
|
||||
subject TEXT NOT NULL DEFAULT 'system',
|
||||
content TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0.5,
|
||||
source_events INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON spark_events(event_type)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_agent ON spark_events(agent_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_task ON spark_events(task_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_subject ON spark_memories(subject)")
|
||||
conn.commit()
|
||||
return conn
|
||||
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS spark_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
agent_id TEXT,
|
||||
task_id TEXT,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
importance REAL NOT NULL DEFAULT 0.5,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS spark_memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
memory_type TEXT NOT NULL,
|
||||
subject TEXT NOT NULL DEFAULT 'system',
|
||||
content TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0.5,
|
||||
source_events INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON spark_events(event_type)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_agent ON spark_events(agent_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_task ON spark_events(task_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_subject ON spark_memories(subject)")
|
||||
conn.commit()
|
||||
yield conn
|
||||
|
||||
|
||||
# ── Importance scoring ──────────────────────────────────────────────────────
|
||||
@@ -146,17 +152,16 @@ def record_event(
|
||||
parsed = {}
|
||||
importance = score_importance(event_type, parsed)
|
||||
|
||||
conn = _get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO spark_events
|
||||
(id, event_type, agent_id, task_id, description, data, importance, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(event_id, event_type, agent_id, task_id, description, data, importance, now),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO spark_events
|
||||
(id, event_type, agent_id, task_id, description, data, importance, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(event_id, event_type, agent_id, task_id, description, data, importance, now),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Bridge to unified event log so all events are queryable from one place
|
||||
try:
|
||||
@@ -170,7 +175,8 @@ def record_event(
|
||||
task_id=task_id or "",
|
||||
agent_id=agent_id or "",
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug("Spark event log error: %s", exc)
|
||||
pass # Graceful — don't break spark if event_log is unavailable
|
||||
|
||||
return event_id
|
||||
@@ -184,7 +190,6 @@ def get_events(
|
||||
min_importance: float = 0.0,
|
||||
) -> list[SparkEvent]:
|
||||
"""Query events with optional filters."""
|
||||
conn = _get_conn()
|
||||
query = "SELECT * FROM spark_events WHERE importance >= ?"
|
||||
params: list = [min_importance]
|
||||
|
||||
@@ -201,8 +206,8 @@ def get_events(
|
||||
query += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [
|
||||
SparkEvent(
|
||||
id=r["id"],
|
||||
@@ -220,15 +225,14 @@ def get_events(
|
||||
|
||||
def count_events(event_type: str | None = None) -> int:
|
||||
"""Count events, optionally filtered by type."""
|
||||
conn = _get_conn()
|
||||
if event_type:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM spark_events WHERE event_type = ?",
|
||||
(event_type,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute("SELECT COUNT(*) FROM spark_events").fetchone()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
if event_type:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM spark_events WHERE event_type = ?",
|
||||
(event_type,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute("SELECT COUNT(*) FROM spark_events").fetchone()
|
||||
return row[0]
|
||||
|
||||
|
||||
@@ -246,17 +250,16 @@ def store_memory(
|
||||
"""Store a consolidated memory. Returns the memory id."""
|
||||
mem_id = str(uuid.uuid4())
|
||||
now = datetime.now(UTC).isoformat()
|
||||
conn = _get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO spark_memories
|
||||
(id, memory_type, subject, content, confidence, source_events, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(mem_id, memory_type, subject, content, confidence, source_events, now, expires_at),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO spark_memories
|
||||
(id, memory_type, subject, content, confidence, source_events, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(mem_id, memory_type, subject, content, confidence, source_events, now, expires_at),
|
||||
)
|
||||
conn.commit()
|
||||
return mem_id
|
||||
|
||||
|
||||
@@ -267,7 +270,6 @@ def get_memories(
|
||||
limit: int = 50,
|
||||
) -> list[SparkMemory]:
|
||||
"""Query memories with optional filters."""
|
||||
conn = _get_conn()
|
||||
query = "SELECT * FROM spark_memories WHERE confidence >= ?"
|
||||
params: list = [min_confidence]
|
||||
|
||||
@@ -281,8 +283,8 @@ def get_memories(
|
||||
query += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [
|
||||
SparkMemory(
|
||||
id=r["id"],
|
||||
@@ -300,13 +302,12 @@ def get_memories(
|
||||
|
||||
def count_memories(memory_type: str | None = None) -> int:
|
||||
"""Count memories, optionally filtered by type."""
|
||||
conn = _get_conn()
|
||||
if memory_type:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM spark_memories WHERE memory_type = ?",
|
||||
(memory_type,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute("SELECT COUNT(*) FROM spark_memories").fetchone()
|
||||
conn.close()
|
||||
with _get_conn() as conn:
|
||||
if memory_type:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM spark_memories WHERE memory_type = ?",
|
||||
(memory_type,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute("SELECT COUNT(*) FROM spark_memories").fetchone()
|
||||
return row[0]
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Timmy — Core AI agent (Ollama/AirLLM backends, CLI, prompts)."""
|
||||
"""Timmy — Core AI agent (Ollama backend, CLI, prompts)."""
|
||||
|
||||
1
src/timmy/adapters/__init__.py
Normal file
1
src/timmy/adapters/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Adapters — normalize external data streams into sensory events."""
|
||||
136
src/timmy/adapters/gitea_adapter.py
Normal file
136
src/timmy/adapters/gitea_adapter.py
Normal 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
|
||||
82
src/timmy/adapters/time_adapter.py
Normal file
82
src/timmy/adapters/time_adapter.py
Normal 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
|
||||
@@ -63,7 +63,7 @@ def _pull_model(model_name: str) -> bool:
|
||||
|
||||
logger.info("Pulling model: %s", model_name)
|
||||
|
||||
url = settings.ollama_url.replace("localhost", "127.0.0.1")
|
||||
url = settings.normalized_ollama_url
|
||||
req = urllib.request.Request(
|
||||
f"{url}/api/pull",
|
||||
method="POST",
|
||||
@@ -203,6 +203,7 @@ def create_timmy(
|
||||
model_size: str | None = None,
|
||||
*,
|
||||
skip_mcp: bool = False,
|
||||
session_id: str = "unknown",
|
||||
) -> TimmyAgent:
|
||||
"""Instantiate the agent — Ollama or AirLLM, same public interface.
|
||||
|
||||
@@ -219,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
|
||||
@@ -286,7 +287,7 @@ def create_timmy(
|
||||
logger.debug("MCP tools unavailable: %s", exc)
|
||||
|
||||
# Select prompt tier based on tool capability
|
||||
base_prompt = get_system_prompt(tools_enabled=use_tools)
|
||||
base_prompt = get_system_prompt(tools_enabled=use_tools, session_id=session_id)
|
||||
|
||||
# Try to load memory context
|
||||
try:
|
||||
@@ -299,16 +300,23 @@ 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:
|
||||
logger.warning("Failed to load memory context: %s", exc)
|
||||
full_prompt = base_prompt
|
||||
|
||||
model_kwargs = {}
|
||||
if settings.ollama_num_ctx > 0:
|
||||
model_kwargs["options"] = {"num_ctx": settings.ollama_num_ctx}
|
||||
agent = Agent(
|
||||
name="Agent",
|
||||
model=Ollama(id=model_name, host=settings.ollama_url, timeout=300),
|
||||
model=Ollama(id=model_name, host=settings.ollama_url, timeout=300, **model_kwargs),
|
||||
db=SqliteDb(db_file=db_file),
|
||||
description=full_prompt,
|
||||
add_history_to_context=True,
|
||||
@@ -336,15 +344,47 @@ class TimmyWithMemory:
|
||||
self.initial_context = self.memory.get_system_context()
|
||||
|
||||
def chat(self, message: str) -> str:
|
||||
"""Simple chat interface that tracks in memory."""
|
||||
"""Simple chat interface that tracks in memory.
|
||||
|
||||
Retries on transient Ollama errors (GPU contention, timeouts)
|
||||
with exponential backoff (#70).
|
||||
"""
|
||||
import time
|
||||
|
||||
# Check for user facts to extract
|
||||
self._extract_and_store_facts(message)
|
||||
|
||||
# Run agent
|
||||
result = self.agent.run(message, stream=False)
|
||||
response_text = result.content if hasattr(result, "content") else str(result)
|
||||
|
||||
return response_text
|
||||
# Retry with backoff — GPU contention causes ReadError/ReadTimeout
|
||||
max_retries = 3
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
result = self.agent.run(message, stream=False)
|
||||
return result.content if hasattr(result, "content") else str(result)
|
||||
except (
|
||||
httpx.ConnectError,
|
||||
httpx.ReadError,
|
||||
httpx.ReadTimeout,
|
||||
httpx.ConnectTimeout,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
) as exc:
|
||||
if attempt < max_retries:
|
||||
wait = min(2**attempt, 16)
|
||||
logger.warning(
|
||||
"Ollama contention on attempt %d/%d: %s. Waiting %ds before retry...",
|
||||
attempt,
|
||||
max_retries,
|
||||
type(exc).__name__,
|
||||
wait,
|
||||
)
|
||||
time.sleep(wait)
|
||||
else:
|
||||
logger.error(
|
||||
"Ollama unreachable after %d attempts: %s",
|
||||
max_retries,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
|
||||
def _extract_and_store_facts(self, message: str) -> None:
|
||||
"""Extract user facts from message and store in memory."""
|
||||
@@ -355,7 +395,8 @@ class TimmyWithMemory:
|
||||
if name:
|
||||
self.memory.update_user_fact("Name", name)
|
||||
self.memory.record_decision(f"Learned user's name: {name}")
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("User name extraction failed: %s", exc)
|
||||
pass # Best-effort extraction
|
||||
|
||||
def end_session(self, summary: str = "Session completed") -> None:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Agent Core — Substrate-agnostic agent interface and base classes."""
|
||||
@@ -1,381 +0,0 @@
|
||||
"""TimAgent Interface — The substrate-agnostic agent contract.
|
||||
|
||||
This is the foundation for embodiment. Whether Timmy runs on:
|
||||
- A server with Ollama (today)
|
||||
- A Raspberry Pi with sensors
|
||||
- A Boston Dynamics Spot robot
|
||||
- A VR avatar
|
||||
|
||||
The interface remains constant. Implementation varies.
|
||||
|
||||
Architecture:
|
||||
perceive() → reason → act()
|
||||
↑ ↓
|
||||
←←← remember() ←←←←←←┘
|
||||
|
||||
All methods return effects that can be logged, audited, and replayed.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum, auto
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PerceptionType(Enum):
|
||||
"""Types of sensory input an agent can receive."""
|
||||
|
||||
TEXT = auto() # Natural language
|
||||
IMAGE = auto() # Visual input
|
||||
AUDIO = auto() # Sound/speech
|
||||
SENSOR = auto() # Temperature, distance, etc.
|
||||
MOTION = auto() # Accelerometer, gyroscope
|
||||
NETWORK = auto() # API calls, messages
|
||||
INTERNAL = auto() # Self-monitoring (battery, temp)
|
||||
|
||||
|
||||
class ActionType(Enum):
|
||||
"""Types of actions an agent can perform."""
|
||||
|
||||
TEXT = auto() # Generate text response
|
||||
SPEAK = auto() # Text-to-speech
|
||||
MOVE = auto() # Physical movement
|
||||
GRIP = auto() # Manipulate objects
|
||||
CALL = auto() # API/network call
|
||||
EMIT = auto() # Signal/light/sound
|
||||
SLEEP = auto() # Power management
|
||||
|
||||
|
||||
class AgentCapability(Enum):
|
||||
"""High-level capabilities a TimAgent may possess."""
|
||||
|
||||
REASONING = "reasoning"
|
||||
CODING = "coding"
|
||||
WRITING = "writing"
|
||||
ANALYSIS = "analysis"
|
||||
VISION = "vision"
|
||||
SPEECH = "speech"
|
||||
NAVIGATION = "navigation"
|
||||
MANIPULATION = "manipulation"
|
||||
LEARNING = "learning"
|
||||
COMMUNICATION = "communication"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentIdentity:
|
||||
"""Immutable identity for an agent instance.
|
||||
|
||||
This persists across sessions and substrates. If Timmy moves
|
||||
from cloud to robot, the identity follows.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
|
||||
@classmethod
|
||||
def generate(cls, name: str, version: str = "1.0.0") -> "AgentIdentity":
|
||||
"""Generate a new unique identity."""
|
||||
return cls(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
version=version,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Perception:
|
||||
"""A sensory input to the agent.
|
||||
|
||||
Substrate-agnostic representation. A camera image and a
|
||||
LiDAR point cloud are both Perception instances.
|
||||
"""
|
||||
|
||||
type: PerceptionType
|
||||
data: Any # Content depends on type
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
source: str = "unknown" # e.g., "camera_1", "microphone", "user_input"
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def text(cls, content: str, source: str = "user") -> "Perception":
|
||||
"""Factory for text perception."""
|
||||
return cls(
|
||||
type=PerceptionType.TEXT,
|
||||
data=content,
|
||||
source=source,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def sensor(cls, kind: str, value: float, unit: str = "") -> "Perception":
|
||||
"""Factory for sensor readings."""
|
||||
return cls(
|
||||
type=PerceptionType.SENSOR,
|
||||
data={"kind": kind, "value": value, "unit": unit},
|
||||
source=f"sensor_{kind}",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Action:
|
||||
"""An action the agent intends to perform.
|
||||
|
||||
Actions are effects — they describe what should happen,
|
||||
not how. The substrate implements the "how."
|
||||
"""
|
||||
|
||||
type: ActionType
|
||||
payload: Any # Action-specific data
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
confidence: float = 1.0 # 0-1, agent's certainty
|
||||
deadline: str | None = None # When action must complete
|
||||
|
||||
@classmethod
|
||||
def respond(cls, text: str, confidence: float = 1.0) -> "Action":
|
||||
"""Factory for text response action."""
|
||||
return cls(
|
||||
type=ActionType.TEXT,
|
||||
payload=text,
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def move(cls, vector: tuple[float, float, float], speed: float = 1.0) -> "Action":
|
||||
"""Factory for movement action (x, y, z meters)."""
|
||||
return cls(
|
||||
type=ActionType.MOVE,
|
||||
payload={"vector": vector, "speed": speed},
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Memory:
|
||||
"""A stored experience or fact.
|
||||
|
||||
Memories are substrate-agnostic. A conversation history
|
||||
and a video recording are both Memory instances.
|
||||
"""
|
||||
|
||||
id: str
|
||||
content: Any
|
||||
created_at: str
|
||||
access_count: int = 0
|
||||
last_accessed: str | None = None
|
||||
importance: float = 0.5 # 0-1, for pruning decisions
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
def touch(self) -> None:
|
||||
"""Mark memory as accessed."""
|
||||
self.access_count += 1
|
||||
self.last_accessed = datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Communication:
|
||||
"""A message to/from another agent or human."""
|
||||
|
||||
sender: str
|
||||
recipient: str
|
||||
content: Any
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
protocol: str = "direct" # e.g., "http", "websocket", "speech"
|
||||
encrypted: bool = False
|
||||
|
||||
|
||||
class TimAgent(ABC):
|
||||
"""Abstract base class for all Timmy agent implementations.
|
||||
|
||||
This is the substrate-agnostic interface. Implementations:
|
||||
- OllamaAgent: LLM-based reasoning (today)
|
||||
- RobotAgent: Physical embodiment (future)
|
||||
- SimulationAgent: Virtual environment (future)
|
||||
|
||||
Usage:
|
||||
agent = OllamaAgent(identity) # Today's implementation
|
||||
|
||||
perception = Perception.text("Hello Timmy")
|
||||
memory = agent.perceive(perception)
|
||||
|
||||
action = agent.reason("How should I respond?")
|
||||
result = agent.act(action)
|
||||
|
||||
agent.remember(memory) # Store for future
|
||||
"""
|
||||
|
||||
def __init__(self, identity: AgentIdentity) -> None:
|
||||
self._identity = identity
|
||||
self._capabilities: set[AgentCapability] = set()
|
||||
self._state: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def identity(self) -> AgentIdentity:
|
||||
"""Return this agent's immutable identity."""
|
||||
return self._identity
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[AgentCapability]:
|
||||
"""Return set of supported capabilities."""
|
||||
return self._capabilities.copy()
|
||||
|
||||
def has_capability(self, capability: AgentCapability) -> bool:
|
||||
"""Check if agent supports a capability."""
|
||||
return capability in self._capabilities
|
||||
|
||||
@abstractmethod
|
||||
def perceive(self, perception: Perception) -> Memory:
|
||||
"""Process sensory input and create a memory.
|
||||
|
||||
This is the entry point for all agent interaction.
|
||||
A text message, camera frame, or temperature reading
|
||||
all enter through perceive().
|
||||
|
||||
Args:
|
||||
perception: Sensory input
|
||||
|
||||
Returns:
|
||||
Memory: Stored representation of the perception
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def reason(self, query: str, context: list[Memory]) -> Action:
|
||||
"""Reason about a situation and decide on action.
|
||||
|
||||
This is where "thinking" happens. The agent uses its
|
||||
substrate-appropriate reasoning (LLM, neural net, rules)
|
||||
to decide what to do.
|
||||
|
||||
Args:
|
||||
query: What to reason about
|
||||
context: Relevant memories for context
|
||||
|
||||
Returns:
|
||||
Action: What the agent decides to do
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def act(self, action: Action) -> Any:
|
||||
"""Execute an action in the substrate.
|
||||
|
||||
This is where the abstract action becomes concrete:
|
||||
- TEXT → Generate LLM response
|
||||
- MOVE → Send motor commands
|
||||
- SPEAK → Call TTS engine
|
||||
|
||||
Args:
|
||||
action: The action to execute
|
||||
|
||||
Returns:
|
||||
Result of the action (substrate-specific)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remember(self, memory: Memory) -> None:
|
||||
"""Store a memory for future retrieval.
|
||||
|
||||
The storage mechanism depends on substrate:
|
||||
- Cloud: SQLite, vector DB
|
||||
- Robot: Local flash storage
|
||||
- Hybrid: Synced with conflict resolution
|
||||
|
||||
Args:
|
||||
memory: Experience to store
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def recall(self, query: str, limit: int = 5) -> list[Memory]:
|
||||
"""Retrieve relevant memories.
|
||||
|
||||
Args:
|
||||
query: What to search for
|
||||
limit: Maximum memories to return
|
||||
|
||||
Returns:
|
||||
List of relevant memories, sorted by relevance
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def communicate(self, message: Communication) -> bool:
|
||||
"""Send/receive communication with another agent.
|
||||
|
||||
Args:
|
||||
message: Message to send
|
||||
|
||||
Returns:
|
||||
True if communication succeeded
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_state(self) -> dict[str, Any]:
|
||||
"""Get current agent state for monitoring/debugging."""
|
||||
return {
|
||||
"identity": self._identity,
|
||||
"capabilities": list(self._capabilities),
|
||||
"state": self._state.copy(),
|
||||
}
|
||||
|
||||
def shutdown(self) -> None: # noqa: B027
|
||||
"""Graceful shutdown. Persist state, close connections."""
|
||||
# Override in subclass for cleanup
|
||||
|
||||
|
||||
class AgentEffect:
|
||||
"""Log entry for agent actions — for audit and replay.
|
||||
|
||||
The complete history of an agent's life can be captured
|
||||
as a sequence of AgentEffects. This enables:
|
||||
- Debugging: What did the agent see and do?
|
||||
- Audit: Why did it make that decision?
|
||||
- Replay: Reconstruct agent state from log
|
||||
- Training: Learn from agent experiences
|
||||
"""
|
||||
|
||||
def __init__(self, log_path: str | None = None) -> None:
|
||||
self._effects: list[dict] = []
|
||||
self._log_path = log_path
|
||||
|
||||
def log_perceive(self, perception: Perception, memory_id: str) -> None:
|
||||
"""Log a perception event."""
|
||||
self._effects.append(
|
||||
{
|
||||
"type": "perceive",
|
||||
"perception_type": perception.type.name,
|
||||
"source": perception.source,
|
||||
"memory_id": memory_id,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
def log_reason(self, query: str, action_type: ActionType) -> None:
|
||||
"""Log a reasoning event."""
|
||||
self._effects.append(
|
||||
{
|
||||
"type": "reason",
|
||||
"query": query,
|
||||
"action_type": action_type.name,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
def log_act(self, action: Action, result: Any) -> None:
|
||||
"""Log an action event."""
|
||||
self._effects.append(
|
||||
{
|
||||
"type": "act",
|
||||
"action_type": action.type.name,
|
||||
"confidence": action.confidence,
|
||||
"result_type": type(result).__name__,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
def export(self) -> list[dict]:
|
||||
"""Export effect log for analysis."""
|
||||
return self._effects.copy()
|
||||
@@ -1,275 +0,0 @@
|
||||
"""Ollama-based implementation of TimAgent interface.
|
||||
|
||||
This adapter wraps the existing Timmy Ollama agent to conform
|
||||
to the substrate-agnostic TimAgent interface. It's the bridge
|
||||
between the old codebase and the new embodiment-ready architecture.
|
||||
|
||||
Usage:
|
||||
from timmy.agent_core import AgentIdentity, Perception
|
||||
from timmy.agent_core.ollama_adapter import OllamaAgent
|
||||
|
||||
identity = AgentIdentity.generate("Timmy")
|
||||
agent = OllamaAgent(identity)
|
||||
|
||||
perception = Perception.text("Hello!")
|
||||
memory = agent.perceive(perception)
|
||||
action = agent.reason("How should I respond?", [memory])
|
||||
result = agent.act(action)
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from timmy.agent import _resolve_model_with_fallback, create_timmy
|
||||
from timmy.agent_core.interface import (
|
||||
Action,
|
||||
ActionType,
|
||||
AgentCapability,
|
||||
AgentEffect,
|
||||
AgentIdentity,
|
||||
Communication,
|
||||
Memory,
|
||||
Perception,
|
||||
PerceptionType,
|
||||
TimAgent,
|
||||
)
|
||||
|
||||
|
||||
class OllamaAgent(TimAgent):
|
||||
"""TimAgent implementation using local Ollama LLM.
|
||||
|
||||
This is the production agent for Timmy Time v2. It uses
|
||||
Ollama for reasoning and SQLite for memory persistence.
|
||||
|
||||
Capabilities:
|
||||
- REASONING: LLM-based inference
|
||||
- CODING: Code generation and analysis
|
||||
- WRITING: Long-form content creation
|
||||
- ANALYSIS: Data processing and insights
|
||||
- COMMUNICATION: Multi-agent messaging
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identity: AgentIdentity,
|
||||
model: str | None = None,
|
||||
effect_log: str | None = None,
|
||||
require_vision: bool = False,
|
||||
) -> None:
|
||||
"""Initialize Ollama-based agent.
|
||||
|
||||
Args:
|
||||
identity: Agent identity (persistent across sessions)
|
||||
model: Ollama model to use (auto-resolves with fallback)
|
||||
effect_log: Path to log agent effects (optional)
|
||||
require_vision: Whether to select a vision-capable model
|
||||
"""
|
||||
super().__init__(identity)
|
||||
|
||||
# Resolve model with automatic pulling and fallback
|
||||
resolved_model, is_fallback = _resolve_model_with_fallback(
|
||||
requested_model=model,
|
||||
require_vision=require_vision,
|
||||
auto_pull=True,
|
||||
)
|
||||
|
||||
if is_fallback:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).info(
|
||||
"OllamaAdapter using fallback model %s", resolved_model
|
||||
)
|
||||
|
||||
# Initialize underlying Ollama agent
|
||||
self._timmy = create_timmy(model=resolved_model)
|
||||
|
||||
# Set capabilities based on what Ollama can do
|
||||
self._capabilities = {
|
||||
AgentCapability.REASONING,
|
||||
AgentCapability.CODING,
|
||||
AgentCapability.WRITING,
|
||||
AgentCapability.ANALYSIS,
|
||||
AgentCapability.COMMUNICATION,
|
||||
}
|
||||
|
||||
# Effect logging for audit/replay
|
||||
self._effect_log = AgentEffect(effect_log) if effect_log else None
|
||||
|
||||
# Simple in-memory working memory (short term)
|
||||
self._working_memory: list[Memory] = []
|
||||
self._max_working_memory = 10
|
||||
|
||||
def perceive(self, perception: Perception) -> Memory:
|
||||
"""Process perception and store in memory.
|
||||
|
||||
For text perceptions, we might do light preprocessing
|
||||
(summarization, keyword extraction) before storage.
|
||||
"""
|
||||
# Create memory from perception
|
||||
memory = Memory(
|
||||
id=f"mem_{len(self._working_memory)}",
|
||||
content={
|
||||
"type": perception.type.name,
|
||||
"data": perception.data,
|
||||
"source": perception.source,
|
||||
},
|
||||
created_at=perception.timestamp,
|
||||
tags=self._extract_tags(perception),
|
||||
)
|
||||
|
||||
# Add to working memory
|
||||
self._working_memory.append(memory)
|
||||
if len(self._working_memory) > self._max_working_memory:
|
||||
self._working_memory.pop(0) # FIFO eviction
|
||||
|
||||
# Log effect
|
||||
if self._effect_log:
|
||||
self._effect_log.log_perceive(perception, memory.id)
|
||||
|
||||
return memory
|
||||
|
||||
def reason(self, query: str, context: list[Memory]) -> Action:
|
||||
"""Use LLM to reason and decide on action.
|
||||
|
||||
This is where the Ollama agent does its work. We construct
|
||||
a prompt from the query and context, then interpret the
|
||||
response as an action.
|
||||
"""
|
||||
# Build context string from memories
|
||||
context_str = self._format_context(context)
|
||||
|
||||
# Construct prompt
|
||||
prompt = f"""You are {self._identity.name}, an AI assistant.
|
||||
|
||||
Context from previous interactions:
|
||||
{context_str}
|
||||
|
||||
Current query: {query}
|
||||
|
||||
Respond naturally and helpfully."""
|
||||
|
||||
# Run LLM inference
|
||||
result = self._timmy.run(prompt, stream=False)
|
||||
response_text = result.content if hasattr(result, "content") else str(result)
|
||||
|
||||
# Create text response action
|
||||
action = Action.respond(response_text, confidence=0.9)
|
||||
|
||||
# Log effect
|
||||
if self._effect_log:
|
||||
self._effect_log.log_reason(query, action.type)
|
||||
|
||||
return action
|
||||
|
||||
def act(self, action: Action) -> Any:
|
||||
"""Execute action in the Ollama substrate.
|
||||
|
||||
For text actions, the "execution" is just returning the
|
||||
text (already generated during reasoning). For future
|
||||
action types (MOVE, SPEAK), this would trigger the
|
||||
appropriate Ollama tool calls.
|
||||
"""
|
||||
result = None
|
||||
|
||||
if action.type == ActionType.TEXT:
|
||||
result = action.payload
|
||||
elif action.type == ActionType.SPEAK:
|
||||
# Would call TTS here
|
||||
result = {"spoken": action.payload, "tts_engine": "pyttsx3"}
|
||||
elif action.type == ActionType.CALL:
|
||||
# Would make API call
|
||||
result = {"status": "not_implemented", "payload": action.payload}
|
||||
else:
|
||||
result = {"error": f"Action type {action.type} not supported by OllamaAgent"}
|
||||
|
||||
# Log effect
|
||||
if self._effect_log:
|
||||
self._effect_log.log_act(action, result)
|
||||
|
||||
return result
|
||||
|
||||
def remember(self, memory: Memory) -> None:
|
||||
"""Store memory in working memory.
|
||||
|
||||
Adds the memory to the sliding window and bumps its importance.
|
||||
"""
|
||||
memory.touch()
|
||||
|
||||
# Deduplicate by id
|
||||
self._working_memory = [m for m in self._working_memory if m.id != memory.id]
|
||||
self._working_memory.append(memory)
|
||||
|
||||
# Evict oldest if over capacity
|
||||
if len(self._working_memory) > self._max_working_memory:
|
||||
self._working_memory.pop(0)
|
||||
|
||||
def recall(self, query: str, limit: int = 5) -> list[Memory]:
|
||||
"""Retrieve relevant memories.
|
||||
|
||||
Simple keyword matching for now. Future: vector similarity.
|
||||
"""
|
||||
query_lower = query.lower()
|
||||
scored = []
|
||||
|
||||
for memory in self._working_memory:
|
||||
score = 0
|
||||
content_str = str(memory.content).lower()
|
||||
|
||||
# Simple keyword overlap
|
||||
query_words = set(query_lower.split())
|
||||
content_words = set(content_str.split())
|
||||
overlap = len(query_words & content_words)
|
||||
score += overlap
|
||||
|
||||
# Boost recent memories
|
||||
score += memory.importance
|
||||
|
||||
scored.append((score, memory))
|
||||
|
||||
# Sort by score descending
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
# Return top N
|
||||
return [m for _, m in scored[:limit]]
|
||||
|
||||
def communicate(self, message: Communication) -> bool:
|
||||
"""Send message to another agent.
|
||||
|
||||
Swarm comms removed — inter-agent communication will be handled
|
||||
by the unified brain memory layer.
|
||||
"""
|
||||
return False
|
||||
|
||||
def _extract_tags(self, perception: Perception) -> list[str]:
|
||||
"""Extract searchable tags from perception."""
|
||||
tags = [perception.type.name, perception.source]
|
||||
|
||||
if perception.type == PerceptionType.TEXT:
|
||||
# Simple keyword extraction
|
||||
text = str(perception.data).lower()
|
||||
keywords = ["code", "bug", "help", "question", "task"]
|
||||
for kw in keywords:
|
||||
if kw in text:
|
||||
tags.append(kw)
|
||||
|
||||
return tags
|
||||
|
||||
def _format_context(self, memories: list[Memory]) -> str:
|
||||
"""Format memories into context string for prompt."""
|
||||
if not memories:
|
||||
return "No previous context."
|
||||
|
||||
parts = []
|
||||
for mem in memories[-5:]: # Last 5 memories
|
||||
if isinstance(mem.content, dict):
|
||||
data = mem.content.get("data", "")
|
||||
parts.append(f"- {data}")
|
||||
else:
|
||||
parts.append(f"- {mem.content}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def get_effect_log(self) -> list[dict] | None:
|
||||
"""Export effect log if logging is enabled."""
|
||||
if self._effect_log:
|
||||
return self._effect_log.export()
|
||||
return None
|
||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
@@ -58,6 +59,9 @@ class AgenticResult:
|
||||
# Agent factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_loop_agent = None
|
||||
_loop_agent_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_loop_agent():
|
||||
"""Create a fresh agent for the agentic loop.
|
||||
@@ -65,9 +69,14 @@ def _get_loop_agent():
|
||||
Returns the same type of agent as `create_timmy()` but with a
|
||||
dedicated session so it doesn't pollute the main chat history.
|
||||
"""
|
||||
from timmy.agent import create_timmy
|
||||
global _loop_agent
|
||||
if _loop_agent is None:
|
||||
with _loop_agent_lock:
|
||||
if _loop_agent is None:
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
return create_timmy()
|
||||
_loop_agent = create_timmy()
|
||||
return _loop_agent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -86,6 +95,126 @@ def _parse_steps(plan_text: str) -> list[str]:
|
||||
return [line.strip() for line in plan_text.strip().splitlines() if line.strip()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extracted helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_content(run_result) -> str:
|
||||
"""Extract text content from an agent run result."""
|
||||
return run_result.content if hasattr(run_result, "content") else str(run_result)
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
"""Clean a model response using session's response cleaner."""
|
||||
from timmy.session import _clean_response
|
||||
|
||||
return _clean_response(text)
|
||||
|
||||
|
||||
async def _plan_task(
|
||||
agent, task: str, session_id: str, max_steps: int
|
||||
) -> tuple[list[str], bool] | str:
|
||||
"""Run the planning phase — returns (steps, was_truncated) or error string."""
|
||||
plan_prompt = (
|
||||
f"Break this task into numbered steps (max {max_steps}). "
|
||||
f"Return ONLY a numbered list, nothing else.\n\n"
|
||||
f"Task: {task}"
|
||||
)
|
||||
try:
|
||||
plan_run = await asyncio.to_thread(
|
||||
agent.run, plan_prompt, stream=False, session_id=f"{session_id}_plan"
|
||||
)
|
||||
plan_text = _extract_content(plan_run)
|
||||
except Exception as exc: # broad catch intentional: agent.run can raise any error
|
||||
logger.error("Agentic loop: planning failed: %s", exc)
|
||||
return f"Planning failed: {exc}"
|
||||
|
||||
steps = _parse_steps(plan_text)
|
||||
if not steps:
|
||||
return "Planning produced no steps."
|
||||
|
||||
planned_count = len(steps)
|
||||
steps = steps[:max_steps]
|
||||
return steps, planned_count > len(steps)
|
||||
|
||||
|
||||
async def _execute_step(
|
||||
agent,
|
||||
task: str,
|
||||
step_desc: str,
|
||||
step_num: int,
|
||||
total_steps: int,
|
||||
recent_results: list[str],
|
||||
session_id: str,
|
||||
) -> AgenticStep:
|
||||
"""Execute a single step, returning an AgenticStep."""
|
||||
step_start = time.monotonic()
|
||||
context = (
|
||||
f"Task: {task}\n"
|
||||
f"Step {step_num}/{total_steps}: {step_desc}\n"
|
||||
f"Recent progress: {recent_results[-2:] if recent_results else []}\n\n"
|
||||
f"Execute this step and report what you did."
|
||||
)
|
||||
step_run = await asyncio.to_thread(
|
||||
agent.run, context, stream=False, session_id=f"{session_id}_step{step_num}"
|
||||
)
|
||||
step_result = _clean(_extract_content(step_run))
|
||||
return AgenticStep(
|
||||
step_num=step_num,
|
||||
description=step_desc,
|
||||
result=step_result,
|
||||
status="completed",
|
||||
duration_ms=int((time.monotonic() - step_start) * 1000),
|
||||
)
|
||||
|
||||
|
||||
async def _adapt_step(
|
||||
agent,
|
||||
step_desc: str,
|
||||
step_num: int,
|
||||
error: Exception,
|
||||
step_start: float,
|
||||
session_id: str,
|
||||
) -> AgenticStep:
|
||||
"""Attempt adaptation after a step failure."""
|
||||
adapt_prompt = (
|
||||
f"Step {step_num} failed with error: {error}\n"
|
||||
f"Original step was: {step_desc}\n"
|
||||
f"Adapt the plan and try an alternative approach for this step."
|
||||
)
|
||||
adapt_run = await asyncio.to_thread(
|
||||
agent.run, adapt_prompt, stream=False, session_id=f"{session_id}_adapt{step_num}"
|
||||
)
|
||||
adapt_result = _clean(_extract_content(adapt_run))
|
||||
return AgenticStep(
|
||||
step_num=step_num,
|
||||
description=f"[Adapted] {step_desc}",
|
||||
result=adapt_result,
|
||||
status="adapted",
|
||||
duration_ms=int((time.monotonic() - step_start) * 1000),
|
||||
)
|
||||
|
||||
|
||||
def _summarize(result: AgenticResult, total_steps: int, was_truncated: bool) -> None:
|
||||
"""Fill in summary and final status on the result object (mutates in place)."""
|
||||
completed = sum(1 for s in result.steps if s.status == "completed")
|
||||
adapted = sum(1 for s in result.steps if s.status == "adapted")
|
||||
failed = sum(1 for s in result.steps if s.status == "failed")
|
||||
|
||||
parts = [f"Completed {completed}/{total_steps} steps"]
|
||||
if adapted:
|
||||
parts.append(f"{adapted} adapted")
|
||||
if failed:
|
||||
parts.append(f"{failed} failed")
|
||||
result.summary = f"{result.task}: {', '.join(parts)}."
|
||||
|
||||
if was_truncated or len(result.steps) < total_steps or failed:
|
||||
result.status = "partial"
|
||||
else:
|
||||
result.status = "completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core loop
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -116,88 +245,41 @@ async def run_agentic_loop(
|
||||
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
start_time = time.monotonic()
|
||||
|
||||
agent = _get_loop_agent()
|
||||
result = AgenticResult(task_id=task_id, task=task, summary="")
|
||||
|
||||
# ── Phase 1: Planning ──────────────────────────────────────────────────
|
||||
plan_prompt = (
|
||||
f"Break this task into numbered steps (max {max_steps}). "
|
||||
f"Return ONLY a numbered list, nothing else.\n\n"
|
||||
f"Task: {task}"
|
||||
)
|
||||
try:
|
||||
plan_run = await asyncio.to_thread(
|
||||
agent.run, plan_prompt, stream=False, session_id=f"{session_id}_plan"
|
||||
)
|
||||
plan_text = plan_run.content if hasattr(plan_run, "content") else str(plan_run)
|
||||
except Exception as exc:
|
||||
logger.error("Agentic loop: planning failed: %s", exc)
|
||||
# Phase 1: Planning
|
||||
plan = await _plan_task(agent, task, session_id, max_steps)
|
||||
if isinstance(plan, str):
|
||||
result.status = "failed"
|
||||
result.summary = f"Planning failed: {exc}"
|
||||
result.summary = plan
|
||||
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
return result
|
||||
|
||||
steps = _parse_steps(plan_text)
|
||||
if not steps:
|
||||
result.status = "failed"
|
||||
result.summary = "Planning produced no steps."
|
||||
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
return result
|
||||
|
||||
# Enforce max_steps — track if we truncated
|
||||
planned_steps = len(steps)
|
||||
steps = steps[:max_steps]
|
||||
steps, was_truncated = plan
|
||||
total_steps = len(steps)
|
||||
was_truncated = planned_steps > total_steps
|
||||
|
||||
# Broadcast plan
|
||||
await _broadcast_progress(
|
||||
"agentic.plan_ready",
|
||||
{
|
||||
"task_id": task_id,
|
||||
"task": task,
|
||||
"steps": steps,
|
||||
"total": total_steps,
|
||||
},
|
||||
{"task_id": task_id, "task": task, "steps": steps, "total": total_steps},
|
||||
)
|
||||
|
||||
# ── Phase 2: Execution ─────────────────────────────────────────────────
|
||||
# Phase 2: Execution
|
||||
completed_results: list[str] = []
|
||||
|
||||
for i, step_desc in enumerate(steps, 1):
|
||||
step_start = time.monotonic()
|
||||
|
||||
context = (
|
||||
f"Task: {task}\n"
|
||||
f"Plan: {plan_text}\n"
|
||||
f"Completed so far: {completed_results}\n\n"
|
||||
f"Now do step {i}: {step_desc}\n"
|
||||
f"Execute this step and report what you did."
|
||||
)
|
||||
|
||||
try:
|
||||
step_run = await asyncio.to_thread(
|
||||
agent.run, context, stream=False, session_id=f"{session_id}_step{i}"
|
||||
)
|
||||
step_result = step_run.content if hasattr(step_run, "content") else str(step_run)
|
||||
|
||||
# Clean the response
|
||||
from timmy.session import _clean_response
|
||||
|
||||
step_result = _clean_response(step_result)
|
||||
|
||||
step = AgenticStep(
|
||||
step_num=i,
|
||||
description=step_desc,
|
||||
result=step_result,
|
||||
status="completed",
|
||||
duration_ms=int((time.monotonic() - step_start) * 1000),
|
||||
step = await _execute_step(
|
||||
agent,
|
||||
task,
|
||||
step_desc,
|
||||
i,
|
||||
total_steps,
|
||||
completed_results,
|
||||
session_id,
|
||||
)
|
||||
result.steps.append(step)
|
||||
completed_results.append(f"Step {i}: {step_result[:200]}")
|
||||
|
||||
# Broadcast progress
|
||||
completed_results.append(f"Step {i}: {step.result[:200]}")
|
||||
await _broadcast_progress(
|
||||
"agentic.step_complete",
|
||||
{
|
||||
@@ -205,46 +287,18 @@ async def run_agentic_loop(
|
||||
"step": i,
|
||||
"total": total_steps,
|
||||
"description": step_desc,
|
||||
"result": step_result[:200],
|
||||
"result": step.result[:200],
|
||||
},
|
||||
)
|
||||
|
||||
if on_progress:
|
||||
await on_progress(step_desc, i, total_steps)
|
||||
|
||||
except Exception as exc:
|
||||
except Exception as exc: # broad catch intentional: agent.run can raise any error
|
||||
logger.warning("Agentic loop step %d failed: %s", i, exc)
|
||||
|
||||
# ── Adaptation: ask model to adapt ─────────────────────────────
|
||||
adapt_prompt = (
|
||||
f"Step {i} failed with error: {exc}\n"
|
||||
f"Original step was: {step_desc}\n"
|
||||
f"Adapt the plan and try an alternative approach for this step."
|
||||
)
|
||||
try:
|
||||
adapt_run = await asyncio.to_thread(
|
||||
agent.run,
|
||||
adapt_prompt,
|
||||
stream=False,
|
||||
session_id=f"{session_id}_adapt{i}",
|
||||
)
|
||||
adapt_result = (
|
||||
adapt_run.content if hasattr(adapt_run, "content") else str(adapt_run)
|
||||
)
|
||||
from timmy.session import _clean_response
|
||||
|
||||
adapt_result = _clean_response(adapt_result)
|
||||
|
||||
step = AgenticStep(
|
||||
step_num=i,
|
||||
description=f"[Adapted] {step_desc}",
|
||||
result=adapt_result,
|
||||
status="adapted",
|
||||
duration_ms=int((time.monotonic() - step_start) * 1000),
|
||||
)
|
||||
step = await _adapt_step(agent, step_desc, i, exc, step_start, session_id)
|
||||
result.steps.append(step)
|
||||
completed_results.append(f"Step {i} (adapted): {adapt_result[:200]}")
|
||||
|
||||
completed_results.append(f"Step {i} (adapted): {step.result[:200]}")
|
||||
await _broadcast_progress(
|
||||
"agentic.step_adapted",
|
||||
{
|
||||
@@ -253,58 +307,26 @@ async def run_agentic_loop(
|
||||
"total": total_steps,
|
||||
"description": step_desc,
|
||||
"error": str(exc),
|
||||
"adaptation": adapt_result[:200],
|
||||
"adaptation": step.result[:200],
|
||||
},
|
||||
)
|
||||
|
||||
if on_progress:
|
||||
await on_progress(f"[Adapted] {step_desc}", i, total_steps)
|
||||
|
||||
except Exception as adapt_exc:
|
||||
except Exception as adapt_exc: # broad catch intentional
|
||||
logger.error("Agentic loop adaptation also failed: %s", adapt_exc)
|
||||
step = AgenticStep(
|
||||
step_num=i,
|
||||
description=step_desc,
|
||||
result=f"Failed: {exc}; Adaptation also failed: {adapt_exc}",
|
||||
status="failed",
|
||||
duration_ms=int((time.monotonic() - step_start) * 1000),
|
||||
result.steps.append(
|
||||
AgenticStep(
|
||||
step_num=i,
|
||||
description=step_desc,
|
||||
result=f"Failed: {exc}; Adaptation also failed: {adapt_exc}",
|
||||
status="failed",
|
||||
duration_ms=int((time.monotonic() - step_start) * 1000),
|
||||
)
|
||||
)
|
||||
result.steps.append(step)
|
||||
completed_results.append(f"Step {i}: FAILED")
|
||||
|
||||
# ── Phase 3: Summary ───────────────────────────────────────────────────
|
||||
summary_prompt = (
|
||||
f"Task: {task}\n"
|
||||
f"Results:\n" + "\n".join(completed_results) + "\n\n"
|
||||
"Summarise what was accomplished in 2-3 sentences."
|
||||
)
|
||||
try:
|
||||
summary_run = await asyncio.to_thread(
|
||||
agent.run,
|
||||
summary_prompt,
|
||||
stream=False,
|
||||
session_id=f"{session_id}_summary",
|
||||
)
|
||||
result.summary = (
|
||||
summary_run.content if hasattr(summary_run, "content") else str(summary_run)
|
||||
)
|
||||
from timmy.session import _clean_response
|
||||
|
||||
result.summary = _clean_response(result.summary)
|
||||
except Exception as exc:
|
||||
logger.error("Agentic loop summary failed: %s", exc)
|
||||
result.summary = f"Completed {len(result.steps)} steps."
|
||||
|
||||
# Determine final status
|
||||
if was_truncated:
|
||||
result.status = "partial"
|
||||
elif len(result.steps) < total_steps:
|
||||
result.status = "partial"
|
||||
elif any(s.status == "failed" for s in result.steps):
|
||||
result.status = "partial"
|
||||
else:
|
||||
result.status = "completed"
|
||||
|
||||
# Phase 3: Summary
|
||||
_summarize(result, total_steps, was_truncated)
|
||||
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
|
||||
await _broadcast_progress(
|
||||
@@ -332,5 +354,6 @@ async def _broadcast_progress(event: str, data: dict) -> None:
|
||||
from infrastructure.ws_manager.handler import ws_manager
|
||||
|
||||
await ws_manager.broadcast(event, data)
|
||||
except Exception:
|
||||
except (ImportError, AttributeError, ConnectionError, RuntimeError) as exc:
|
||||
logger.warning("Agentic loop broadcast failed: %s", exc)
|
||||
logger.debug("Agentic loop: WS broadcast failed for %s", event)
|
||||
|
||||
@@ -10,10 +10,12 @@ SubAgent is the single seed class for ALL agents. Differentiation
|
||||
comes entirely from config (agents.yaml), not from Python subclasses.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from agno.agent import Agent
|
||||
from agno.models.ollama import Ollama
|
||||
|
||||
@@ -72,9 +74,12 @@ class BaseAgent(ABC):
|
||||
if handler:
|
||||
tool_instances.append(handler)
|
||||
|
||||
ollama_kwargs = {}
|
||||
if settings.ollama_num_ctx > 0:
|
||||
ollama_kwargs["options"] = {"num_ctx": settings.ollama_num_ctx}
|
||||
return Agent(
|
||||
name=self.name,
|
||||
model=Ollama(id=self.model, host=settings.ollama_url, timeout=300),
|
||||
model=Ollama(id=self.model, host=settings.ollama_url, timeout=300, **ollama_kwargs),
|
||||
description=system_prompt,
|
||||
tools=tool_instances if tool_instances else None,
|
||||
add_history_to_context=True,
|
||||
@@ -117,11 +122,70 @@ class BaseAgent(ABC):
|
||||
async def run(self, message: str) -> str:
|
||||
"""Run the agent with a message.
|
||||
|
||||
Retries on transient failures (connection errors, timeouts) with
|
||||
exponential backoff. GPU contention from concurrent Ollama
|
||||
requests causes ReadError / ReadTimeout — these are transient
|
||||
and should be retried, not raised immediately (#70).
|
||||
|
||||
Returns:
|
||||
Agent response
|
||||
"""
|
||||
result = self.agent.run(message, stream=False)
|
||||
response = result.content if hasattr(result, "content") else str(result)
|
||||
max_retries = 3
|
||||
last_exception = None
|
||||
# Transient errors that indicate Ollama contention or temporary
|
||||
# unavailability — these deserve a retry with backoff.
|
||||
_transient = (
|
||||
httpx.ConnectError,
|
||||
httpx.ReadError,
|
||||
httpx.ReadTimeout,
|
||||
httpx.ConnectTimeout,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
)
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
result = self.agent.run(message, stream=False)
|
||||
response = result.content if hasattr(result, "content") else str(result)
|
||||
break # Success, exit the retry loop
|
||||
except _transient as exc:
|
||||
last_exception = exc
|
||||
if attempt < max_retries:
|
||||
# Contention backoff — longer waits because the GPU
|
||||
# needs time to finish the other request.
|
||||
wait = min(2**attempt, 16)
|
||||
logger.warning(
|
||||
"Ollama contention on attempt %d/%d: %s. Waiting %ds before retry...",
|
||||
attempt,
|
||||
max_retries,
|
||||
type(exc).__name__,
|
||||
wait,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
else:
|
||||
logger.error(
|
||||
"Ollama unreachable after %d attempts: %s",
|
||||
max_retries,
|
||||
exc,
|
||||
)
|
||||
raise last_exception from exc
|
||||
except Exception as exc:
|
||||
last_exception = exc
|
||||
if attempt < max_retries:
|
||||
logger.warning(
|
||||
"Agent run failed on attempt %d/%d: %s. Retrying...",
|
||||
attempt,
|
||||
max_retries,
|
||||
exc,
|
||||
)
|
||||
await asyncio.sleep(min(2 ** (attempt - 1), 8))
|
||||
else:
|
||||
logger.error(
|
||||
"Agent run failed after %d attempts: %s",
|
||||
max_retries,
|
||||
exc,
|
||||
)
|
||||
raise last_exception from exc
|
||||
|
||||
# Emit completion event
|
||||
if self.event_bus:
|
||||
|
||||
@@ -16,6 +16,7 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -181,6 +182,23 @@ def get_routing_config() -> dict[str, Any]:
|
||||
return config.get("routing", {"method": "pattern", "patterns": {}})
|
||||
|
||||
|
||||
def _matches_pattern(pattern: str, message: str) -> bool:
|
||||
"""Check if a pattern matches using word-boundary matching.
|
||||
|
||||
For single-word patterns, uses \b word boundaries.
|
||||
For multi-word patterns, all words must appear as whole words (in any order).
|
||||
"""
|
||||
pattern_lower = pattern.lower()
|
||||
message_lower = message.lower()
|
||||
words = pattern_lower.split()
|
||||
|
||||
for word in words:
|
||||
# Use word boundary regex to match whole words only
|
||||
if not re.search(rf"\b{re.escape(word)}\b", message_lower):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def route_request(user_message: str) -> str | None:
|
||||
"""Route a user request to an agent using pattern matching.
|
||||
|
||||
@@ -193,17 +211,36 @@ def route_request(user_message: str) -> str | None:
|
||||
return None
|
||||
|
||||
patterns = routing.get("patterns", {})
|
||||
message_lower = user_message.lower()
|
||||
|
||||
for agent_id, keywords in patterns.items():
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in message_lower:
|
||||
if _matches_pattern(keyword, user_message):
|
||||
logger.debug("Routed to %s (matched: %r)", agent_id, keyword)
|
||||
return agent_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def route_request_with_match(user_message: str) -> tuple[str | None, str | None]:
|
||||
"""Route a user request and return both the agent and the matched pattern.
|
||||
|
||||
Returns a tuple of (agent_id, matched_pattern). If no match, returns (None, None).
|
||||
"""
|
||||
routing = get_routing_config()
|
||||
|
||||
if routing.get("method") != "pattern":
|
||||
return None, None
|
||||
|
||||
patterns = routing.get("patterns", {})
|
||||
|
||||
for agent_id, keywords in patterns.items():
|
||||
for keyword in keywords:
|
||||
if _matches_pattern(keyword, user_message):
|
||||
return agent_id, keyword
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def reload_agents() -> dict[str, Any]:
|
||||
"""Force reload agents from YAML. Call after editing agents.yaml."""
|
||||
global _agents, _config
|
||||
|
||||
@@ -13,6 +13,8 @@ Default is always True. The owner changes this intentionally.
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from contextlib import closing, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -43,23 +45,24 @@ class ApprovalItem:
|
||||
status: str # "pending" | "approved" | "rejected"
|
||||
|
||||
|
||||
def _get_conn(db_path: Path = _DEFAULT_DB) -> sqlite3.Connection:
|
||||
@contextmanager
|
||||
def _get_conn(db_path: Path = _DEFAULT_DB) -> Generator[sqlite3.Connection, None, None]:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS approval_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
proposed_action TEXT NOT NULL,
|
||||
impact TEXT NOT NULL DEFAULT 'low',
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS approval_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
proposed_action TEXT NOT NULL,
|
||||
impact TEXT NOT NULL DEFAULT 'low',
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
yield conn
|
||||
|
||||
|
||||
def _row_to_item(row: sqlite3.Row) -> ApprovalItem:
|
||||
@@ -96,80 +99,73 @@ def create_item(
|
||||
created_at=datetime.now(UTC),
|
||||
status="pending",
|
||||
)
|
||||
conn = _get_conn(db_path)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO approval_items
|
||||
(id, title, description, proposed_action, impact, created_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
item.id,
|
||||
item.title,
|
||||
item.description,
|
||||
item.proposed_action,
|
||||
item.impact,
|
||||
item.created_at.isoformat(),
|
||||
item.status,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_conn(db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO approval_items
|
||||
(id, title, description, proposed_action, impact, created_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
item.id,
|
||||
item.title,
|
||||
item.description,
|
||||
item.proposed_action,
|
||||
item.impact,
|
||||
item.created_at.isoformat(),
|
||||
item.status,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return item
|
||||
|
||||
|
||||
def list_pending(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
|
||||
"""Return all pending approval items, newest first."""
|
||||
conn = _get_conn(db_path)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM approval_items WHERE status = 'pending' ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
with _get_conn(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM approval_items WHERE status = 'pending' ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [_row_to_item(r) for r in rows]
|
||||
|
||||
|
||||
def list_all(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
|
||||
"""Return all approval items regardless of status, newest first."""
|
||||
conn = _get_conn(db_path)
|
||||
rows = conn.execute("SELECT * FROM approval_items ORDER BY created_at DESC").fetchall()
|
||||
conn.close()
|
||||
with _get_conn(db_path) as conn:
|
||||
rows = conn.execute("SELECT * FROM approval_items ORDER BY created_at DESC").fetchall()
|
||||
return [_row_to_item(r) for r in rows]
|
||||
|
||||
|
||||
def get_item(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
||||
conn = _get_conn(db_path)
|
||||
row = conn.execute("SELECT * FROM approval_items WHERE id = ?", (item_id,)).fetchone()
|
||||
conn.close()
|
||||
with _get_conn(db_path) as conn:
|
||||
row = conn.execute("SELECT * FROM approval_items WHERE id = ?", (item_id,)).fetchone()
|
||||
return _row_to_item(row) if row else None
|
||||
|
||||
|
||||
def approve(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
||||
"""Mark an approval item as approved."""
|
||||
conn = _get_conn(db_path)
|
||||
conn.execute("UPDATE approval_items SET status = 'approved' WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_conn(db_path) as conn:
|
||||
conn.execute("UPDATE approval_items SET status = 'approved' WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
return get_item(item_id, db_path)
|
||||
|
||||
|
||||
def reject(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
||||
"""Mark an approval item as rejected."""
|
||||
conn = _get_conn(db_path)
|
||||
conn.execute("UPDATE approval_items SET status = 'rejected' WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_conn(db_path) as conn:
|
||||
conn.execute("UPDATE approval_items SET status = 'rejected' WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
return get_item(item_id, db_path)
|
||||
|
||||
|
||||
def expire_old(db_path: Path = _DEFAULT_DB) -> int:
|
||||
"""Auto-expire pending items older than EXPIRY_DAYS. Returns count removed."""
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=_EXPIRY_DAYS)).isoformat()
|
||||
conn = _get_conn(db_path)
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM approval_items WHERE status = 'pending' AND created_at < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
conn.commit()
|
||||
count = cursor.rowcount
|
||||
conn.close()
|
||||
with _get_conn(db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM approval_items WHERE status = 'pending' AND created_at < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
conn.commit()
|
||||
count = cursor.rowcount
|
||||
return count
|
||||
|
||||
@@ -18,7 +18,7 @@ import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from timmy.prompts import SYSTEM_PROMPT
|
||||
from timmy.prompts import get_system_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,6 +37,7 @@ class RunResult:
|
||||
"""Minimal Agno-compatible run result — carries the model's response text."""
|
||||
|
||||
content: str
|
||||
confidence: float | None = None
|
||||
|
||||
|
||||
def is_apple_silicon() -> bool:
|
||||
@@ -128,7 +129,7 @@ class TimmyAirLLMAgent:
|
||||
# ── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_prompt(self, message: str) -> str:
|
||||
context = SYSTEM_PROMPT + "\n\n"
|
||||
context = get_system_prompt(tools_enabled=False, session_id="airllm") + "\n\n"
|
||||
# Include the last 10 turns (5 exchanges) for continuity.
|
||||
if self._history:
|
||||
context += "\n".join(self._history[-10:]) + "\n\n"
|
||||
@@ -388,7 +389,9 @@ class GrokBackend:
|
||||
|
||||
def _build_messages(self, message: str) -> list[dict[str, str]]:
|
||||
"""Build the messages array for the API call."""
|
||||
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
messages = [
|
||||
{"role": "system", "content": get_system_prompt(tools_enabled=True, session_id="grok")}
|
||||
]
|
||||
# Include conversation history for context
|
||||
messages.extend(self._history[-10:])
|
||||
messages.append({"role": "user", "content": message})
|
||||
@@ -414,7 +417,8 @@ def grok_available() -> bool:
|
||||
from config import settings
|
||||
|
||||
return settings.grok_enabled and bool(settings.xai_api_key)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Backend check failed (grok_available): %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
@@ -480,7 +484,7 @@ class ClaudeBackend:
|
||||
response = client.messages.create(
|
||||
model=self._model,
|
||||
max_tokens=1024,
|
||||
system=SYSTEM_PROMPT,
|
||||
system=get_system_prompt(tools_enabled=True, session_id="claude"),
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
@@ -566,5 +570,6 @@ def claude_available() -> bool:
|
||||
from config import settings
|
||||
|
||||
return bool(settings.anthropic_api_key)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Backend check failed (claude_available): %s", exc)
|
||||
return False
|
||||
|
||||
@@ -10,6 +10,8 @@ regenerates the briefing every 6 hours.
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections.abc import Generator
|
||||
from contextlib import closing, contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -56,46 +58,45 @@ class Briefing:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_cache_conn(db_path: Path = _DEFAULT_DB) -> sqlite3.Connection:
|
||||
@contextmanager
|
||||
def _get_cache_conn(db_path: Path = _DEFAULT_DB) -> Generator[sqlite3.Connection, None, None]:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS briefings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
generated_at TEXT NOT NULL,
|
||||
period_start TEXT NOT NULL,
|
||||
period_end TEXT NOT NULL,
|
||||
summary TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS briefings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
generated_at TEXT NOT NULL,
|
||||
period_start TEXT NOT NULL,
|
||||
period_end TEXT NOT NULL,
|
||||
summary TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
yield conn
|
||||
|
||||
|
||||
def _save_briefing(briefing: Briefing, db_path: Path = _DEFAULT_DB) -> None:
|
||||
conn = _get_cache_conn(db_path)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO briefings (generated_at, period_start, period_end, summary)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
briefing.generated_at.isoformat(),
|
||||
briefing.period_start.isoformat(),
|
||||
briefing.period_end.isoformat(),
|
||||
briefing.summary,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with _get_cache_conn(db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO briefings (generated_at, period_start, period_end, summary)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
briefing.generated_at.isoformat(),
|
||||
briefing.period_start.isoformat(),
|
||||
briefing.period_end.isoformat(),
|
||||
briefing.summary,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _load_latest(db_path: Path = _DEFAULT_DB) -> Briefing | None:
|
||||
"""Load the most-recently cached briefing, or None if there is none."""
|
||||
conn = _get_cache_conn(db_path)
|
||||
row = conn.execute("SELECT * FROM briefings ORDER BY generated_at DESC LIMIT 1").fetchone()
|
||||
conn.close()
|
||||
with _get_cache_conn(db_path) as conn:
|
||||
row = conn.execute("SELECT * FROM briefings ORDER BY generated_at DESC LIMIT 1").fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return Briefing(
|
||||
@@ -129,27 +130,25 @@ def _gather_swarm_summary(since: datetime) -> str:
|
||||
return "No swarm activity recorded yet."
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(swarm_db))
|
||||
conn.row_factory = sqlite3.Row
|
||||
with closing(sqlite3.connect(str(swarm_db))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
since_iso = since.isoformat()
|
||||
since_iso = since.isoformat()
|
||||
|
||||
completed = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM tasks WHERE status = 'completed' AND created_at > ?",
|
||||
(since_iso,),
|
||||
).fetchone()["c"]
|
||||
completed = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM tasks WHERE status = 'completed' AND created_at > ?",
|
||||
(since_iso,),
|
||||
).fetchone()["c"]
|
||||
|
||||
failed = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM tasks WHERE status = 'failed' AND created_at > ?",
|
||||
(since_iso,),
|
||||
).fetchone()["c"]
|
||||
failed = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM tasks WHERE status = 'failed' AND created_at > ?",
|
||||
(since_iso,),
|
||||
).fetchone()["c"]
|
||||
|
||||
agents = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM agents WHERE registered_at > ?",
|
||||
(since_iso,),
|
||||
).fetchone()["c"]
|
||||
|
||||
conn.close()
|
||||
agents = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM agents WHERE registered_at > ?",
|
||||
(since_iso,),
|
||||
).fetchone()["c"]
|
||||
|
||||
parts = []
|
||||
if completed:
|
||||
@@ -193,7 +192,7 @@ def _gather_task_queue_summary() -> str:
|
||||
def _gather_chat_summary(since: datetime) -> str:
|
||||
"""Pull recent chat messages from the in-memory log."""
|
||||
try:
|
||||
from dashboard.store import message_log
|
||||
from infrastructure.chat_store import message_log
|
||||
|
||||
messages = message_log.all()
|
||||
# Filter to messages in the briefing window (best-effort: no timestamps)
|
||||
|
||||
138
src/timmy/cli.py
138
src/timmy/cli.py
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -137,13 +138,15 @@ def think(
|
||||
model_size: str | None = _MODEL_SIZE_OPTION,
|
||||
):
|
||||
"""Ask Timmy to think carefully about a topic."""
|
||||
timmy = create_timmy(backend=backend, model_size=model_size)
|
||||
timmy = create_timmy(backend=backend, model_size=model_size, session_id=_CLI_SESSION_ID)
|
||||
timmy.print_response(f"Think carefully about: {topic}", stream=True, session_id=_CLI_SESSION_ID)
|
||||
|
||||
|
||||
@app.command()
|
||||
def chat(
|
||||
message: str = typer.Argument(..., help="Message to send"),
|
||||
message: list[str] = typer.Argument(
|
||||
..., help="Message to send (multiple words are joined automatically)"
|
||||
),
|
||||
backend: str | None = _BACKEND_OPTION,
|
||||
model_size: str | None = _MODEL_SIZE_OPTION,
|
||||
new_session: bool = typer.Option(
|
||||
@@ -172,19 +175,36 @@ def chat(
|
||||
Use --autonomous for non-interactive contexts (scripts, dev loops). Tool
|
||||
calls are checked against config/allowlist.yaml — allowlisted operations
|
||||
execute automatically, everything else is safely rejected.
|
||||
|
||||
Read from stdin by passing "-" as the message or piping input.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
# Join multiple arguments into a single message string
|
||||
message_str = " ".join(message)
|
||||
|
||||
# Handle stdin input if "-" is passed or stdin is not a tty
|
||||
if message_str == "-" or not _is_interactive():
|
||||
try:
|
||||
stdin_content = sys.stdin.read().strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
stdin_content = ""
|
||||
if stdin_content:
|
||||
message_str = stdin_content
|
||||
elif message_str == "-":
|
||||
typer.echo("No input provided via stdin.", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if session_id is not None:
|
||||
pass # use the provided value
|
||||
elif new_session:
|
||||
session_id = str(uuid.uuid4())
|
||||
else:
|
||||
session_id = _CLI_SESSION_ID
|
||||
timmy = create_timmy(backend=backend, model_size=model_size)
|
||||
timmy = create_timmy(backend=backend, model_size=model_size, session_id=session_id)
|
||||
|
||||
# Use agent.run() so we can intercept paused runs for tool confirmation.
|
||||
run_output = timmy.run(message, stream=False, session_id=session_id)
|
||||
run_output = timmy.run(message_str, stream=False, session_id=session_id)
|
||||
|
||||
# Handle paused runs — dangerous tools need user approval
|
||||
run_output = _handle_tool_confirmation(timmy, run_output, session_id, autonomous=autonomous)
|
||||
@@ -197,13 +217,68 @@ def chat(
|
||||
typer.echo(_clean_response(content))
|
||||
|
||||
|
||||
@app.command()
|
||||
def repl(
|
||||
backend: str | None = _BACKEND_OPTION,
|
||||
model_size: str | None = _MODEL_SIZE_OPTION,
|
||||
session_id: str | None = typer.Option(
|
||||
None,
|
||||
"--session-id",
|
||||
help="Use a specific session ID for this conversation",
|
||||
),
|
||||
):
|
||||
"""Start an interactive REPL session with Timmy.
|
||||
|
||||
Keeps the agent warm between messages. Conversation history is persisted
|
||||
across invocations. Use Ctrl+C or Ctrl+D to exit gracefully.
|
||||
"""
|
||||
from timmy.session import chat
|
||||
|
||||
if session_id is None:
|
||||
session_id = _CLI_SESSION_ID
|
||||
|
||||
typer.echo(typer.style("Timmy REPL", bold=True))
|
||||
typer.echo("Type your messages below. Use Ctrl+C or Ctrl+D to exit.")
|
||||
typer.echo()
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
user_input = input("> ")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
typer.echo()
|
||||
typer.echo("Goodbye!")
|
||||
break
|
||||
|
||||
user_input = user_input.strip()
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
if user_input.lower() in ("exit", "quit", "q"):
|
||||
typer.echo("Goodbye!")
|
||||
break
|
||||
|
||||
try:
|
||||
response = loop.run_until_complete(chat(user_input, session_id=session_id))
|
||||
if response:
|
||||
typer.echo(response)
|
||||
typer.echo()
|
||||
except Exception as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@app.command()
|
||||
def status(
|
||||
backend: str | None = _BACKEND_OPTION,
|
||||
model_size: str | None = _MODEL_SIZE_OPTION,
|
||||
):
|
||||
"""Print Timmy's operational status."""
|
||||
timmy = create_timmy(backend=backend, model_size=model_size)
|
||||
timmy = create_timmy(backend=backend, model_size=model_size, session_id=_CLI_SESSION_ID)
|
||||
timmy.print_response(STATUS_PROMPT, stream=False, session_id=_CLI_SESSION_ID)
|
||||
|
||||
|
||||
@@ -259,7 +334,8 @@ def interview(
|
||||
from timmy.mcp_tools import close_mcp_sessions
|
||||
|
||||
loop.run_until_complete(close_mcp_sessions())
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("MCP session close failed: %s", exc)
|
||||
pass
|
||||
loop.close()
|
||||
|
||||
@@ -325,5 +401,55 @@ def voice(
|
||||
loop.run()
|
||||
|
||||
|
||||
@app.command()
|
||||
def route(
|
||||
message: list[str] = typer.Argument(..., help="Message to route"),
|
||||
):
|
||||
"""Show which agent would handle a message (debug routing)."""
|
||||
full_message = " ".join(message)
|
||||
from timmy.agents.loader import route_request_with_match
|
||||
|
||||
agent_id, matched_pattern = route_request_with_match(full_message)
|
||||
if agent_id:
|
||||
typer.echo(f"→ {agent_id} (matched: {matched_pattern})")
|
||||
else:
|
||||
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()
|
||||
|
||||
250
src/timmy/cognitive_state.py
Normal file
250
src/timmy/cognitive_state.py
Normal 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()
|
||||
128
src/timmy/confidence.py
Normal file
128
src/timmy/confidence.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Confidence estimation for Timmy's responses.
|
||||
|
||||
Implements SOUL.md requirement: "When I am uncertain, I must say so in
|
||||
proportion to my uncertainty."
|
||||
|
||||
This module provides heuristics to estimate confidence based on linguistic
|
||||
signals in the response text. It measures uncertainty without modifying
|
||||
the response content.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Hedging words that indicate uncertainty
|
||||
HEDGING_WORDS = [
|
||||
"i think",
|
||||
"maybe",
|
||||
"perhaps",
|
||||
"not sure",
|
||||
"might",
|
||||
"could be",
|
||||
"possibly",
|
||||
"i believe",
|
||||
"approximately",
|
||||
"roughly",
|
||||
"probably",
|
||||
"likely",
|
||||
"seems",
|
||||
"appears",
|
||||
"suggests",
|
||||
"i guess",
|
||||
"i suppose",
|
||||
"sort of",
|
||||
"kind of",
|
||||
"somewhat",
|
||||
"fairly",
|
||||
"relatively",
|
||||
"i'm not certain",
|
||||
"i am not certain",
|
||||
"uncertain",
|
||||
"unclear",
|
||||
]
|
||||
|
||||
# Certainty words that indicate confidence
|
||||
CERTAINTY_WORDS = [
|
||||
"i know",
|
||||
"definitely",
|
||||
"certainly",
|
||||
"the answer is",
|
||||
"specifically",
|
||||
"exactly",
|
||||
"absolutely",
|
||||
"without doubt",
|
||||
"i am certain",
|
||||
"i'm certain",
|
||||
"it is true that",
|
||||
"fact is",
|
||||
"in fact",
|
||||
"indeed",
|
||||
"undoubtedly",
|
||||
"clearly",
|
||||
"obviously",
|
||||
"conclusively",
|
||||
]
|
||||
|
||||
# Very low confidence indicators (direct admissions of ignorance)
|
||||
LOW_CONFIDENCE_PATTERNS = [
|
||||
r"i\s+(?:don't|do not)\s+know",
|
||||
r"i\s+(?:am|I'm|i'm)\s+(?:not\s+sure|unsure)",
|
||||
r"i\s+have\s+no\s+(?:idea|clue)",
|
||||
r"i\s+cannot\s+(?:say|tell|answer)",
|
||||
r"i\s+can't\s+(?:say|tell|answer)",
|
||||
]
|
||||
|
||||
|
||||
def estimate_confidence(text: str) -> float:
|
||||
"""Estimate confidence level of a response based on linguistic signals.
|
||||
|
||||
Analyzes the text for hedging words (reducing confidence) and certainty
|
||||
words (increasing confidence). Returns a score between 0.0 and 1.0.
|
||||
|
||||
Args:
|
||||
text: The response text to analyze.
|
||||
|
||||
Returns:
|
||||
A float between 0.0 (very uncertain) and 1.0 (very confident).
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return 0.0
|
||||
|
||||
text_lower = text.lower().strip()
|
||||
confidence = 0.5 # Start with neutral confidence
|
||||
|
||||
# Check for direct admissions of ignorance (very low confidence)
|
||||
for pattern in LOW_CONFIDENCE_PATTERNS:
|
||||
if re.search(pattern, text_lower):
|
||||
# Direct admission of not knowing - very low confidence
|
||||
confidence = 0.15
|
||||
break
|
||||
|
||||
# Count hedging words (reduce confidence)
|
||||
hedging_count = 0
|
||||
for hedge in HEDGING_WORDS:
|
||||
if hedge in text_lower:
|
||||
hedging_count += 1
|
||||
|
||||
# Count certainty words (increase confidence)
|
||||
certainty_count = 0
|
||||
for certain in CERTAINTY_WORDS:
|
||||
if certain in text_lower:
|
||||
certainty_count += 1
|
||||
|
||||
# Adjust confidence based on word counts
|
||||
# Each hedging word reduces confidence by 0.1
|
||||
# Each certainty word increases confidence by 0.1
|
||||
confidence -= hedging_count * 0.1
|
||||
confidence += certainty_count * 0.1
|
||||
|
||||
# Short factual answers get a small boost
|
||||
word_count = len(text.split())
|
||||
if word_count <= 5 and confidence > 0.3:
|
||||
confidence += 0.1
|
||||
|
||||
# Questions in response indicate uncertainty
|
||||
if "?" in text:
|
||||
confidence -= 0.15
|
||||
|
||||
# Clamp to valid range
|
||||
return max(0.0, min(1.0, confidence))
|
||||
79
src/timmy/event_bus.py
Normal file
79
src/timmy/event_bus.py
Normal 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
39
src/timmy/events.py
Normal 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
263
src/timmy/familiar.py
Normal 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
105
src/timmy/focus.py
Normal 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()
|
||||
387
src/timmy/gematria.py
Normal file
387
src/timmy/gematria.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""Gematria computation engine — the language of letters and numbers.
|
||||
|
||||
Implements multiple cipher systems for gematric analysis:
|
||||
- Simple English (A=1 .. Z=26)
|
||||
- Full Reduction (reduce each letter value to single digit)
|
||||
- Reverse Ordinal (A=26 .. Z=1)
|
||||
- Sumerian (Simple × 6)
|
||||
- Hebrew (traditional letter values, for A-Z mapping)
|
||||
|
||||
Also provides numerological reduction, notable-number lookup,
|
||||
and multi-phrase comparison.
|
||||
|
||||
Alexander Whitestone = 222 in Simple English Gematria.
|
||||
This is not trivia. It is foundational.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
# ── Cipher Tables ────────────────────────────────────────────────────────────
|
||||
|
||||
# Simple English: A=1, B=2, ..., Z=26
|
||||
_SIMPLE: dict[str, int] = {chr(i): i - 64 for i in range(65, 91)}
|
||||
|
||||
# Full Reduction: reduce each letter to single digit (A=1..I=9, J=1..R=9, S=1..Z=8)
|
||||
_REDUCTION: dict[str, int] = {}
|
||||
for _c, _v in _SIMPLE.items():
|
||||
_r = _v
|
||||
while _r > 9:
|
||||
_r = sum(int(d) for d in str(_r))
|
||||
_REDUCTION[_c] = _r
|
||||
|
||||
# Reverse Ordinal: A=26, B=25, ..., Z=1
|
||||
_REVERSE: dict[str, int] = {chr(i): 91 - i for i in range(65, 91)}
|
||||
|
||||
# Sumerian: Simple × 6
|
||||
_SUMERIAN: dict[str, int] = {c: v * 6 for c, v in _SIMPLE.items()}
|
||||
|
||||
# Hebrew-mapped: traditional Hebrew gematria mapped to Latin alphabet
|
||||
# Aleph=1..Tet=9, Yod=10..Tsade=90, Qoph=100..Tav=400
|
||||
# Standard mapping for the 22 Hebrew letters extended to 26 Latin chars
|
||||
_HEBREW: dict[str, int] = {
|
||||
"A": 1,
|
||||
"B": 2,
|
||||
"C": 3,
|
||||
"D": 4,
|
||||
"E": 5,
|
||||
"F": 6,
|
||||
"G": 7,
|
||||
"H": 8,
|
||||
"I": 9,
|
||||
"J": 10,
|
||||
"K": 20,
|
||||
"L": 30,
|
||||
"M": 40,
|
||||
"N": 50,
|
||||
"O": 60,
|
||||
"P": 70,
|
||||
"Q": 80,
|
||||
"R": 90,
|
||||
"S": 100,
|
||||
"T": 200,
|
||||
"U": 300,
|
||||
"V": 400,
|
||||
"W": 500,
|
||||
"X": 600,
|
||||
"Y": 700,
|
||||
"Z": 800,
|
||||
}
|
||||
|
||||
CIPHERS: dict[str, dict[str, int]] = {
|
||||
"simple": _SIMPLE,
|
||||
"reduction": _REDUCTION,
|
||||
"reverse": _REVERSE,
|
||||
"sumerian": _SUMERIAN,
|
||||
"hebrew": _HEBREW,
|
||||
}
|
||||
|
||||
# ── Notable Numbers ──────────────────────────────────────────────────────────
|
||||
|
||||
NOTABLE_NUMBERS: dict[int, str] = {
|
||||
1: "Unity, the Monad, beginning of all",
|
||||
3: "Trinity, divine completeness, the Triad",
|
||||
7: "Spiritual perfection, completion (7 days, 7 seals)",
|
||||
9: "Finality, judgment, the last single digit",
|
||||
11: "Master number — intuition, spiritual insight",
|
||||
12: "Divine government (12 tribes, 12 apostles)",
|
||||
13: "Rebellion and transformation, the 13th step",
|
||||
22: "Master builder — turning dreams into reality",
|
||||
26: "YHWH (Yod=10, He=5, Vav=6, He=5)",
|
||||
33: "Master teacher — Christ consciousness, 33 vertebrae",
|
||||
36: "The number of the righteous (Lamed-Vav Tzadikim)",
|
||||
40: "Trial, testing, probation (40 days, 40 years)",
|
||||
42: "The answer, and the number of generations to Christ",
|
||||
72: "The Shemhamphorasch — 72 names of God",
|
||||
88: "Mercury, infinite abundance, double infinity",
|
||||
108: "Sacred in Hinduism and Buddhism (108 beads)",
|
||||
111: "Angel number — new beginnings, alignment",
|
||||
144: "12² — the elect, the sealed (144,000)",
|
||||
153: "The miraculous catch of fish (John 21:11)",
|
||||
222: "Alexander Whitestone. Balance, partnership, trust the process",
|
||||
333: "Ascended masters present, divine protection",
|
||||
369: "Tesla's key to the universe",
|
||||
444: "Angels surrounding, foundation, stability",
|
||||
555: "Major change coming, transformation",
|
||||
616: "Earliest manuscript number of the Beast (P115)",
|
||||
666: "Number of the Beast (Revelation 13:18), also carbon (6p 6n 6e)",
|
||||
777: "Divine perfection tripled, jackpot of the spirit",
|
||||
888: "Jesus in Greek isopsephy (Ιησους = 888)",
|
||||
1776: "Year of independence, Bavarian Illuminati founding",
|
||||
}
|
||||
|
||||
|
||||
# ── Core Functions ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
"""Strip non-alpha, uppercase."""
|
||||
return "".join(c for c in text.upper() if c.isalpha())
|
||||
|
||||
|
||||
def compute_value(text: str, cipher: str = "simple") -> int:
|
||||
"""Compute the gematria value of text in a given cipher.
|
||||
|
||||
Args:
|
||||
text: Any string (non-alpha characters are ignored).
|
||||
cipher: One of 'simple', 'reduction', 'reverse', 'sumerian', 'hebrew'.
|
||||
|
||||
Returns:
|
||||
Integer gematria value.
|
||||
|
||||
Raises:
|
||||
ValueError: If cipher name is not recognized.
|
||||
"""
|
||||
table = CIPHERS.get(cipher)
|
||||
if table is None:
|
||||
raise ValueError(f"Unknown cipher: {cipher!r}. Use one of {list(CIPHERS)}")
|
||||
return sum(table.get(c, 0) for c in _clean(text))
|
||||
|
||||
|
||||
def compute_all(text: str) -> dict[str, int]:
|
||||
"""Compute gematria value across all cipher systems.
|
||||
|
||||
Args:
|
||||
text: Any string.
|
||||
|
||||
Returns:
|
||||
Dict mapping cipher name to integer value.
|
||||
"""
|
||||
return {name: compute_value(text, name) for name in CIPHERS}
|
||||
|
||||
|
||||
def letter_breakdown(text: str, cipher: str = "simple") -> list[tuple[str, int]]:
|
||||
"""Return per-letter values for a text in a given cipher.
|
||||
|
||||
Args:
|
||||
text: Any string.
|
||||
cipher: Cipher system name.
|
||||
|
||||
Returns:
|
||||
List of (letter, value) tuples for each alpha character.
|
||||
"""
|
||||
table = CIPHERS.get(cipher)
|
||||
if table is None:
|
||||
raise ValueError(f"Unknown cipher: {cipher!r}")
|
||||
return [(c, table.get(c, 0)) for c in _clean(text)]
|
||||
|
||||
|
||||
def reduce_number(n: int) -> int:
|
||||
"""Numerological reduction — sum digits until single digit.
|
||||
|
||||
Master numbers (11, 22, 33) are preserved.
|
||||
|
||||
Args:
|
||||
n: Any positive integer.
|
||||
|
||||
Returns:
|
||||
Single-digit result (or master number 11/22/33).
|
||||
"""
|
||||
n = abs(n)
|
||||
while n > 9 and n not in (11, 22, 33):
|
||||
n = sum(int(d) for d in str(n))
|
||||
return n
|
||||
|
||||
|
||||
def factorize(n: int) -> list[int]:
|
||||
"""Prime factorization of n.
|
||||
|
||||
Args:
|
||||
n: Positive integer.
|
||||
|
||||
Returns:
|
||||
List of prime factors in ascending order (with repetition).
|
||||
"""
|
||||
if n < 2:
|
||||
return [n] if n > 0 else []
|
||||
factors = []
|
||||
d = 2
|
||||
while d * d <= n:
|
||||
while n % d == 0:
|
||||
factors.append(d)
|
||||
n //= d
|
||||
d += 1
|
||||
if n > 1:
|
||||
factors.append(n)
|
||||
return factors
|
||||
|
||||
|
||||
def analyze_number(n: int) -> dict:
|
||||
"""Deep analysis of a number — reduction, factors, significance.
|
||||
|
||||
Args:
|
||||
n: Any positive integer.
|
||||
|
||||
Returns:
|
||||
Dict with reduction, factors, properties, and any notable significance.
|
||||
"""
|
||||
result: dict = {
|
||||
"value": n,
|
||||
"numerological_reduction": reduce_number(n),
|
||||
"prime_factors": factorize(n),
|
||||
"is_prime": len(factorize(n)) == 1 and n > 1,
|
||||
"is_perfect_square": math.isqrt(n) ** 2 == n if n >= 0 else False,
|
||||
"is_triangular": _is_triangular(n),
|
||||
"digit_sum": sum(int(d) for d in str(abs(n))),
|
||||
}
|
||||
|
||||
# Master numbers
|
||||
if n in (11, 22, 33):
|
||||
result["master_number"] = True
|
||||
|
||||
# Angel numbers (repeating digits)
|
||||
s = str(n)
|
||||
if len(s) >= 3 and len(set(s)) == 1:
|
||||
result["angel_number"] = True
|
||||
|
||||
# Notable significance
|
||||
if n in NOTABLE_NUMBERS:
|
||||
result["significance"] = NOTABLE_NUMBERS[n]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _is_triangular(n: int) -> bool:
|
||||
"""Check if n is a triangular number (1, 3, 6, 10, 15, ...)."""
|
||||
if n < 0:
|
||||
return False
|
||||
# n = k(k+1)/2 → k² + k - 2n = 0 → k = (-1 + sqrt(1+8n))/2
|
||||
discriminant = 1 + 8 * n
|
||||
sqrt_d = math.isqrt(discriminant)
|
||||
return sqrt_d * sqrt_d == discriminant and (sqrt_d - 1) % 2 == 0
|
||||
|
||||
|
||||
# ── Tool Function (registered with Timmy) ────────────────────────────────────
|
||||
|
||||
|
||||
def gematria(query: str) -> str:
|
||||
"""Compute gematria values, analyze numbers, and find correspondences.
|
||||
|
||||
This is the wizard's language — letters are numbers, numbers are letters.
|
||||
Use this tool for ANY gematria calculation. Do not attempt mental arithmetic.
|
||||
|
||||
Input modes:
|
||||
- A word or phrase → computes values across all cipher systems
|
||||
- A bare integer → analyzes the number (factors, reduction, significance)
|
||||
- "compare: X, Y, Z" → side-by-side gematria comparison
|
||||
|
||||
Examples:
|
||||
gematria("Alexander Whitestone")
|
||||
gematria("222")
|
||||
gematria("compare: Timmy Time, Alexander Whitestone")
|
||||
|
||||
Args:
|
||||
query: A word/phrase, a number, or a "compare:" instruction.
|
||||
|
||||
Returns:
|
||||
Formatted gematria analysis as a string.
|
||||
"""
|
||||
query = query.strip()
|
||||
|
||||
# Mode: compare
|
||||
if query.lower().startswith("compare:"):
|
||||
phrases = [p.strip() for p in query[8:].split(",") if p.strip()]
|
||||
if len(phrases) < 2:
|
||||
return "Compare requires at least two phrases separated by commas."
|
||||
return _format_comparison(phrases)
|
||||
|
||||
# Mode: number analysis
|
||||
if query.lstrip("-").isdigit():
|
||||
n = int(query)
|
||||
return _format_number_analysis(n)
|
||||
|
||||
# Mode: phrase gematria
|
||||
if not _clean(query):
|
||||
return "No alphabetic characters found in input."
|
||||
|
||||
return _format_phrase_analysis(query)
|
||||
|
||||
|
||||
def _format_phrase_analysis(text: str) -> str:
|
||||
"""Format full gematria analysis for a phrase."""
|
||||
values = compute_all(text)
|
||||
lines = [f'Gematria of "{text}":', ""]
|
||||
|
||||
# All cipher values
|
||||
for cipher, val in values.items():
|
||||
label = cipher.replace("_", " ").title()
|
||||
lines.append(f" {label:12s} = {val}")
|
||||
|
||||
# Letter breakdown (simple)
|
||||
breakdown = letter_breakdown(text, "simple")
|
||||
letters_str = " + ".join(f"{c}({v})" for c, v in breakdown)
|
||||
lines.append(f"\n Breakdown (Simple): {letters_str}")
|
||||
|
||||
# Numerological reduction of the simple value
|
||||
simple_val = values["simple"]
|
||||
reduced = reduce_number(simple_val)
|
||||
lines.append(f" Numerological root: {simple_val} → {reduced}")
|
||||
|
||||
# Check notable
|
||||
for cipher, val in values.items():
|
||||
if val in NOTABLE_NUMBERS:
|
||||
label = cipher.replace("_", " ").title()
|
||||
lines.append(f"\n ★ {val} ({label}): {NOTABLE_NUMBERS[val]}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_number_analysis(n: int) -> str:
|
||||
"""Format deep number analysis."""
|
||||
info = analyze_number(n)
|
||||
lines = [f"Analysis of {n}:", ""]
|
||||
lines.append(f" Numerological reduction: {n} → {info['numerological_reduction']}")
|
||||
lines.append(f" Prime factors: {' × '.join(str(f) for f in info['prime_factors']) or 'N/A'}")
|
||||
lines.append(f" Is prime: {info['is_prime']}")
|
||||
lines.append(f" Is perfect square: {info['is_perfect_square']}")
|
||||
lines.append(f" Is triangular: {info['is_triangular']}")
|
||||
lines.append(f" Digit sum: {info['digit_sum']}")
|
||||
|
||||
if info.get("master_number"):
|
||||
lines.append(" ★ Master Number")
|
||||
if info.get("angel_number"):
|
||||
lines.append(" ★ Angel Number (repeating digits)")
|
||||
if info.get("significance"):
|
||||
lines.append(f"\n Significance: {info['significance']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_comparison(phrases: list[str]) -> str:
|
||||
"""Format side-by-side gematria comparison."""
|
||||
lines = ["Gematria Comparison:", ""]
|
||||
|
||||
# Header
|
||||
max_name = max(len(p) for p in phrases)
|
||||
header = f" {'Phrase':<{max_name}s} Simple Reduct Reverse Sumerian Hebrew"
|
||||
lines.append(header)
|
||||
lines.append(" " + "─" * (len(header) - 2))
|
||||
|
||||
all_values = {}
|
||||
for phrase in phrases:
|
||||
vals = compute_all(phrase)
|
||||
all_values[phrase] = vals
|
||||
lines.append(
|
||||
f" {phrase:<{max_name}s} {vals['simple']:>6d} {vals['reduction']:>6d}"
|
||||
f" {vals['reverse']:>7d} {vals['sumerian']:>8d} {vals['hebrew']:>6d}"
|
||||
)
|
||||
|
||||
# Find matches (shared values across any cipher)
|
||||
matches = []
|
||||
for cipher in CIPHERS:
|
||||
vals_by_cipher = {p: all_values[p][cipher] for p in phrases}
|
||||
unique_vals = set(vals_by_cipher.values())
|
||||
if len(unique_vals) < len(phrases):
|
||||
# At least two phrases share a value
|
||||
for v in unique_vals:
|
||||
sharing = [p for p, pv in vals_by_cipher.items() if pv == v]
|
||||
if len(sharing) > 1:
|
||||
label = cipher.title()
|
||||
matches.append(f" ★ {label} = {v}: " + ", ".join(sharing))
|
||||
|
||||
if matches:
|
||||
lines.append("\nCorrespondences found:")
|
||||
lines.extend(matches)
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -86,7 +86,7 @@ def run_interview(
|
||||
|
||||
try:
|
||||
answer = chat_fn(question)
|
||||
except Exception as exc:
|
||||
except Exception as exc: # broad catch intentional: chat_fn can raise any error
|
||||
logger.error("Interview question failed: %s", exc)
|
||||
answer = f"(Error: {exc})"
|
||||
|
||||
|
||||
@@ -262,7 +262,8 @@ def capture_error(exc, **kwargs):
|
||||
from infrastructure.error_capture import capture_error as _capture
|
||||
|
||||
return _capture(exc, **kwargs)
|
||||
except Exception:
|
||||
except Exception as capture_exc:
|
||||
logger.debug("Failed to capture error: %s", capture_exc)
|
||||
logger.debug("Failed to capture error", exc_info=True)
|
||||
|
||||
|
||||
|
||||
@@ -25,9 +25,12 @@ import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import uuid
|
||||
from contextlib import closing
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -40,7 +43,7 @@ def _parse_command(command_str: str) -> tuple[str, list[str]]:
|
||||
"""Split a command string into (executable, args).
|
||||
|
||||
Handles ``~/`` expansion and resolves via PATH if needed.
|
||||
E.g. ``"gitea-mcp -t stdio"`` → ``("/Users/x/go/bin/gitea-mcp", ["-t", "stdio"])``
|
||||
E.g. ``"gitea-mcp-server -t stdio"`` → ``("/opt/homebrew/bin/gitea-mcp-server", ["-t", "stdio"])``
|
||||
"""
|
||||
parts = command_str.split()
|
||||
executable = os.path.expanduser(parts[0])
|
||||
@@ -163,37 +166,36 @@ def _bridge_to_work_order(title: str, body: str, category: str) -> None:
|
||||
try:
|
||||
db_path = Path(settings.repo_root) / "data" / "work_orders.db"
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS work_orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
category TEXT DEFAULT 'suggestion',
|
||||
submitter TEXT DEFAULT 'dashboard',
|
||||
related_files TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'submitted',
|
||||
result TEXT DEFAULT '',
|
||||
rejection_reason TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO work_orders (id, title, description, category, submitter, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
title,
|
||||
body,
|
||||
category,
|
||||
"timmy-thinking",
|
||||
datetime.utcnow().isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS work_orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
category TEXT DEFAULT 'suggestion',
|
||||
submitter TEXT DEFAULT 'dashboard',
|
||||
related_files TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'submitted',
|
||||
result TEXT DEFAULT '',
|
||||
rejection_reason TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO work_orders (id, title, description, category, submitter, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
title,
|
||||
body,
|
||||
category,
|
||||
"timmy-thinking",
|
||||
datetime.utcnow().isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
logger.debug("Work order bridge failed: %s", exc)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
88
src/timmy/memory/embeddings.py
Normal file
88
src/timmy/memory/embeddings.py
Normal 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)
|
||||
@@ -1,85 +1,201 @@
|
||||
"""Unified memory database — single SQLite DB for all memory types.
|
||||
"""Unified memory schema and connection management.
|
||||
|
||||
Consolidates three previously separate stores into one:
|
||||
- **facts**: Long-term knowledge (user preferences, learned patterns)
|
||||
- **chunks**: Indexed vault documents (markdown files from memory/)
|
||||
- **episodes**: Runtime memories (conversations, agent observations)
|
||||
|
||||
All three tables live in ``data/memory.db``. Existing APIs in
|
||||
``vector_store.py`` and ``semantic_memory.py`` are updated to point here.
|
||||
This module provides the central database schema for Timmy's consolidated
|
||||
memory system. All memory types (facts, conversations, documents, vault chunks)
|
||||
are stored in a single `memories` table with a `memory_type` discriminator.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from contextlib import closing, contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent.parent.parent / "data" / "memory.db"
|
||||
# Paths
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
|
||||
DB_PATH = PROJECT_ROOT / "data" / "memory.db"
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
"""Open (and lazily create) the unified memory database."""
|
||||
@contextmanager
|
||||
def get_connection() -> Generator[sqlite3.Connection, None, None]:
|
||||
"""Get database connection to unified memory database."""
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
_ensure_schema(conn)
|
||||
return conn
|
||||
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
_ensure_schema(conn)
|
||||
yield conn
|
||||
|
||||
|
||||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
"""Create the three core tables and indexes if they don't exist."""
|
||||
|
||||
# --- facts ---------------------------------------------------------------
|
||||
"""Create the unified memories table and indexes if they don't exist."""
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS facts (
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
content TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0.8,
|
||||
memory_type TEXT NOT NULL DEFAULT 'fact',
|
||||
source TEXT NOT NULL DEFAULT 'agent',
|
||||
embedding TEXT,
|
||||
metadata TEXT,
|
||||
source_hash TEXT,
|
||||
agent_id TEXT,
|
||||
task_id TEXT,
|
||||
session_id TEXT,
|
||||
confidence REAL NOT NULL DEFAULT 0.8,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
last_accessed TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence)")
|
||||
|
||||
# --- chunks (vault document fragments) -----------------------------------
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
source_hash TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source)")
|
||||
# Create indexes for efficient querying
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_time ON memories(created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories(agent_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source)")
|
||||
conn.commit()
|
||||
|
||||
# --- episodes (runtime memory entries) -----------------------------------
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
context_type TEXT NOT NULL DEFAULT 'conversation',
|
||||
embedding TEXT,
|
||||
metadata TEXT,
|
||||
agent_id TEXT,
|
||||
task_id TEXT,
|
||||
session_id TEXT,
|
||||
timestamp TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_episodes_type ON episodes(context_type)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_episodes_time ON episodes(timestamp)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_episodes_session ON episodes(session_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_episodes_agent ON episodes(agent_id)")
|
||||
# Run migration if needed
|
||||
_migrate_schema(conn)
|
||||
|
||||
|
||||
def _migrate_schema(conn: sqlite3.Connection) -> None:
|
||||
"""Migrate from old three-table schema to unified memories table.
|
||||
|
||||
Migration paths:
|
||||
- episodes table -> memories (context_type -> memory_type)
|
||||
- chunks table -> memories with memory_type='vault_chunk'
|
||||
- facts table -> dropped (unused, 0 rows expected)
|
||||
"""
|
||||
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in cursor.fetchall()}
|
||||
|
||||
has_memories = "memories" in tables
|
||||
has_episodes = "episodes" in tables
|
||||
has_chunks = "chunks" in tables
|
||||
has_facts = "facts" in tables
|
||||
|
||||
# Check if we need to migrate (old schema exists but new one doesn't fully)
|
||||
if not has_memories:
|
||||
logger.info("Migration: Creating unified memories table")
|
||||
# Schema will be created above
|
||||
|
||||
# Migrate episodes -> memories
|
||||
if has_episodes and has_memories:
|
||||
logger.info("Migration: Converting episodes table to memories")
|
||||
try:
|
||||
cols = _get_table_columns(conn, "episodes")
|
||||
context_type_col = "context_type" if "context_type" in cols else "'conversation'"
|
||||
|
||||
conn.execute(f"""
|
||||
INSERT INTO memories (
|
||||
id, content, memory_type, source, embedding,
|
||||
metadata, agent_id, task_id, session_id,
|
||||
created_at, access_count, last_accessed
|
||||
)
|
||||
SELECT
|
||||
id, content,
|
||||
COALESCE({context_type_col}, 'conversation'),
|
||||
COALESCE(source, 'agent'),
|
||||
embedding,
|
||||
metadata, agent_id, task_id, session_id,
|
||||
COALESCE(timestamp, datetime('now')), 0, NULL
|
||||
FROM episodes
|
||||
""")
|
||||
conn.execute("DROP TABLE episodes")
|
||||
logger.info("Migration: Migrated episodes to memories")
|
||||
except sqlite3.Error as exc:
|
||||
logger.warning("Migration: Failed to migrate episodes: %s", exc)
|
||||
|
||||
# Migrate chunks -> memories as vault_chunk
|
||||
if has_chunks and has_memories:
|
||||
logger.info("Migration: Converting chunks table to memories")
|
||||
try:
|
||||
cols = _get_table_columns(conn, "chunks")
|
||||
|
||||
id_col = "id" if "id" in cols else "CAST(rowid AS TEXT)"
|
||||
content_col = "content" if "content" in cols else "text"
|
||||
source_col = (
|
||||
"filepath" if "filepath" in cols else ("source" if "source" in cols else "'vault'")
|
||||
)
|
||||
embedding_col = "embedding" if "embedding" in cols else "NULL"
|
||||
created_col = "created_at" if "created_at" in cols else "datetime('now')"
|
||||
|
||||
conn.execute(f"""
|
||||
INSERT INTO memories (
|
||||
id, content, memory_type, source, embedding,
|
||||
created_at, access_count
|
||||
)
|
||||
SELECT
|
||||
{id_col}, {content_col}, 'vault_chunk', {source_col},
|
||||
{embedding_col}, {created_col}, 0
|
||||
FROM chunks
|
||||
""")
|
||||
conn.execute("DROP TABLE chunks")
|
||||
logger.info("Migration: Migrated chunks to memories")
|
||||
except sqlite3.Error as exc:
|
||||
logger.warning("Migration: Failed to migrate chunks: %s", exc)
|
||||
|
||||
# Drop old facts table
|
||||
if has_facts:
|
||||
try:
|
||||
conn.execute("DROP TABLE facts")
|
||||
logger.info("Migration: Dropped old facts table")
|
||||
except sqlite3.Error as exc:
|
||||
logger.warning("Migration: Failed to drop facts: %s", exc)
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _get_table_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
|
||||
"""Get the column names for a table."""
|
||||
cursor = conn.execute(f"PRAGMA table_info({table_name})")
|
||||
return {row[1] for row in cursor.fetchall()}
|
||||
|
||||
|
||||
# Backward compatibility aliases
|
||||
get_conn = get_connection
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryEntry:
|
||||
"""A memory entry with vector embedding.
|
||||
|
||||
Note: The DB column is `memory_type` but this field is named `context_type`
|
||||
for backward API compatibility.
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
content: str = "" # The actual text content
|
||||
source: str = "" # Where it came from (agent, user, system)
|
||||
context_type: str = "conversation" # API field name; DB column is memory_type
|
||||
agent_id: str | None = None
|
||||
task_id: str | None = None
|
||||
session_id: str | None = None
|
||||
metadata: dict | None = None
|
||||
embedding: list[float] | None = None
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
relevance_score: float | None = None # Set during search
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryChunk:
|
||||
"""A searchable chunk of memory."""
|
||||
|
||||
id: str
|
||||
source: str # filepath
|
||||
content: str
|
||||
embedding: list[float]
|
||||
created_at: str
|
||||
|
||||
|
||||
# Note: Functions are available via memory_system module directly
|
||||
# from timmy.memory_system import store_memory, search_memories, etc.
|
||||
|
||||
@@ -1,430 +1,37 @@
|
||||
"""Vector store for semantic memory using sqlite-vss.
|
||||
|
||||
Provides embedding-based similarity search for the Echo agent
|
||||
to retrieve relevant context from conversation history.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def _check_embedding_model() -> bool | None:
|
||||
"""Check if the canonical embedding model is available."""
|
||||
try:
|
||||
from timmy.semantic_memory import _get_embedding_model
|
||||
|
||||
model = _get_embedding_model()
|
||||
return model is not None and model is not False
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _compute_embedding(text: str) -> list[float]:
|
||||
"""Compute embedding vector for text.
|
||||
|
||||
Delegates to the canonical embedding provider in semantic_memory
|
||||
to avoid loading the model multiple times.
|
||||
"""
|
||||
from timmy.semantic_memory import embed_text
|
||||
|
||||
return embed_text(text)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryEntry:
|
||||
"""A memory entry with vector embedding."""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
content: str = "" # The actual text content
|
||||
source: str = "" # Where it came from (agent, user, system)
|
||||
context_type: str = "conversation" # conversation, document, fact, etc.
|
||||
agent_id: str | None = None
|
||||
task_id: str | None = None
|
||||
session_id: str | None = None
|
||||
metadata: dict | None = None
|
||||
embedding: list[float] | None = None
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
relevance_score: float | None = None # Set during search
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
"""Get database connection to unified memory.db."""
|
||||
from timmy.memory.unified import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def store_memory(
|
||||
content: str,
|
||||
source: str,
|
||||
context_type: str = "conversation",
|
||||
agent_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
compute_embedding: bool = True,
|
||||
) -> MemoryEntry:
|
||||
"""Store a memory entry with optional embedding.
|
||||
|
||||
Args:
|
||||
content: The text content to store
|
||||
source: Source of the memory (agent name, user, system)
|
||||
context_type: Type of context (conversation, document, fact)
|
||||
agent_id: Associated agent ID
|
||||
task_id: Associated task ID
|
||||
session_id: Session identifier
|
||||
metadata: Additional structured data
|
||||
compute_embedding: Whether to compute vector embedding
|
||||
|
||||
Returns:
|
||||
The stored MemoryEntry
|
||||
"""
|
||||
embedding = None
|
||||
if compute_embedding:
|
||||
embedding = _compute_embedding(content)
|
||||
|
||||
entry = MemoryEntry(
|
||||
content=content,
|
||||
source=source,
|
||||
context_type=context_type,
|
||||
agent_id=agent_id,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
metadata=metadata,
|
||||
embedding=embedding,
|
||||
)
|
||||
|
||||
conn = _get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO episodes
|
||||
(id, content, source, context_type, agent_id, task_id, session_id,
|
||||
metadata, embedding, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
entry.id,
|
||||
entry.content,
|
||||
entry.source,
|
||||
entry.context_type,
|
||||
entry.agent_id,
|
||||
entry.task_id,
|
||||
entry.session_id,
|
||||
json.dumps(metadata) if metadata else None,
|
||||
json.dumps(embedding) if embedding else None,
|
||||
entry.timestamp,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def search_memories(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
context_type: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
min_relevance: float = 0.0,
|
||||
) -> list[MemoryEntry]:
|
||||
"""Search for memories by semantic similarity.
|
||||
|
||||
Args:
|
||||
query: Search query text
|
||||
limit: Maximum results
|
||||
context_type: Filter by context type
|
||||
agent_id: Filter by agent
|
||||
session_id: Filter by session
|
||||
min_relevance: Minimum similarity score (0-1)
|
||||
|
||||
Returns:
|
||||
List of MemoryEntry objects sorted by relevance
|
||||
"""
|
||||
query_embedding = _compute_embedding(query)
|
||||
|
||||
conn = _get_conn()
|
||||
|
||||
# Build query with filters
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if context_type:
|
||||
conditions.append("context_type = ?")
|
||||
params.append(context_type)
|
||||
if agent_id:
|
||||
conditions.append("agent_id = ?")
|
||||
params.append(agent_id)
|
||||
if session_id:
|
||||
conditions.append("session_id = ?")
|
||||
params.append(session_id)
|
||||
|
||||
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
|
||||
|
||||
# Fetch candidates (we'll do in-memory similarity for now)
|
||||
# For production with sqlite-vss, this would use vector similarity index
|
||||
query_sql = f"""
|
||||
SELECT * FROM episodes
|
||||
{where_clause}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
params.append(limit * 3) # Get more candidates for ranking
|
||||
|
||||
rows = conn.execute(query_sql, params).fetchall()
|
||||
conn.close()
|
||||
|
||||
# Compute similarity scores
|
||||
results = []
|
||||
for row in rows:
|
||||
entry = MemoryEntry(
|
||||
id=row["id"],
|
||||
content=row["content"],
|
||||
source=row["source"],
|
||||
context_type=row["context_type"],
|
||||
agent_id=row["agent_id"],
|
||||
task_id=row["task_id"],
|
||||
session_id=row["session_id"],
|
||||
metadata=json.loads(row["metadata"]) if row["metadata"] else None,
|
||||
embedding=json.loads(row["embedding"]) if row["embedding"] else None,
|
||||
timestamp=row["timestamp"],
|
||||
)
|
||||
|
||||
if entry.embedding:
|
||||
# Cosine similarity
|
||||
score = _cosine_similarity(query_embedding, entry.embedding)
|
||||
entry.relevance_score = score
|
||||
if score >= min_relevance:
|
||||
results.append(entry)
|
||||
else:
|
||||
# Fallback: check for keyword overlap
|
||||
score = _keyword_overlap(query, entry.content)
|
||||
entry.relevance_score = score
|
||||
if score >= min_relevance:
|
||||
results.append(entry)
|
||||
|
||||
# Sort by relevance and return top results
|
||||
results.sort(key=lambda x: x.relevance_score or 0, reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
|
||||
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
"""Compute cosine similarity between two vectors."""
|
||||
dot = sum(x * y for x, y in zip(a, b, strict=False))
|
||||
norm_a = sum(x * x for x in a) ** 0.5
|
||||
norm_b = sum(x * x for x in b) ** 0.5
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return 0.0
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def get_memory_context(query: str, max_tokens: int = 2000, **filters) -> str:
|
||||
"""Get relevant memory context as formatted text for LLM prompts.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
max_tokens: Approximate maximum tokens to return
|
||||
**filters: Additional filters (agent_id, session_id, etc.)
|
||||
|
||||
Returns:
|
||||
Formatted context string for inclusion in prompts
|
||||
"""
|
||||
memories = search_memories(query, limit=20, **filters)
|
||||
|
||||
context_parts = []
|
||||
total_chars = 0
|
||||
max_chars = max_tokens * 4 # Rough approximation
|
||||
|
||||
for mem in memories:
|
||||
formatted = f"[{mem.source}]: {mem.content}"
|
||||
if total_chars + len(formatted) > max_chars:
|
||||
break
|
||||
context_parts.append(formatted)
|
||||
total_chars += len(formatted)
|
||||
|
||||
if not context_parts:
|
||||
return ""
|
||||
|
||||
return "Relevant context from memory:\n" + "\n\n".join(context_parts)
|
||||
|
||||
|
||||
def recall_personal_facts(agent_id: str | None = None) -> list[str]:
|
||||
"""Recall personal facts about the user or system.
|
||||
|
||||
Args:
|
||||
agent_id: Optional agent filter
|
||||
|
||||
Returns:
|
||||
List of fact strings
|
||||
"""
|
||||
conn = _get_conn()
|
||||
|
||||
if agent_id:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT content FROM episodes
|
||||
WHERE context_type = 'fact' AND agent_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 100
|
||||
""",
|
||||
(agent_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT content FROM episodes
|
||||
WHERE context_type = 'fact'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 100
|
||||
""",
|
||||
).fetchall()
|
||||
|
||||
conn.close()
|
||||
return [r["content"] for r in rows]
|
||||
|
||||
|
||||
def recall_personal_facts_with_ids(agent_id: str | None = None) -> list[dict]:
|
||||
"""Recall personal facts with their IDs for edit/delete operations."""
|
||||
conn = _get_conn()
|
||||
if agent_id:
|
||||
rows = conn.execute(
|
||||
"SELECT id, content FROM episodes WHERE context_type = 'fact' AND agent_id = ? ORDER BY timestamp DESC LIMIT 100",
|
||||
(agent_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, content FROM episodes WHERE context_type = 'fact' ORDER BY timestamp DESC LIMIT 100",
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [{"id": r["id"], "content": r["content"]} for r in rows]
|
||||
|
||||
|
||||
def update_personal_fact(memory_id: str, new_content: str) -> bool:
|
||||
"""Update a personal fact's content."""
|
||||
conn = _get_conn()
|
||||
cursor = conn.execute(
|
||||
"UPDATE episodes SET content = ? WHERE id = ? AND context_type = 'fact'",
|
||||
(new_content, memory_id),
|
||||
)
|
||||
conn.commit()
|
||||
updated = cursor.rowcount > 0
|
||||
conn.close()
|
||||
return updated
|
||||
|
||||
|
||||
def store_personal_fact(fact: str, agent_id: str | None = None) -> MemoryEntry:
|
||||
"""Store a personal fact about the user or system.
|
||||
|
||||
Args:
|
||||
fact: The fact to store
|
||||
agent_id: Associated agent
|
||||
|
||||
Returns:
|
||||
The stored MemoryEntry
|
||||
"""
|
||||
return store_memory(
|
||||
content=fact,
|
||||
source="system",
|
||||
context_type="fact",
|
||||
agent_id=agent_id,
|
||||
metadata={"auto_extracted": False},
|
||||
)
|
||||
|
||||
|
||||
def delete_memory(memory_id: str) -> bool:
|
||||
"""Delete a memory entry by ID.
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
conn = _get_conn()
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM episodes WHERE id = ?",
|
||||
(memory_id,),
|
||||
)
|
||||
conn.commit()
|
||||
deleted = cursor.rowcount > 0
|
||||
conn.close()
|
||||
return deleted
|
||||
|
||||
|
||||
def get_memory_stats() -> dict:
|
||||
"""Get statistics about the memory store.
|
||||
|
||||
Returns:
|
||||
Dict with counts by type, total entries, etc.
|
||||
"""
|
||||
conn = _get_conn()
|
||||
|
||||
total = conn.execute("SELECT COUNT(*) as count FROM episodes").fetchone()["count"]
|
||||
|
||||
by_type = {}
|
||||
rows = conn.execute(
|
||||
"SELECT context_type, COUNT(*) as count FROM episodes GROUP BY context_type"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
by_type[row["context_type"]] = row["count"]
|
||||
|
||||
with_embeddings = conn.execute(
|
||||
"SELECT COUNT(*) as count FROM episodes WHERE embedding IS NOT NULL"
|
||||
).fetchone()["count"]
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"total_entries": total,
|
||||
"by_type": by_type,
|
||||
"with_embeddings": with_embeddings,
|
||||
"has_embedding_model": _check_embedding_model(),
|
||||
}
|
||||
|
||||
|
||||
def prune_memories(older_than_days: int = 90, keep_facts: bool = True) -> int:
|
||||
"""Delete old memories to manage storage.
|
||||
|
||||
Args:
|
||||
older_than_days: Delete memories older than this
|
||||
keep_facts: Whether to preserve fact-type memories
|
||||
|
||||
Returns:
|
||||
Number of entries deleted
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=older_than_days)).isoformat()
|
||||
|
||||
conn = _get_conn()
|
||||
|
||||
if keep_facts:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
DELETE FROM episodes
|
||||
WHERE timestamp < ? AND context_type != 'fact'
|
||||
""",
|
||||
(cutoff,),
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM episodes WHERE timestamp < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return deleted
|
||||
"""Backward compatibility — all memory functions live in memory_system now."""
|
||||
|
||||
from timmy.memory_system import (
|
||||
DB_PATH,
|
||||
MemoryEntry,
|
||||
_cosine_similarity,
|
||||
_keyword_overlap,
|
||||
delete_memory,
|
||||
get_memory_context,
|
||||
get_memory_stats,
|
||||
get_memory_system,
|
||||
prune_memories,
|
||||
recall_personal_facts,
|
||||
recall_personal_facts_with_ids,
|
||||
search_memories,
|
||||
store_memory,
|
||||
store_personal_fact,
|
||||
update_personal_fact,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DB_PATH",
|
||||
"MemoryEntry",
|
||||
"delete_memory",
|
||||
"get_memory_context",
|
||||
"get_memory_stats",
|
||||
"get_memory_system",
|
||||
"prune_memories",
|
||||
"recall_personal_facts",
|
||||
"recall_personal_facts_with_ids",
|
||||
"search_memories",
|
||||
"store_memory",
|
||||
"store_personal_fact",
|
||||
"update_personal_fact",
|
||||
"_cosine_similarity",
|
||||
"_keyword_overlap",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user