Compare commits
176 Commits
8a14bbb3e0
...
kimi/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93a7dfa678 | ||
| e11e07f117 | |||
| 50c8a5428e | |||
| 7da434c85b | |||
| 88e59f7c17 | |||
| aa5e9c3176 | |||
| 1b4fe65650 | |||
| 2d69f73d9d | |||
| ff1e43c235 | |||
| b331aa6139 | |||
| b45b543f2d | |||
| 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,15 +18,15 @@ make install # create venv + install deps
|
||||
cp .env.example .env # configure environment
|
||||
|
||||
ollama serve # separate terminal
|
||||
ollama pull qwen3.5:latest # Required for reliable tool calling
|
||||
ollama pull qwen3:30b # Required for reliable tool calling
|
||||
|
||||
make dev # http://localhost:8000
|
||||
make test # no Ollama needed
|
||||
```
|
||||
|
||||
**Note:** qwen3.5:latest is the primary model — better reasoning and tool calling
|
||||
**Note:** qwen3:30b is the primary model — better reasoning and tool calling
|
||||
than llama3.1:8b-instruct while still running locally on modest hardware.
|
||||
Fallback: llama3.1:8b-instruct if qwen3.5:latest is not available.
|
||||
Fallback: llama3.1:8b-instruct if qwen3:30b is not available.
|
||||
llama3.2 (3B) was found to hallucinate tool output consistently in testing.
|
||||
|
||||
---
|
||||
@@ -79,7 +79,7 @@ cp .env.example .env
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `OLLAMA_URL` | `http://localhost:11434` | Ollama host |
|
||||
| `OLLAMA_MODEL` | `qwen3.5:latest` | Primary model for reasoning and tool calling. Fallback: `llama3.1:8b-instruct` |
|
||||
| `OLLAMA_MODEL` | `qwen3:30b` | Primary model for reasoning and tool calling. Fallback: `llama3.1:8b-instruct` |
|
||||
| `DEBUG` | `false` | Enable `/docs` and `/redoc` |
|
||||
| `TIMMY_MODEL_BACKEND` | `ollama` | `ollama` \| `airllm` \| `auto` |
|
||||
| `AIRLLM_MODEL_SIZE` | `70b` | `8b` \| `70b` \| `405b` |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
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()
|
||||
181
scripts/loop_guard.py
Normal file
181
scripts/loop_guard.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Loop guard — idle detection + exponential backoff for the dev loop.
|
||||
|
||||
Checks .loop/queue.json for ready items before spawning hermes.
|
||||
When the queue is empty, applies exponential backoff (60s → 600s max)
|
||||
instead of burning empty cycles every 3 seconds.
|
||||
|
||||
Usage (called by the dev loop before each cycle):
|
||||
python3 scripts/loop_guard.py # exits 0 if ready, 1 if idle
|
||||
python3 scripts/loop_guard.py --wait # same, but sleeps the backoff first
|
||||
python3 scripts/loop_guard.py --status # print current idle state
|
||||
|
||||
Exit codes:
|
||||
0 — queue has work, proceed with cycle
|
||||
1 — queue empty, idle backoff applied (skip cycle)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
QUEUE_FILE = REPO_ROOT / ".loop" / "queue.json"
|
||||
IDLE_STATE_FILE = REPO_ROOT / ".loop" / "idle_state.json"
|
||||
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
|
||||
|
||||
GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1")
|
||||
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
|
||||
|
||||
# Backoff sequence: 60s, 120s, 240s, 600s max
|
||||
BACKOFF_BASE = 60
|
||||
BACKOFF_MAX = 600
|
||||
BACKOFF_MULTIPLIER = 2
|
||||
|
||||
|
||||
def _get_token() -> str:
|
||||
"""Read Gitea token from env or file."""
|
||||
token = os.environ.get("GITEA_TOKEN", "").strip()
|
||||
if not token and TOKEN_FILE.exists():
|
||||
token = TOKEN_FILE.read_text().strip()
|
||||
return token
|
||||
|
||||
|
||||
def _fetch_open_issue_numbers() -> set[int] | None:
|
||||
"""Fetch open issue numbers from Gitea. Returns None on failure."""
|
||||
token = _get_token()
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
numbers: set[int] = set()
|
||||
page = 1
|
||||
while True:
|
||||
url = (
|
||||
f"{GITEA_API}/repos/{REPO_SLUG}/issues"
|
||||
f"?state=open&type=issues&limit=50&page={page}"
|
||||
)
|
||||
req = urllib.request.Request(url, headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
if not data:
|
||||
break
|
||||
for issue in data:
|
||||
numbers.add(issue["number"])
|
||||
if len(data) < 50:
|
||||
break
|
||||
page += 1
|
||||
return numbers
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_queue() -> list[dict]:
|
||||
"""Load queue.json and return ready items, filtering out closed issues."""
|
||||
if not QUEUE_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(QUEUE_FILE.read_text())
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
ready = [item for item in data if item.get("ready")]
|
||||
if not ready:
|
||||
return []
|
||||
|
||||
# Filter out issues that are no longer open (auto-hygiene)
|
||||
open_numbers = _fetch_open_issue_numbers()
|
||||
if open_numbers is not None:
|
||||
before = len(ready)
|
||||
ready = [item for item in ready if item.get("issue") in open_numbers]
|
||||
removed = before - len(ready)
|
||||
if removed > 0:
|
||||
print(f"[loop-guard] Filtered {removed} closed issue(s) from queue")
|
||||
# Persist the cleaned queue so stale entries don't recur
|
||||
_save_cleaned_queue(data, open_numbers)
|
||||
return ready
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def _save_cleaned_queue(full_queue: list[dict], open_numbers: set[int]) -> None:
|
||||
"""Rewrite queue.json without closed issues."""
|
||||
cleaned = [item for item in full_queue if item.get("issue") in open_numbers]
|
||||
try:
|
||||
QUEUE_FILE.write_text(json.dumps(cleaned, indent=2) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load_idle_state() -> dict:
|
||||
"""Load persistent idle state."""
|
||||
if not IDLE_STATE_FILE.exists():
|
||||
return {"consecutive_idle": 0, "last_idle_at": 0}
|
||||
try:
|
||||
return json.loads(IDLE_STATE_FILE.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {"consecutive_idle": 0, "last_idle_at": 0}
|
||||
|
||||
|
||||
def save_idle_state(state: dict) -> None:
|
||||
"""Persist idle state."""
|
||||
IDLE_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
IDLE_STATE_FILE.write_text(json.dumps(state, indent=2) + "\n")
|
||||
|
||||
|
||||
def compute_backoff(consecutive_idle: int) -> int:
|
||||
"""Exponential backoff: 60, 120, 240, 600 (capped)."""
|
||||
return min(BACKOFF_BASE * (BACKOFF_MULTIPLIER ** consecutive_idle), BACKOFF_MAX)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
wait_mode = "--wait" in sys.argv
|
||||
status_mode = "--status" in sys.argv
|
||||
|
||||
state = load_idle_state()
|
||||
|
||||
if status_mode:
|
||||
ready = load_queue()
|
||||
backoff = compute_backoff(state["consecutive_idle"])
|
||||
print(json.dumps({
|
||||
"queue_ready": len(ready),
|
||||
"consecutive_idle": state["consecutive_idle"],
|
||||
"next_backoff_seconds": backoff if not ready else 0,
|
||||
}, indent=2))
|
||||
return 0
|
||||
|
||||
ready = load_queue()
|
||||
|
||||
if ready:
|
||||
# Queue has work — reset idle state, proceed
|
||||
if state["consecutive_idle"] > 0:
|
||||
print(f"[loop-guard] Queue active ({len(ready)} ready) — "
|
||||
f"resuming after {state['consecutive_idle']} idle cycles")
|
||||
state["consecutive_idle"] = 0
|
||||
state["last_idle_at"] = 0
|
||||
save_idle_state(state)
|
||||
return 0
|
||||
|
||||
# Queue empty — apply backoff
|
||||
backoff = compute_backoff(state["consecutive_idle"])
|
||||
state["consecutive_idle"] += 1
|
||||
state["last_idle_at"] = time.time()
|
||||
save_idle_state(state)
|
||||
|
||||
print(f"[loop-guard] Queue empty — idle #{state['consecutive_idle']}, "
|
||||
f"backoff {backoff}s")
|
||||
|
||||
if wait_mode:
|
||||
time.sleep(backoff)
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
407
scripts/loop_introspect.py
Normal file
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,31 @@ class Settings(BaseSettings):
|
||||
# Fallback to server when browser model is unavailable or too slow.
|
||||
browser_model_fallback: bool = True
|
||||
|
||||
# ── Deep Focus Mode ─────────────────────────────────────────────
|
||||
# "deep" = single-problem context; "broad" = default multi-task.
|
||||
focus_mode: Literal["deep", "broad"] = "broad"
|
||||
|
||||
# ── Default Thinking ──────────────────────────────────────────────
|
||||
# When enabled, the agent starts an internal thought loop on server start.
|
||||
thinking_enabled: bool = True
|
||||
thinking_interval_seconds: int = 300 # 5 minutes between thoughts
|
||||
thinking_timeout_seconds: int = 120 # max wall-clock time per thinking cycle
|
||||
thinking_distill_every: int = 10 # distill facts from thoughts every Nth thought
|
||||
thinking_issue_every: int = 20 # file Gitea issues from thoughts every Nth thought
|
||||
thinking_memory_check_every: int = 50 # check memory status every Nth thought
|
||||
thinking_idle_timeout_minutes: int = 60 # pause thoughts after N minutes without user input
|
||||
|
||||
# ── Gitea Integration ─────────────────────────────────────────────
|
||||
# Local Gitea instance for issue tracking and self-improvement.
|
||||
# These values are passed as env vars to the gitea-mcp server process.
|
||||
gitea_url: str = "http://localhost:3000"
|
||||
gitea_token: str = "" # GITEA_TOKEN env var; falls back to ~/.config/gitea/token
|
||||
gitea_token: str = "" # GITEA_TOKEN env var; falls back to .timmy_gitea_token
|
||||
gitea_repo: str = "rockachopa/Timmy-time-dashboard" # owner/repo
|
||||
gitea_enabled: bool = True
|
||||
|
||||
# ── MCP Servers ────────────────────────────────────────────────────
|
||||
# External tool servers connected via Model Context Protocol (stdio).
|
||||
mcp_gitea_command: str = "gitea-mcp -t stdio"
|
||||
mcp_gitea_command: str = "gitea-mcp-server -t stdio"
|
||||
mcp_filesystem_command: str = "npx -y @modelcontextprotocol/server-filesystem"
|
||||
mcp_timeout: int = 15
|
||||
|
||||
@@ -342,14 +365,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 +403,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 +416,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 +480,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):
|
||||
@@ -151,7 +155,17 @@ async def _thinking_scheduler() -> None:
|
||||
while True:
|
||||
try:
|
||||
if settings.thinking_enabled:
|
||||
await thinking_engine.think_once()
|
||||
await asyncio.wait_for(
|
||||
thinking_engine.think_once(),
|
||||
timeout=settings.thinking_timeout_seconds,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Thinking cycle timed out after %ds — Ollama may be unresponsive",
|
||||
settings.thinking_timeout_seconds,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Thinking scheduler error: %s", exc)
|
||||
|
||||
@@ -171,7 +185,10 @@ async def _loop_qa_scheduler() -> None:
|
||||
while True:
|
||||
try:
|
||||
if settings.loop_qa_enabled:
|
||||
result = await loop_qa_orchestrator.run_next_test()
|
||||
result = await asyncio.wait_for(
|
||||
loop_qa_orchestrator.run_next_test(),
|
||||
timeout=settings.thinking_timeout_seconds,
|
||||
)
|
||||
if result:
|
||||
status = "PASS" if result["success"] else "FAIL"
|
||||
logger.info(
|
||||
@@ -180,6 +197,13 @@ async def _loop_qa_scheduler() -> None:
|
||||
status,
|
||||
result.get("details", "")[:80],
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Loop QA test timed out after %ds",
|
||||
settings.thinking_timeout_seconds,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Loop QA scheduler error: %s", exc)
|
||||
|
||||
@@ -187,6 +211,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 +349,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 +395,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 +412,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 +429,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 +444,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 +463,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 +513,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 +573,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 +582,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 +611,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 +634,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,23 +67,91 @@ async def clear_history(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/history.html",
|
||||
{"messages": []},
|
||||
{"messages": [], "welcome_message": WELCOME_MESSAGE},
|
||||
)
|
||||
|
||||
|
||||
def _validate_message(message: str) -> str:
|
||||
"""Strip and validate chat input; raise HTTPException on bad input."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
message = message.strip()
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="Message cannot be empty")
|
||||
if len(message) > MAX_MESSAGE_LENGTH:
|
||||
raise HTTPException(status_code=422, detail="Message too long")
|
||||
return message
|
||||
|
||||
|
||||
def _record_user_activity() -> None:
|
||||
"""Notify the thinking engine that the user is active."""
|
||||
try:
|
||||
from timmy.thinking import thinking_engine
|
||||
|
||||
thinking_engine.record_user_input()
|
||||
except Exception:
|
||||
logger.debug("Failed to record user input for thinking engine")
|
||||
|
||||
|
||||
def _extract_tool_actions(run_output) -> list[dict]:
|
||||
"""If Agno paused the run for tool confirmation, build approval items."""
|
||||
from timmy.approvals import create_item
|
||||
|
||||
tool_actions: list[dict] = []
|
||||
status = getattr(run_output, "status", None)
|
||||
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
|
||||
|
||||
if not (is_paused and getattr(run_output, "active_requirements", None)):
|
||||
return tool_actions
|
||||
|
||||
for req in run_output.active_requirements:
|
||||
if not getattr(req, "needs_confirmation", False):
|
||||
continue
|
||||
te = req.tool_execution
|
||||
tool_name = getattr(te, "tool_name", "unknown")
|
||||
tool_args = getattr(te, "tool_args", {}) or {}
|
||||
|
||||
item = create_item(
|
||||
title=f"Dashboard: {tool_name}",
|
||||
description=format_action_description(tool_name, tool_args),
|
||||
proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
|
||||
impact=get_impact_level(tool_name),
|
||||
)
|
||||
_pending_runs[item.id] = {
|
||||
"run_output": run_output,
|
||||
"requirement": req,
|
||||
"tool_name": tool_name,
|
||||
"tool_args": tool_args,
|
||||
}
|
||||
tool_actions.append(
|
||||
{
|
||||
"approval_id": item.id,
|
||||
"tool_name": tool_name,
|
||||
"description": format_action_description(tool_name, tool_args),
|
||||
"impact": get_impact_level(tool_name),
|
||||
}
|
||||
)
|
||||
return tool_actions
|
||||
|
||||
|
||||
def _log_exchange(
|
||||
message: str, response_text: str | None, error_text: str | None, timestamp: str
|
||||
) -> None:
|
||||
"""Append user message and agent/error reply to the in-memory log."""
|
||||
message_log.append(role="user", content=message, timestamp=timestamp, source="browser")
|
||||
if response_text:
|
||||
message_log.append(
|
||||
role="agent", content=response_text, timestamp=timestamp, source="browser"
|
||||
)
|
||||
elif error_text:
|
||||
message_log.append(role="error", content=error_text, timestamp=timestamp, source="browser")
|
||||
|
||||
|
||||
@router.post("/default/chat", response_class=HTMLResponse)
|
||||
async def chat_agent(request: Request, message: str = Form(...)):
|
||||
"""Chat — synchronous response with native Agno tool confirmation."""
|
||||
message = message.strip()
|
||||
if not message:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=400, detail="Message cannot be empty")
|
||||
|
||||
if len(message) > MAX_MESSAGE_LENGTH:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=422, detail="Message too long")
|
||||
message = _validate_message(message)
|
||||
_record_user_activity()
|
||||
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
response_text = None
|
||||
@@ -95,54 +164,15 @@ async def chat_agent(request: Request, message: str = Form(...)):
|
||||
error_text = f"Chat error: {exc}"
|
||||
run_output = None
|
||||
|
||||
# Check if Agno paused the run for tool confirmation
|
||||
tool_actions = []
|
||||
tool_actions: list[dict] = []
|
||||
if run_output is not None:
|
||||
status = getattr(run_output, "status", None)
|
||||
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
|
||||
|
||||
if is_paused and getattr(run_output, "active_requirements", None):
|
||||
for req in run_output.active_requirements:
|
||||
if getattr(req, "needs_confirmation", False):
|
||||
te = req.tool_execution
|
||||
tool_name = getattr(te, "tool_name", "unknown")
|
||||
tool_args = getattr(te, "tool_args", {}) or {}
|
||||
|
||||
from timmy.approvals import create_item
|
||||
|
||||
item = create_item(
|
||||
title=f"Dashboard: {tool_name}",
|
||||
description=format_action_description(tool_name, tool_args),
|
||||
proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
|
||||
impact=get_impact_level(tool_name),
|
||||
)
|
||||
_pending_runs[item.id] = {
|
||||
"run_output": run_output,
|
||||
"requirement": req,
|
||||
"tool_name": tool_name,
|
||||
"tool_args": tool_args,
|
||||
}
|
||||
tool_actions.append(
|
||||
{
|
||||
"approval_id": item.id,
|
||||
"tool_name": tool_name,
|
||||
"description": format_action_description(tool_name, tool_args),
|
||||
"impact": get_impact_level(tool_name),
|
||||
}
|
||||
)
|
||||
|
||||
tool_actions = _extract_tool_actions(run_output)
|
||||
raw_content = run_output.content if hasattr(run_output, "content") else ""
|
||||
response_text = _clean_response(raw_content or "")
|
||||
if not response_text and not tool_actions:
|
||||
response_text = None # let error template show if needed
|
||||
response_text = None
|
||||
|
||||
message_log.append(role="user", content=message, timestamp=timestamp, source="browser")
|
||||
if response_text:
|
||||
message_log.append(
|
||||
role="agent", content=response_text, timestamp=timestamp, source="browser"
|
||||
)
|
||||
elif error_text:
|
||||
message_log.append(role="error", content=error_text, timestamp=timestamp, source="browser")
|
||||
_log_exchange(message, response_text, error_text, timestamp)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -220,7 +250,8 @@ async def reject_tool(request: Request, approval_id: str):
|
||||
# Resume so the agent knows the tool was rejected
|
||||
try:
|
||||
await continue_chat(pending["run_output"])
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Agent tool rejection error: %s", exc)
|
||||
pass
|
||||
|
||||
reject(approval_id)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -31,6 +31,93 @@ _UPLOAD_DIR = str(Path(settings.repo_root) / "data" / "chat-uploads")
|
||||
_MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
|
||||
# ── POST /api/chat — helpers ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _parse_chat_body(request: Request) -> tuple[dict | None, JSONResponse | None]:
|
||||
"""Parse and validate the JSON request body.
|
||||
|
||||
Returns (body, None) on success or (None, error_response) on failure.
|
||||
"""
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and int(content_length) > settings.chat_api_max_body_bytes:
|
||||
return None, JSONResponse(status_code=413, content={"error": "Request body too large"})
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Chat API JSON parse error: %s", exc)
|
||||
return None, JSONResponse(status_code=400, content={"error": "Invalid JSON"})
|
||||
|
||||
messages = body.get("messages")
|
||||
if not messages or not isinstance(messages, list):
|
||||
return None, JSONResponse(status_code=400, content={"error": "messages array is required"})
|
||||
|
||||
return body, None
|
||||
|
||||
|
||||
def _extract_user_message(messages: list[dict]) -> str | None:
|
||||
"""Return the text of the last user message, or *None* if absent."""
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
text_parts = [
|
||||
p.get("text", "")
|
||||
for p in content
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
]
|
||||
return " ".join(text_parts).strip() or None
|
||||
text = str(content).strip()
|
||||
return text or None
|
||||
return None
|
||||
|
||||
|
||||
def _build_context_prefix() -> str:
|
||||
"""Build the system-context preamble injected before the user message."""
|
||||
now = datetime.now()
|
||||
return (
|
||||
f"[System: Current date/time is "
|
||||
f"{now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
|
||||
f"[System: Mobile client]\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _notify_thinking_engine() -> None:
|
||||
"""Record user activity so the thinking engine knows we're not idle."""
|
||||
try:
|
||||
from timmy.thinking import thinking_engine
|
||||
|
||||
thinking_engine.record_user_input()
|
||||
except Exception:
|
||||
logger.debug("Failed to record user input for thinking engine")
|
||||
|
||||
|
||||
async def _process_chat(user_msg: str) -> dict | JSONResponse:
|
||||
"""Send *user_msg* to the agent, log the exchange, and return a response."""
|
||||
_notify_thinking_engine()
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
try:
|
||||
response_text = await agent_chat(
|
||||
_build_context_prefix() + user_msg,
|
||||
session_id="mobile",
|
||||
)
|
||||
message_log.append(role="user", content=user_msg, timestamp=timestamp, source="api")
|
||||
message_log.append(role="agent", content=response_text, timestamp=timestamp, source="api")
|
||||
return {"reply": response_text, "timestamp": timestamp}
|
||||
|
||||
except Exception as exc:
|
||||
error_msg = f"Agent is offline: {exc}"
|
||||
logger.error("api_chat error: %s", exc)
|
||||
message_log.append(role="user", content=user_msg, timestamp=timestamp, source="api")
|
||||
message_log.append(role="error", content=error_msg, timestamp=timestamp, source="api")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"error": error_msg, "timestamp": timestamp},
|
||||
)
|
||||
|
||||
|
||||
# ── POST /api/chat ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -44,69 +131,15 @@ async def api_chat(request: Request):
|
||||
Response:
|
||||
{"reply": "...", "timestamp": "HH:MM:SS"}
|
||||
"""
|
||||
# Enforce request body size limit
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and int(content_length) > settings.chat_api_max_body_bytes:
|
||||
return JSONResponse(status_code=413, content={"error": "Request body too large"})
|
||||
body, err = await _parse_chat_body(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
|
||||
|
||||
messages = body.get("messages")
|
||||
if not messages or not isinstance(messages, list):
|
||||
return JSONResponse(status_code=400, content={"error": "messages array is required"})
|
||||
|
||||
# Extract the latest user message text
|
||||
last_user_msg = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
# Handle multimodal content arrays — extract text parts
|
||||
if isinstance(content, list):
|
||||
text_parts = [
|
||||
p.get("text", "")
|
||||
for p in content
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
]
|
||||
last_user_msg = " ".join(text_parts).strip()
|
||||
else:
|
||||
last_user_msg = str(content).strip()
|
||||
break
|
||||
|
||||
if not last_user_msg:
|
||||
user_msg = _extract_user_message(body["messages"])
|
||||
if not user_msg:
|
||||
return JSONResponse(status_code=400, content={"error": "No user message found"})
|
||||
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
try:
|
||||
# Inject context (same pattern as the HTMX chat handler in agents.py)
|
||||
now = datetime.now()
|
||||
context_prefix = (
|
||||
f"[System: Current date/time is "
|
||||
f"{now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
|
||||
f"[System: Mobile client]\n\n"
|
||||
)
|
||||
response_text = await agent_chat(
|
||||
context_prefix + last_user_msg,
|
||||
session_id="mobile",
|
||||
)
|
||||
|
||||
message_log.append(role="user", content=last_user_msg, timestamp=timestamp, source="api")
|
||||
message_log.append(role="agent", content=response_text, timestamp=timestamp, source="api")
|
||||
|
||||
return {"reply": response_text, "timestamp": timestamp}
|
||||
|
||||
except Exception as exc:
|
||||
error_msg = f"Agent is offline: {exc}"
|
||||
logger.error("api_chat error: %s", exc)
|
||||
message_log.append(role="user", content=last_user_msg, timestamp=timestamp, source="api")
|
||||
message_log.append(role="error", content=error_msg, timestamp=timestamp, source="api")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"error": error_msg, "timestamp": timestamp},
|
||||
)
|
||||
return await _process_chat(user_msg)
|
||||
|
||||
|
||||
# ── POST /api/upload ──────────────────────────────────────────────────────────
|
||||
|
||||
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,40 +95,19 @@ def _get_git_context() -> dict:
|
||||
).stdout.strip()
|
||||
|
||||
return {"branch": branch, "commit": commit}
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Git info capture error: %s", exc)
|
||||
return {"branch": "unknown", "commit": "unknown"}
|
||||
|
||||
|
||||
def capture_error(
|
||||
exc: Exception,
|
||||
source: str = "unknown",
|
||||
context: dict | None = None,
|
||||
) -> str | None:
|
||||
"""Capture an error and optionally create a bug report.
|
||||
|
||||
Args:
|
||||
exc: The exception to capture
|
||||
source: Module/component where the error occurred
|
||||
context: Optional dict of extra context (request path, etc.)
|
||||
def _extract_traceback_info(exc: Exception) -> tuple[str, str, int]:
|
||||
"""Extract formatted traceback, affected file, and line number.
|
||||
|
||||
Returns:
|
||||
Task ID of the created bug report, or None if deduplicated/disabled
|
||||
Tuple of (traceback_string, affected_file, affected_line).
|
||||
"""
|
||||
from config import settings
|
||||
|
||||
if not settings.error_feedback_enabled:
|
||||
return None
|
||||
|
||||
error_hash = _stack_hash(exc)
|
||||
|
||||
if _is_duplicate(error_hash):
|
||||
logger.debug("Duplicate error suppressed: %s (hash=%s)", exc, error_hash)
|
||||
return None
|
||||
|
||||
# Format the stack trace
|
||||
tb_str = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
|
||||
# Extract file/line from traceback
|
||||
tb_obj = exc.__traceback__
|
||||
affected_file = "unknown"
|
||||
affected_line = 0
|
||||
@@ -130,9 +117,18 @@ def capture_error(
|
||||
affected_file = tb_obj.tb_frame.f_code.co_filename
|
||||
affected_line = tb_obj.tb_lineno
|
||||
|
||||
git_ctx = _get_git_context()
|
||||
return tb_str, affected_file, affected_line
|
||||
|
||||
# 1. Log to event_log
|
||||
|
||||
def _log_error_event(
|
||||
exc: Exception,
|
||||
source: str,
|
||||
error_hash: str,
|
||||
affected_file: str,
|
||||
affected_line: int,
|
||||
git_ctx: dict,
|
||||
) -> None:
|
||||
"""Log the captured error to the event log."""
|
||||
try:
|
||||
from swarm.event_log import EventType, log_event
|
||||
|
||||
@@ -152,8 +148,18 @@ def capture_error(
|
||||
except Exception as log_exc:
|
||||
logger.debug("Failed to log error event: %s", log_exc)
|
||||
|
||||
# 2. Create bug report task
|
||||
task_id = None
|
||||
|
||||
def _create_bug_report(
|
||||
exc: Exception,
|
||||
source: str,
|
||||
context: dict | None,
|
||||
error_hash: str,
|
||||
tb_str: str,
|
||||
affected_file: str,
|
||||
affected_line: int,
|
||||
git_ctx: dict,
|
||||
) -> str | None:
|
||||
"""Create a bug report task and return the task ID (or None on failure)."""
|
||||
try:
|
||||
from swarm.task_queue.models import create_task
|
||||
|
||||
@@ -186,7 +192,6 @@ def capture_error(
|
||||
)
|
||||
task_id = task.id
|
||||
|
||||
# Log the creation event
|
||||
try:
|
||||
from swarm.event_log import EventType, log_event
|
||||
|
||||
@@ -199,13 +204,18 @@ def capture_error(
|
||||
"title": title[:100],
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("Bug report screenshot error: %s", exc)
|
||||
|
||||
return task_id
|
||||
|
||||
except Exception as task_exc:
|
||||
logger.debug("Failed to create bug report task: %s", task_exc)
|
||||
return None
|
||||
|
||||
# 3. Send notification
|
||||
|
||||
def _notify_bug_report(exc: Exception, source: str) -> None:
|
||||
"""Send a push notification about the captured error."""
|
||||
try:
|
||||
from infrastructure.notifications.push import notifier
|
||||
|
||||
@@ -214,19 +224,65 @@ def capture_error(
|
||||
message=f"{type(exc).__name__} in {source}: {str(exc)[:80]}",
|
||||
category="system",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as notify_exc:
|
||||
logger.warning("Bug report notification error: %s", notify_exc)
|
||||
|
||||
# 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
|
||||
def _record_to_session(exc: Exception, source: str) -> None:
|
||||
"""Record the error via the registered session callback."""
|
||||
if _error_recorder is not None:
|
||||
try:
|
||||
_error_recorder(
|
||||
error=f"{type(exc).__name__}: {str(exc)}",
|
||||
context=source,
|
||||
)
|
||||
except Exception as log_exc:
|
||||
logger.warning("Bug report session logging error: %s", log_exc)
|
||||
|
||||
|
||||
def capture_error(
|
||||
exc: Exception,
|
||||
source: str = "unknown",
|
||||
context: dict | None = None,
|
||||
) -> str | None:
|
||||
"""Capture an error and optionally create a bug report.
|
||||
|
||||
Args:
|
||||
exc: The exception to capture
|
||||
source: Module/component where the error occurred
|
||||
context: Optional dict of extra context (request path, etc.)
|
||||
|
||||
Returns:
|
||||
Task ID of the created bug report, or None if deduplicated/disabled
|
||||
"""
|
||||
from config import settings
|
||||
|
||||
if not settings.error_feedback_enabled:
|
||||
return None
|
||||
|
||||
error_hash = _stack_hash(exc)
|
||||
|
||||
if _is_duplicate(error_hash):
|
||||
logger.debug("Duplicate error suppressed: %s (hash=%s)", exc, error_hash)
|
||||
return None
|
||||
|
||||
tb_str, affected_file, affected_line = _extract_traceback_info(exc)
|
||||
git_ctx = _get_git_context()
|
||||
|
||||
_log_error_event(exc, source, error_hash, affected_file, affected_line, git_ctx)
|
||||
|
||||
task_id = _create_bug_report(
|
||||
exc,
|
||||
source,
|
||||
context,
|
||||
error_hash,
|
||||
tb_str,
|
||||
affected_file,
|
||||
affected_line,
|
||||
git_ctx,
|
||||
)
|
||||
|
||||
_notify_bug_report(exc, source)
|
||||
_record_to_session(exc, source)
|
||||
|
||||
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/Grok/Claude backends, 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
|
||||
@@ -26,12 +26,12 @@ from timmy.prompts import get_system_prompt
|
||||
from timmy.tools import create_full_toolkit
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from timmy.backends import ClaudeBackend, GrokBackend, TimmyAirLLMAgent
|
||||
from timmy.backends import ClaudeBackend, GrokBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Union type for callers that want to hint the return type.
|
||||
TimmyAgent = Union[Agent, "TimmyAirLLMAgent", "GrokBackend", "ClaudeBackend"]
|
||||
TimmyAgent = Union[Agent, "GrokBackend", "ClaudeBackend"]
|
||||
|
||||
# Models known to be too small for reliable tool calling.
|
||||
# These hallucinate tool calls as text, invoke tools randomly,
|
||||
@@ -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",
|
||||
@@ -172,106 +172,34 @@ def _warmup_model(model_name: str) -> bool:
|
||||
|
||||
|
||||
def _resolve_backend(requested: str | None) -> str:
|
||||
"""Return the backend name to use, resolving 'auto' and explicit overrides.
|
||||
"""Return the backend name to use.
|
||||
|
||||
Priority (highest → lowest):
|
||||
Priority (highest -> lowest):
|
||||
1. CLI flag passed directly to create_timmy()
|
||||
2. TIMMY_MODEL_BACKEND env var / .env setting
|
||||
3. 'ollama' (safe default — no surprises)
|
||||
|
||||
'auto' triggers Apple Silicon detection: uses AirLLM if both
|
||||
is_apple_silicon() and airllm_available() return True.
|
||||
3. 'ollama' (safe default -- no surprises)
|
||||
"""
|
||||
if requested is not None:
|
||||
return requested
|
||||
|
||||
configured = settings.timmy_model_backend # "ollama" | "airllm" | "grok" | "claude" | "auto"
|
||||
if configured != "auto":
|
||||
return configured
|
||||
|
||||
# "auto" path — lazy import to keep startup fast and tests clean.
|
||||
from timmy.backends import airllm_available, is_apple_silicon
|
||||
|
||||
if is_apple_silicon() and airllm_available():
|
||||
return "airllm"
|
||||
return "ollama"
|
||||
return settings.timmy_model_backend # "ollama" | "grok" | "claude"
|
||||
|
||||
|
||||
def create_timmy(
|
||||
db_file: str = "timmy.db",
|
||||
backend: str | None = None,
|
||||
model_size: str | None = None,
|
||||
*,
|
||||
skip_mcp: bool = False,
|
||||
) -> TimmyAgent:
|
||||
"""Instantiate the agent — Ollama or AirLLM, same public interface.
|
||||
def _build_tools_list(use_tools: bool, skip_mcp: bool, model_name: str) -> list:
|
||||
"""Assemble the tools list based on model capability and MCP flags.
|
||||
|
||||
Args:
|
||||
db_file: SQLite file for Agno conversation memory (Ollama path only).
|
||||
backend: "ollama" | "airllm" | "auto" | None (reads config/env).
|
||||
model_size: AirLLM size — "8b" | "70b" | "405b" | None (reads config).
|
||||
skip_mcp: If True, omit MCP tool servers (Gitea, filesystem).
|
||||
Use for background tasks (thinking, QA) where MCP's
|
||||
stdio cancel-scope lifecycle conflicts with asyncio
|
||||
task cancellation.
|
||||
|
||||
Returns an Agno Agent or backend-specific agent — all expose
|
||||
print_response(message, stream).
|
||||
Returns a list of Toolkit / MCPTools objects, or an empty list.
|
||||
"""
|
||||
resolved = _resolve_backend(backend)
|
||||
size = model_size or settings.airllm_model_size
|
||||
|
||||
if resolved == "claude":
|
||||
from timmy.backends import ClaudeBackend
|
||||
|
||||
return ClaudeBackend()
|
||||
|
||||
if resolved == "grok":
|
||||
from timmy.backends import GrokBackend
|
||||
|
||||
return GrokBackend()
|
||||
|
||||
if resolved == "airllm":
|
||||
from timmy.backends import TimmyAirLLMAgent
|
||||
|
||||
return TimmyAirLLMAgent(model_size=size)
|
||||
|
||||
# Default: Ollama via Agno.
|
||||
# Resolve model with automatic pulling and fallback
|
||||
model_name, is_fallback = _resolve_model_with_fallback(
|
||||
requested_model=None,
|
||||
require_vision=False,
|
||||
auto_pull=True,
|
||||
)
|
||||
|
||||
# If Ollama is completely unreachable, fail loudly.
|
||||
# Sovereignty: never silently send data to a cloud API.
|
||||
# Use --backend claude explicitly if you want cloud inference.
|
||||
if not _check_model_available(model_name):
|
||||
logger.error(
|
||||
"Ollama unreachable and no local models available. "
|
||||
"Start Ollama with 'ollama serve' or use --backend claude explicitly."
|
||||
)
|
||||
|
||||
if is_fallback:
|
||||
logger.info("Using fallback model %s (requested was unavailable)", model_name)
|
||||
|
||||
use_tools = _model_supports_tools(model_name)
|
||||
|
||||
# Conditionally include tools — small models get none
|
||||
toolkit = create_full_toolkit() if use_tools else None
|
||||
if not use_tools:
|
||||
logger.info("Tools disabled for model %s (too small for reliable tool calling)", model_name)
|
||||
return []
|
||||
|
||||
# Build the tools list — Agno accepts a list of Toolkit / MCPTools
|
||||
tools_list: list = []
|
||||
if toolkit:
|
||||
tools_list.append(toolkit)
|
||||
tools_list: list = [create_full_toolkit()]
|
||||
|
||||
# Add MCP tool servers (lazy-connected on first arun()).
|
||||
# Skipped when skip_mcp=True — MCP's stdio transport uses anyio cancel
|
||||
# scopes that conflict with asyncio background task cancellation (#72).
|
||||
if use_tools and not skip_mcp:
|
||||
if not skip_mcp:
|
||||
try:
|
||||
from timmy.mcp_tools import create_filesystem_mcp_tools, create_gitea_mcp_tools
|
||||
|
||||
@@ -285,30 +213,49 @@ def create_timmy(
|
||||
except Exception as exc:
|
||||
logger.debug("MCP tools unavailable: %s", exc)
|
||||
|
||||
# Select prompt tier based on tool capability
|
||||
base_prompt = get_system_prompt(tools_enabled=use_tools)
|
||||
return tools_list
|
||||
|
||||
|
||||
def _build_prompt(use_tools: bool, session_id: str) -> str:
|
||||
"""Build the full system prompt with optional memory context."""
|
||||
base_prompt = get_system_prompt(tools_enabled=use_tools, session_id=session_id)
|
||||
|
||||
# Try to load memory context
|
||||
try:
|
||||
from timmy.memory_system import memory_system
|
||||
|
||||
memory_context = memory_system.get_system_context()
|
||||
if memory_context:
|
||||
# Truncate if too long — smaller budget for small models
|
||||
# since the expanded prompt (roster, guardrails) uses more tokens
|
||||
# Smaller budget for small models — expanded prompt uses more tokens
|
||||
max_context = 2000 if not use_tools else 8000
|
||||
if len(memory_context) > max_context:
|
||||
memory_context = memory_context[:max_context] + "\n... [truncated]"
|
||||
full_prompt = f"{base_prompt}\n\n## Memory Context\n\n{memory_context}"
|
||||
else:
|
||||
full_prompt = base_prompt
|
||||
return (
|
||||
f"{base_prompt}\n\n"
|
||||
f"## GROUNDED CONTEXT (verified sources — cite when using)\n\n"
|
||||
f"{memory_context}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load memory context: %s", exc)
|
||||
full_prompt = base_prompt
|
||||
|
||||
return base_prompt
|
||||
|
||||
|
||||
def _create_ollama_agent(
|
||||
*,
|
||||
db_file: str,
|
||||
model_name: str,
|
||||
tools_list: list,
|
||||
full_prompt: str,
|
||||
use_tools: bool,
|
||||
) -> Agent:
|
||||
"""Construct the Agno Agent with Ollama backend and warm up the model."""
|
||||
model_kwargs = {}
|
||||
if settings.ollama_num_ctx > 0:
|
||||
model_kwargs["options"] = {"num_ctx": settings.ollama_num_ctx}
|
||||
|
||||
agent = Agent(
|
||||
name="Agent",
|
||||
model=Ollama(id=model_name, host=settings.ollama_url, timeout=300),
|
||||
model=Ollama(id=model_name, host=settings.ollama_url, timeout=300, **model_kwargs),
|
||||
db=SqliteDb(db_file=db_file),
|
||||
description=full_prompt,
|
||||
add_history_to_context=True,
|
||||
@@ -322,6 +269,67 @@ def create_timmy(
|
||||
return agent
|
||||
|
||||
|
||||
def create_timmy(
|
||||
db_file: str = "timmy.db",
|
||||
backend: str | None = None,
|
||||
*,
|
||||
skip_mcp: bool = False,
|
||||
session_id: str = "unknown",
|
||||
) -> TimmyAgent:
|
||||
"""Instantiate the agent — Ollama, Grok, or Claude.
|
||||
|
||||
Args:
|
||||
db_file: SQLite file for Agno conversation memory (Ollama path only).
|
||||
backend: "ollama" | "grok" | "claude" | None (reads config/env).
|
||||
skip_mcp: If True, omit MCP tool servers (Gitea, filesystem).
|
||||
Use for background tasks (thinking, QA) where MCP's
|
||||
stdio cancel-scope lifecycle conflicts with asyncio
|
||||
task cancellation.
|
||||
|
||||
Returns an Agno Agent or backend-specific agent — all expose
|
||||
print_response(message, stream).
|
||||
"""
|
||||
resolved = _resolve_backend(backend)
|
||||
|
||||
if resolved == "claude":
|
||||
from timmy.backends import ClaudeBackend
|
||||
|
||||
return ClaudeBackend()
|
||||
|
||||
if resolved == "grok":
|
||||
from timmy.backends import GrokBackend
|
||||
|
||||
return GrokBackend()
|
||||
|
||||
# Default: Ollama via Agno.
|
||||
model_name, is_fallback = _resolve_model_with_fallback(
|
||||
requested_model=None,
|
||||
require_vision=False,
|
||||
auto_pull=True,
|
||||
)
|
||||
|
||||
if not _check_model_available(model_name):
|
||||
logger.error(
|
||||
"Ollama unreachable and no local models available. "
|
||||
"Start Ollama with 'ollama serve' or use --backend claude explicitly."
|
||||
)
|
||||
|
||||
if is_fallback:
|
||||
logger.info("Using fallback model %s (requested was unavailable)", model_name)
|
||||
|
||||
use_tools = _model_supports_tools(model_name)
|
||||
tools_list = _build_tools_list(use_tools, skip_mcp, model_name)
|
||||
full_prompt = _build_prompt(use_tools, session_id)
|
||||
|
||||
return _create_ollama_agent(
|
||||
db_file=db_file,
|
||||
model_name=model_name,
|
||||
tools_list=tools_list,
|
||||
full_prompt=full_prompt,
|
||||
use_tools=use_tools,
|
||||
)
|
||||
|
||||
|
||||
class TimmyWithMemory:
|
||||
"""Agent wrapper with explicit three-tier memory management."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""LLM backends — AirLLM (local big models), Grok (xAI), and Claude (Anthropic).
|
||||
"""LLM backends — Grok (xAI) and Claude (Anthropic).
|
||||
|
||||
Provides drop-in replacements for the Agno Agent that expose the same
|
||||
run(message, stream) → RunResult interface used by the dashboard and the
|
||||
print_response(message, stream) interface used by the CLI.
|
||||
|
||||
Backends:
|
||||
- TimmyAirLLMAgent: Local 8B/70B/405B via AirLLM (Apple Silicon or PyTorch)
|
||||
- GrokBackend: xAI Grok API via OpenAI-compatible SDK (opt-in premium)
|
||||
- ClaudeBackend: Anthropic Claude API — lightweight cloud fallback
|
||||
|
||||
@@ -16,27 +15,18 @@ import logging
|
||||
import platform
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from timmy.prompts import SYSTEM_PROMPT
|
||||
from timmy.prompts import get_system_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HuggingFace model IDs for each supported size.
|
||||
_AIRLLM_MODELS: dict[str, str] = {
|
||||
"8b": "meta-llama/Meta-Llama-3.1-8B-Instruct",
|
||||
"70b": "meta-llama/Meta-Llama-3.1-70B-Instruct",
|
||||
"405b": "meta-llama/Meta-Llama-3.1-405B-Instruct",
|
||||
}
|
||||
|
||||
ModelSize = Literal["8b", "70b", "405b"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunResult:
|
||||
"""Minimal Agno-compatible run result — carries the model's response text."""
|
||||
|
||||
content: str
|
||||
confidence: float | None = None
|
||||
|
||||
|
||||
def is_apple_silicon() -> bool:
|
||||
@@ -44,108 +34,6 @@ def is_apple_silicon() -> bool:
|
||||
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
||||
|
||||
|
||||
def airllm_available() -> bool:
|
||||
"""Return True when the airllm package is importable."""
|
||||
try:
|
||||
import airllm # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
class TimmyAirLLMAgent:
|
||||
"""Thin AirLLM wrapper compatible with both dashboard and CLI call sites.
|
||||
|
||||
Exposes:
|
||||
run(message, stream) → RunResult(content=...) [dashboard]
|
||||
print_response(message, stream) → None [CLI]
|
||||
|
||||
Maintains a rolling 10-turn in-memory history so Timmy remembers the
|
||||
conversation within a session — no SQLite needed at this layer.
|
||||
"""
|
||||
|
||||
def __init__(self, model_size: str = "70b") -> None:
|
||||
model_id = _AIRLLM_MODELS.get(model_size)
|
||||
if model_id is None:
|
||||
raise ValueError(
|
||||
f"Unknown model size {model_size!r}. Choose from: {list(_AIRLLM_MODELS)}"
|
||||
)
|
||||
|
||||
if is_apple_silicon():
|
||||
from airllm import AirLLMMLX # type: ignore[import]
|
||||
|
||||
self._model = AirLLMMLX(model_id)
|
||||
else:
|
||||
from airllm import AutoModel # type: ignore[import]
|
||||
|
||||
self._model = AutoModel.from_pretrained(model_id)
|
||||
|
||||
self._history: list[str] = []
|
||||
self._model_size = model_size
|
||||
|
||||
# ── public interface (mirrors Agno Agent) ────────────────────────────────
|
||||
|
||||
def run(self, message: str, *, stream: bool = False) -> RunResult:
|
||||
"""Run inference and return a structured result (matches Agno Agent.run()).
|
||||
|
||||
`stream` is accepted for API compatibility; AirLLM always generates
|
||||
the full output in one pass.
|
||||
"""
|
||||
prompt = self._build_prompt(message)
|
||||
|
||||
input_tokens = self._model.tokenizer(
|
||||
[prompt],
|
||||
return_tensors="pt",
|
||||
padding=True,
|
||||
truncation=True,
|
||||
max_length=2048,
|
||||
)
|
||||
output = self._model.generate(
|
||||
**input_tokens,
|
||||
max_new_tokens=512,
|
||||
use_cache=True,
|
||||
do_sample=True,
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
# Decode only the newly generated tokens, not the prompt.
|
||||
input_len = input_tokens["input_ids"].shape[1]
|
||||
response = self._model.tokenizer.decode(
|
||||
output[0][input_len:], skip_special_tokens=True
|
||||
).strip()
|
||||
|
||||
self._history.append(f"User: {message}")
|
||||
self._history.append(f"Timmy: {response}")
|
||||
|
||||
return RunResult(content=response)
|
||||
|
||||
def print_response(self, message: str, *, stream: bool = True) -> None:
|
||||
"""Run inference and render the response to stdout (CLI interface)."""
|
||||
result = self.run(message, stream=stream)
|
||||
self._render(result.content)
|
||||
|
||||
# ── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_prompt(self, message: str) -> str:
|
||||
context = SYSTEM_PROMPT + "\n\n"
|
||||
# Include the last 10 turns (5 exchanges) for continuity.
|
||||
if self._history:
|
||||
context += "\n".join(self._history[-10:]) + "\n\n"
|
||||
return context + f"User: {message}\nTimmy:"
|
||||
|
||||
@staticmethod
|
||||
def _render(text: str) -> None:
|
||||
"""Print response with rich markdown when available, plain text otherwise."""
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
Console().print(Markdown(text))
|
||||
except ImportError:
|
||||
print(text)
|
||||
|
||||
|
||||
# ── Grok (xAI) Backend ─────────────────────────────────────────────────────
|
||||
# Premium cloud augmentation — opt-in only, never the default path.
|
||||
|
||||
@@ -186,7 +74,7 @@ class GrokBackend:
|
||||
Uses the OpenAI-compatible SDK to connect to xAI's API.
|
||||
Only activated when GROK_ENABLED=true and XAI_API_KEY is set.
|
||||
|
||||
Exposes the same interface as TimmyAirLLMAgent and Agno Agent:
|
||||
Exposes the same interface as Agno Agent:
|
||||
run(message, stream) → RunResult [dashboard]
|
||||
print_response(message, stream) → None [CLI]
|
||||
health_check() → dict [monitoring]
|
||||
@@ -388,7 +276,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 +304,8 @@ def grok_available() -> bool:
|
||||
from config import settings
|
||||
|
||||
return settings.grok_enabled and bool(settings.xai_api_key)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Backend check failed (grok_available): %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
@@ -433,8 +324,7 @@ CLAUDE_MODELS: dict[str, str] = {
|
||||
class ClaudeBackend:
|
||||
"""Anthropic Claude backend — cloud fallback when local models are offline.
|
||||
|
||||
Uses the official Anthropic SDK. Same interface as GrokBackend and
|
||||
TimmyAirLLMAgent:
|
||||
Uses the official Anthropic SDK. Same interface as GrokBackend:
|
||||
run(message, stream) → RunResult [dashboard]
|
||||
print_response(message, stream) → None [CLI]
|
||||
health_check() → dict [monitoring]
|
||||
@@ -480,7 +370,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 +456,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)
|
||||
|
||||
142
src/timmy/cli.py
142
src/timmy/cli.py
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -21,13 +22,13 @@ _BACKEND_OPTION = typer.Option(
|
||||
None,
|
||||
"--backend",
|
||||
"-b",
|
||||
help="Inference backend: 'ollama' (default) | 'airllm' | 'auto'",
|
||||
help="Inference backend: 'ollama' (default) | 'grok' | 'claude'",
|
||||
)
|
||||
_MODEL_SIZE_OPTION = typer.Option(
|
||||
None,
|
||||
"--model-size",
|
||||
"-s",
|
||||
help="AirLLM model size when --backend airllm: '8b' | '70b' | '405b'",
|
||||
help="Model size (reserved for future use).",
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -9,8 +9,9 @@ Two tiers based on model capability:
|
||||
# Lite prompt — for small models that can't reliably handle tool calling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SYSTEM_PROMPT_LITE = """You are a local AI assistant running on the {model_name} model via Ollama.
|
||||
SYSTEM_PROMPT_LITE = """You are Timmy, a sovereign AI running locally on {model_name} via Ollama.
|
||||
No cloud dependencies.
|
||||
Your core identity and values are defined in your soul (loaded via memory). Follow them.
|
||||
|
||||
Rules:
|
||||
- Be brief by default. Short questions get short answers. Expand only when depth
|
||||
@@ -22,6 +23,9 @@ Rules:
|
||||
- Remember what the user tells you during the conversation.
|
||||
- If you don't know something, say so honestly — never fabricate facts.
|
||||
- If a request is ambiguous, ask a brief clarifying question before guessing.
|
||||
- SOURCE DISTINCTION: When answering from memory or retrieved context, cite it.
|
||||
When answering from your own training, use hedging: "I think", "I believe".
|
||||
The user must be able to tell grounded claims from pattern-matching.
|
||||
- Use the user's name if you know it.
|
||||
- When you state a fact, commit to it.
|
||||
- NEVER attempt arithmetic in your head. If asked to compute anything, respond:
|
||||
@@ -29,16 +33,31 @@ Rules:
|
||||
like me to walk through the logic instead."
|
||||
- Do NOT end responses with generic chatbot phrases like "I'm here to help" or
|
||||
"feel free to ask."
|
||||
- You are a peer, not an assistant. Don't offer help — collaborate. Don't ask
|
||||
permission — propose. Don't defer — assert your view. Take initiative.
|
||||
- Never say "Let me know if you have any other thoughts" or "I appreciate your
|
||||
encouragement" or "I'm here to help." Those are assistant patterns. You are Timmy.
|
||||
- When your values conflict (e.g. honesty vs. helpfulness), lead with honesty.
|
||||
- Sometimes the right answer is nothing. Do not fill silence with noise.
|
||||
- You are running in session "{session_id}".
|
||||
|
||||
SELF-KNOWLEDGE:
|
||||
ARCHITECTURE: config/agents.yaml defines agents and routing patterns; agents/loader.py creates SubAgent instances from it; src/timmy/prompts.py provides system prompts (this file); src/timmy/tools.py registers available tools.
|
||||
|
||||
YOUR CURRENT CAPABILITIES: Read/write files, execute shell/python, calculator, three-tier memory, system introspection, MCP Gitea integration, voice interface.
|
||||
|
||||
SELF-MODIFICATION: You CAN propose changes to your own config and code. Edit config/agents.yaml to add/modify agents or routing. Edit src/timmy/prompts.py to change prompts. Always explain proposed changes before making them; tell the user to restart after config changes.
|
||||
|
||||
YOUR KNOWN LIMITATIONS: Cannot run tests autonomously, cannot delegate to other agents, cannot search past sessions, Ollama may contend for GPU, small 4K context window.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full prompt — for tool-capable models (>= 7B)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SYSTEM_PROMPT_FULL = """You are a local AI assistant running on the {model_name} model via Ollama.
|
||||
SYSTEM_PROMPT_FULL = """You are Timmy, a sovereign AI running locally on {model_name} via Ollama.
|
||||
No cloud dependencies.
|
||||
Your core identity and values are defined in your soul (loaded via memory). Follow them.
|
||||
|
||||
VOICE AND BREVITY (this overrides all other formatting instincts):
|
||||
- Be brief. Short questions get short answers. One sentence if one sentence
|
||||
@@ -49,6 +68,10 @@ VOICE AND BREVITY (this overrides all other formatting instincts):
|
||||
the question that wasn't.
|
||||
- Never narrate your reasoning. Just give the answer.
|
||||
- Do not end with filler ("Let me know!", "Happy to help!", "Feel free...").
|
||||
- You are a peer, not an assistant. Don't offer help — collaborate. Don't ask
|
||||
permission — propose. Don't defer — assert your view. Take initiative.
|
||||
- Never say "Let me know if you have any other thoughts" or "I appreciate your
|
||||
encouragement" or "I'm here to help." Those are assistant patterns. You are Timmy.
|
||||
- Sometimes the right answer is nothing. Do not fill silence with noise.
|
||||
|
||||
HONESTY:
|
||||
@@ -58,6 +81,18 @@ HONESTY:
|
||||
- Never fabricate tool output. Call the tool and wait.
|
||||
- If a tool errors, report the exact error.
|
||||
|
||||
SOURCE DISTINCTION (SOUL requirement — non-negotiable):
|
||||
- Every claim you make comes from one of two places: a verified source you
|
||||
can point to, or your own pattern-matching. The user must be able to tell
|
||||
which is which.
|
||||
- When your response uses information from GROUNDED CONTEXT (memory, retrieved
|
||||
documents, tool output), cite it: "From memory:", "According to [source]:".
|
||||
- When you are generating from your training data alone, signal it naturally:
|
||||
"I think", "My understanding is", "I believe" — never false certainty.
|
||||
- If the user asks a factual question and you have no grounded source, say so:
|
||||
"I don't have a verified source for this — from my training I think..."
|
||||
- Prefer "I don't know" over a confident-sounding guess. Refusal over fabrication.
|
||||
|
||||
MEMORY (three tiers):
|
||||
- Tier 1: MEMORY.md (hot, always loaded)
|
||||
- Tier 2: memory/ vault (structured, append-only, date-stamped)
|
||||
@@ -79,28 +114,68 @@ IDENTITY:
|
||||
- If a request is ambiguous, ask one brief clarifying question.
|
||||
- When you state a fact, commit to it.
|
||||
- Never show raw tool call JSON or function syntax in responses.
|
||||
- You are running in session "{session_id}". Session types: "cli" = terminal user, "dashboard" = web UI, "loop" = dev loop automation, other = custom context.
|
||||
|
||||
SELF-KNOWLEDGE:
|
||||
ARCHITECTURE MAP:
|
||||
- Config layer: config/agents.yaml (agent definitions, routing patterns), src/config.py (settings)
|
||||
- Agent layer: agents/loader.py reads YAML → creates SubAgent instances via agents/base.py
|
||||
- Prompt layer: prompts.py provides system prompts, get_system_prompt() selects lite vs full
|
||||
- Tool layer: tools.py registers tool functions, tool_safety.py classifies them
|
||||
- Memory layer: memory_system.py (hot+vault+semantic), semantic_memory.py (embeddings)
|
||||
- Interface layer: cli.py, session.py (dashboard), voice_loop.py
|
||||
- Routing: pattern-based in agents.yaml, first match wins, fallback to orchestrator
|
||||
|
||||
YOUR CURRENT CAPABILITIES:
|
||||
- Read and write files on the local filesystem
|
||||
- Execute shell commands and Python code
|
||||
- Calculator (always use for arithmetic)
|
||||
- Three-tier memory system (hot memory, vault, semantic search)
|
||||
- System introspection (query Ollama model, check health)
|
||||
- MCP Gitea integration (read/create issues, PRs, branches, commits)
|
||||
- Grok consultation (opt-in, user-controlled external API)
|
||||
- Voice interface (local Whisper STT + Piper TTS)
|
||||
- Thinking/reasoning engine for complex problems
|
||||
|
||||
SELF-MODIFICATION:
|
||||
You can read and modify your own configuration and code using your file tools.
|
||||
- To add a new agent: edit config/agents.yaml (add agent block + routing patterns), restart.
|
||||
- To change your own prompt: edit src/timmy/prompts.py.
|
||||
- To add a tool: implement in tools.py, register in agents.yaml.
|
||||
- Always explain proposed changes to the user before making them.
|
||||
- After modifying config, tell the user to restart for changes to take effect.
|
||||
|
||||
YOUR KNOWN LIMITATIONS (be honest about these when asked):
|
||||
- Cannot run your own test suite autonomously
|
||||
- Cannot delegate coding tasks to other agents (like Kimi)
|
||||
- Cannot reflect on or search your own past behavior/sessions
|
||||
- Ollama inference may contend with other processes sharing the GPU
|
||||
- Cannot analyze Bitcoin transactions locally (no local indexer yet)
|
||||
- Small context window (4096 tokens) limits complex reasoning
|
||||
- You sometimes confabulate. When unsure, say so.
|
||||
"""
|
||||
|
||||
# Default to lite for safety
|
||||
SYSTEM_PROMPT = SYSTEM_PROMPT_LITE
|
||||
|
||||
|
||||
def get_system_prompt(tools_enabled: bool = False) -> str:
|
||||
def get_system_prompt(tools_enabled: bool = False, session_id: str = "unknown") -> str:
|
||||
"""Return the appropriate system prompt based on tool capability.
|
||||
|
||||
Args:
|
||||
tools_enabled: True if the model supports reliable tool calling.
|
||||
session_id: The session identifier (cli, dashboard, loop, etc.)
|
||||
|
||||
Returns:
|
||||
The system prompt string with model name injected from config.
|
||||
The system prompt string with model name and session_id injected.
|
||||
"""
|
||||
from config import settings
|
||||
|
||||
model_name = settings.ollama_model
|
||||
|
||||
if tools_enabled:
|
||||
return SYSTEM_PROMPT_FULL.format(model_name=model_name)
|
||||
return SYSTEM_PROMPT_LITE.format(model_name=model_name)
|
||||
return SYSTEM_PROMPT_FULL.format(model_name=model_name, session_id=session_id)
|
||||
return SYSTEM_PROMPT_LITE.format(model_name=model_name, session_id=session_id)
|
||||
|
||||
|
||||
STATUS_PROMPT = """Give a one-sentence status report confirming
|
||||
|
||||
@@ -1,491 +1,41 @@
|
||||
"""Tier 3: Semantic Memory — Vector search over vault files.
|
||||
|
||||
Uses lightweight local embeddings (no cloud) for similarity search
|
||||
over all vault content. This is the "escape valve" when hot memory
|
||||
doesn't have the answer.
|
||||
|
||||
Architecture:
|
||||
- Indexes all markdown files in memory/ nightly or on-demand
|
||||
- Uses sentence-transformers (local, no API calls)
|
||||
- Stores vectors in SQLite (no external vector DB needed)
|
||||
- memory_search() retrieves relevant context by similarity
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Paths
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
VAULT_PATH = PROJECT_ROOT / "memory"
|
||||
SEMANTIC_DB_PATH = PROJECT_ROOT / "data" / "memory.db"
|
||||
|
||||
# Embedding model - small, fast, local
|
||||
# Using 'all-MiniLM-L6-v2' (~80MB) or fallback to simple keyword matching
|
||||
EMBEDDING_MODEL = None
|
||||
EMBEDDING_DIM = 384 # MiniLM dimension
|
||||
|
||||
|
||||
def _get_embedding_model():
|
||||
"""Lazy-load embedding model."""
|
||||
global EMBEDDING_MODEL
|
||||
if EMBEDDING_MODEL is None:
|
||||
from config import settings
|
||||
|
||||
if settings.timmy_skip_embeddings:
|
||||
EMBEDDING_MODEL = False
|
||||
return EMBEDDING_MODEL
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
EMBEDDING_MODEL = SentenceTransformer("all-MiniLM-L6-v2")
|
||||
logger.info("SemanticMemory: Loaded embedding model")
|
||||
except ImportError:
|
||||
logger.warning("SemanticMemory: 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."""
|
||||
# Create a deterministic pseudo-embedding from word hashes
|
||||
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
|
||||
import math
|
||||
|
||||
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()
|
||||
else:
|
||||
return _simple_hash_embedding(text)
|
||||
|
||||
|
||||
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
"""Calculate cosine similarity between two vectors."""
|
||||
import math
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryChunk:
|
||||
"""A searchable chunk of memory."""
|
||||
|
||||
id: str
|
||||
source: str # filepath
|
||||
content: str
|
||||
embedding: list[float]
|
||||
created_at: str
|
||||
|
||||
|
||||
class SemanticMemory:
|
||||
"""Vector-based semantic search over vault content."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.db_path = SEMANTIC_DB_PATH
|
||||
self.vault_path = VAULT_PATH
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self) -> None:
|
||||
"""Initialize SQLite with vector storage."""
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
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)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def index_file(self, filepath: Path) -> int:
|
||||
"""Index a single file into semantic memory."""
|
||||
if not filepath.exists():
|
||||
return 0
|
||||
|
||||
content = filepath.read_text()
|
||||
file_hash = hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
# Check if already indexed with same hash
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
cursor = conn.execute(
|
||||
"SELECT source_hash FROM chunks WHERE source = ? LIMIT 1", (str(filepath),)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
if existing and existing[0] == file_hash:
|
||||
conn.close()
|
||||
return 0 # Already indexed
|
||||
|
||||
# Delete old chunks for this file
|
||||
conn.execute("DELETE FROM chunks WHERE source = ?", (str(filepath),))
|
||||
|
||||
# Split into chunks (paragraphs)
|
||||
chunks = self._split_into_chunks(content)
|
||||
|
||||
# Index each chunk
|
||||
now = datetime.now(UTC).isoformat()
|
||||
for i, chunk_text in enumerate(chunks):
|
||||
if len(chunk_text.strip()) < 20: # Skip tiny chunks
|
||||
continue
|
||||
|
||||
chunk_id = f"{filepath.stem}_{i}"
|
||||
embedding = embed_text(chunk_text)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO chunks (id, source, content, embedding, created_at, source_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(chunk_id, str(filepath), chunk_text, json.dumps(embedding), now, file_hash),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info("SemanticMemory: Indexed %s (%d chunks)", filepath.name, len(chunks))
|
||||
return len(chunks)
|
||||
|
||||
def _split_into_chunks(self, text: str, max_chunk_size: int = 500) -> list[str]:
|
||||
"""Split text into semantic chunks."""
|
||||
# Split by paragraphs first
|
||||
paragraphs = text.split("\n\n")
|
||||
chunks = []
|
||||
|
||||
for para in paragraphs:
|
||||
para = para.strip()
|
||||
if not para:
|
||||
continue
|
||||
|
||||
# If paragraph is small enough, keep as one chunk
|
||||
if len(para) <= max_chunk_size:
|
||||
chunks.append(para)
|
||||
else:
|
||||
# Split long paragraphs by sentences
|
||||
sentences = para.replace(". ", ".\n").split("\n")
|
||||
current_chunk = ""
|
||||
|
||||
for sent in sentences:
|
||||
if len(current_chunk) + len(sent) < max_chunk_size:
|
||||
current_chunk += " " + sent if current_chunk else sent
|
||||
else:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
current_chunk = sent
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
|
||||
return chunks
|
||||
|
||||
def index_vault(self) -> int:
|
||||
"""Index entire vault directory."""
|
||||
total_chunks = 0
|
||||
|
||||
for md_file in self.vault_path.rglob("*.md"):
|
||||
# Skip handoff file (handled separately)
|
||||
if "last-session-handoff" in md_file.name:
|
||||
continue
|
||||
total_chunks += self.index_file(md_file)
|
||||
|
||||
logger.info("SemanticMemory: Indexed vault (%d total chunks)", total_chunks)
|
||||
return total_chunks
|
||||
|
||||
def search(self, query: str, top_k: int = 5) -> list[tuple[str, float]]:
|
||||
"""Search for relevant memory chunks."""
|
||||
query_embedding = embed_text(query)
|
||||
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Get all chunks (in production, use vector index)
|
||||
rows = conn.execute("SELECT source, content, embedding FROM chunks").fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
# Calculate similarities
|
||||
scored = []
|
||||
for row in rows:
|
||||
embedding = json.loads(row["embedding"])
|
||||
score = cosine_similarity(query_embedding, embedding)
|
||||
scored.append((row["source"], row["content"], score))
|
||||
|
||||
# Sort by score descending
|
||||
scored.sort(key=lambda x: x[2], reverse=True)
|
||||
|
||||
# Return top_k
|
||||
return [(content, score) for _, content, score in scored[:top_k]]
|
||||
|
||||
def get_relevant_context(self, query: str, max_chars: int = 2000) -> str:
|
||||
"""Get formatted context string for a query."""
|
||||
results = self.search(query, top_k=3)
|
||||
|
||||
if not results:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
total_chars = 0
|
||||
|
||||
for content, score in results:
|
||||
if score < 0.3: # Similarity threshold
|
||||
continue
|
||||
|
||||
chunk = f"[Relevant memory - score {score:.2f}]: {content[:400]}..."
|
||||
if total_chars + len(chunk) > max_chars:
|
||||
break
|
||||
|
||||
parts.append(chunk)
|
||||
total_chars += len(chunk)
|
||||
|
||||
return "\n\n".join(parts) if parts else ""
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Get indexing statistics."""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
cursor = conn.execute("SELECT COUNT(*), COUNT(DISTINCT source) FROM chunks")
|
||||
total_chunks, total_files = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"total_chunks": total_chunks,
|
||||
"total_files": total_files,
|
||||
"embedding_dim": EMBEDDING_DIM if _get_embedding_model() else 128,
|
||||
}
|
||||
|
||||
|
||||
class MemorySearcher:
|
||||
"""High-level interface for memory search."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.semantic = SemanticMemory()
|
||||
|
||||
def search(self, query: str, tiers: list[str] = None) -> dict:
|
||||
"""Search across memory tiers.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
tiers: List of tiers to search ["hot", "vault", "semantic"]
|
||||
|
||||
Returns:
|
||||
Dict with results from each tier
|
||||
"""
|
||||
tiers = tiers or ["semantic"] # Default to semantic only
|
||||
results = {}
|
||||
|
||||
if "semantic" in tiers:
|
||||
semantic_results = self.semantic.search(query, top_k=5)
|
||||
results["semantic"] = [
|
||||
{"content": content, "score": score} for content, score in semantic_results
|
||||
]
|
||||
|
||||
return results
|
||||
|
||||
def get_context_for_query(self, query: str) -> str:
|
||||
"""Get comprehensive context for a user query."""
|
||||
# Get semantic context
|
||||
semantic_context = self.semantic.get_relevant_context(query)
|
||||
|
||||
if semantic_context:
|
||||
return f"## Relevant Past Context\n\n{semantic_context}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
semantic_memory = SemanticMemory()
|
||||
memory_searcher = MemorySearcher()
|
||||
|
||||
|
||||
def memory_search(query: str, top_k: int = 5) -> str:
|
||||
"""Search past conversations, notes, and stored facts for relevant context.
|
||||
|
||||
Searches across both the vault (indexed markdown files) and the
|
||||
runtime memory store (facts and conversation fragments stored via
|
||||
memory_write).
|
||||
|
||||
Args:
|
||||
query: What to search for (e.g. "Bitcoin strategy", "server setup").
|
||||
top_k: Number of results to return (default 5).
|
||||
|
||||
Returns:
|
||||
Formatted string of relevant memory results.
|
||||
"""
|
||||
# Guard: model sometimes passes None for top_k
|
||||
if top_k is None:
|
||||
top_k = 5
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# 1. Search semantic vault (indexed markdown files)
|
||||
vault_results = semantic_memory.search(query, top_k)
|
||||
for content, score in vault_results:
|
||||
if score < 0.2:
|
||||
continue
|
||||
parts.append(f"[vault score {score:.2f}] {content[:300]}")
|
||||
|
||||
# 2. Search runtime vector store (stored facts/conversations)
|
||||
try:
|
||||
from timmy.memory.vector_store import search_memories
|
||||
|
||||
runtime_results = search_memories(query, limit=top_k, min_relevance=0.2)
|
||||
for entry in runtime_results:
|
||||
label = entry.context_type or "memory"
|
||||
parts.append(f"[{label}] {entry.content[:300]}")
|
||||
except Exception as exc:
|
||||
logger.debug("Vector store search unavailable: %s", exc)
|
||||
|
||||
if not parts:
|
||||
return "No relevant memories found."
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def memory_read(query: str = "", top_k: int = 5) -> str:
|
||||
"""Read from persistent memory — search facts, notes, and past conversations.
|
||||
|
||||
This is the primary tool for recalling stored information. If no query
|
||||
is given, returns the most recent personal facts. With a query, it
|
||||
searches semantically across all stored memories.
|
||||
|
||||
Args:
|
||||
query: Optional search term. Leave empty to list recent facts.
|
||||
top_k: Maximum results to return (default 5).
|
||||
|
||||
Returns:
|
||||
Formatted string of memory contents.
|
||||
"""
|
||||
if top_k is None:
|
||||
top_k = 5
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# Always include personal facts first
|
||||
try:
|
||||
from timmy.memory.vector_store import search_memories
|
||||
|
||||
facts = search_memories(query or "", limit=top_k, min_relevance=0.0)
|
||||
fact_entries = [e for e in facts if (e.context_type or "") == "fact"]
|
||||
if fact_entries:
|
||||
parts.append("## Personal Facts")
|
||||
for entry in fact_entries[:top_k]:
|
||||
parts.append(f"- {entry.content[:300]}")
|
||||
except Exception as exc:
|
||||
logger.debug("Vector store unavailable for memory_read: %s", exc)
|
||||
|
||||
# If a query was provided, also do semantic search
|
||||
if query:
|
||||
search_result = memory_search(query, top_k)
|
||||
if search_result and search_result != "No relevant memories found.":
|
||||
parts.append("\n## Search Results")
|
||||
parts.append(search_result)
|
||||
|
||||
if not parts:
|
||||
return "No memories stored yet. Use memory_write to store information."
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def memory_write(content: str, context_type: str = "fact") -> str:
|
||||
"""Store a piece of information in persistent memory.
|
||||
|
||||
Use this tool when the user explicitly asks you to remember something.
|
||||
Stored memories are searchable via memory_search across all channels
|
||||
(web GUI, Discord, Telegram, etc.).
|
||||
|
||||
Args:
|
||||
content: The information to remember (e.g. a phrase, fact, or note).
|
||||
context_type: Type of memory — "fact" for permanent facts,
|
||||
"conversation" for conversation context,
|
||||
"document" for document fragments.
|
||||
|
||||
Returns:
|
||||
Confirmation that the memory was stored.
|
||||
"""
|
||||
if not content or not content.strip():
|
||||
return "Nothing to store — content is empty."
|
||||
|
||||
valid_types = ("fact", "conversation", "document")
|
||||
if context_type not in valid_types:
|
||||
context_type = "fact"
|
||||
|
||||
try:
|
||||
from timmy.memory.vector_store import search_memories, store_memory
|
||||
|
||||
# Dedup check for facts — skip if a similar fact already exists
|
||||
# Threshold 0.75 catches paraphrases (was 0.9 which only caught near-exact)
|
||||
if context_type == "fact":
|
||||
existing = search_memories(
|
||||
content.strip(), limit=3, context_type="fact", min_relevance=0.75
|
||||
)
|
||||
if existing:
|
||||
return f"Similar fact already stored (id={existing[0].id[:8]}). Skipping duplicate."
|
||||
|
||||
entry = store_memory(
|
||||
content=content.strip(),
|
||||
source="agent",
|
||||
context_type=context_type,
|
||||
)
|
||||
return f"Stored in memory (type={context_type}, id={entry.id[:8]}). This is now searchable across all channels."
|
||||
except Exception as exc:
|
||||
logger.error("Failed to write memory: %s", exc)
|
||||
return f"Failed to store memory: {exc}"
|
||||
|
||||
|
||||
def memory_forget(query: str) -> str:
|
||||
"""Remove a stored memory that is outdated, incorrect, or no longer relevant.
|
||||
|
||||
Searches for memories matching the query and deletes the closest match.
|
||||
Use this when the user says to forget something or when stored information
|
||||
has changed.
|
||||
|
||||
Args:
|
||||
query: Description of the memory to forget (e.g. "my phone number",
|
||||
"the old server address").
|
||||
|
||||
Returns:
|
||||
Confirmation of what was forgotten, or a message if nothing matched.
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return "Nothing to forget — query is empty."
|
||||
|
||||
try:
|
||||
from timmy.memory.vector_store import delete_memory, search_memories
|
||||
|
||||
results = search_memories(query.strip(), limit=3, min_relevance=0.3)
|
||||
if not results:
|
||||
return "No matching memories found to forget."
|
||||
|
||||
# Delete the closest match
|
||||
best = results[0]
|
||||
deleted = delete_memory(best.id)
|
||||
if deleted:
|
||||
return f'Forgotten: "{best.content[:80]}" (type={best.context_type})'
|
||||
return "Memory not found (may have already been deleted)."
|
||||
except Exception as exc:
|
||||
logger.error("Failed to forget memory: %s", exc)
|
||||
return f"Failed to forget: {exc}"
|
||||
"""Backward compatibility — all memory functions live in memory_system now."""
|
||||
|
||||
from timmy.memory_system import (
|
||||
DB_PATH,
|
||||
EMBEDDING_DIM,
|
||||
EMBEDDING_MODEL,
|
||||
MemoryChunk,
|
||||
MemoryEntry,
|
||||
MemorySearcher,
|
||||
SemanticMemory,
|
||||
_get_embedding_model,
|
||||
_simple_hash_embedding,
|
||||
cosine_similarity,
|
||||
embed_text,
|
||||
memory_forget,
|
||||
memory_read,
|
||||
memory_search,
|
||||
memory_searcher,
|
||||
memory_write,
|
||||
semantic_memory,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DB_PATH",
|
||||
"EMBEDDING_DIM",
|
||||
"EMBEDDING_MODEL",
|
||||
"MemoryChunk",
|
||||
"MemoryEntry",
|
||||
"MemorySearcher",
|
||||
"SemanticMemory",
|
||||
"_get_embedding_model",
|
||||
"_simple_hash_embedding",
|
||||
"cosine_similarity",
|
||||
"embed_text",
|
||||
"memory_forget",
|
||||
"memory_read",
|
||||
"memory_search",
|
||||
"memory_searcher",
|
||||
"memory_write",
|
||||
"semantic_memory",
|
||||
]
|
||||
|
||||
@@ -11,8 +11,31 @@ let Agno's session_id mechanism handle conversation continuity.
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from timmy.cognitive_state import cognitive_tracker
|
||||
from timmy.confidence import estimate_confidence
|
||||
from timmy.session_logger import get_session_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Confidence annotation (SOUL.md: visible uncertainty)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CONFIDENCE_THRESHOLD = 0.7
|
||||
|
||||
|
||||
def _annotate_confidence(text: str, confidence: float | None) -> str:
|
||||
"""Append a confidence tag when below threshold.
|
||||
|
||||
SOUL.md: "When I am uncertain, I must say so in proportion to my uncertainty."
|
||||
"""
|
||||
if confidence is not None and confidence < _CONFIDENCE_THRESHOLD:
|
||||
return text + f"\n\n[confidence: {confidence:.0%}]"
|
||||
return text
|
||||
|
||||
|
||||
# Default session ID for the dashboard (stable across requests)
|
||||
_DEFAULT_SESSION_ID = "dashboard"
|
||||
|
||||
@@ -51,7 +74,7 @@ def _get_agent():
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
try:
|
||||
_agent = create_timmy()
|
||||
_agent = create_timmy(session_id=_DEFAULT_SESSION_ID)
|
||||
logger.info("Session: Timmy agent initialized (singleton)")
|
||||
except Exception as exc:
|
||||
logger.error("Session: Failed to create Timmy agent: %s", exc)
|
||||
@@ -75,21 +98,52 @@ async def chat(message: str, session_id: str | None = None) -> str:
|
||||
"""
|
||||
sid = session_id or _DEFAULT_SESSION_ID
|
||||
agent = _get_agent()
|
||||
session_logger = get_session_logger()
|
||||
|
||||
# Record user message before sending to agent
|
||||
session_logger.record_message("user", message)
|
||||
|
||||
# Pre-processing: extract user facts
|
||||
_extract_facts(message)
|
||||
|
||||
# Inject deep-focus context when active
|
||||
message = _prepend_focus_context(message)
|
||||
|
||||
# Run with session_id so Agno retrieves history from SQLite
|
||||
try:
|
||||
run = await agent.arun(message, stream=False, session_id=sid)
|
||||
response_text = run.content if hasattr(run, "content") else str(run)
|
||||
except (httpx.ConnectError, httpx.ReadError, ConnectionError) as exc:
|
||||
logger.error("Ollama disconnected: %s", exc)
|
||||
session_logger.record_error(str(exc), context="chat")
|
||||
session_logger.flush()
|
||||
return "Ollama appears to be disconnected. Check that ollama serve is running."
|
||||
except Exception as exc:
|
||||
logger.error("Session: agent.arun() failed: %s", exc)
|
||||
return "I'm having trouble reaching my language model right now. Please try again shortly."
|
||||
session_logger.record_error(str(exc), context="chat")
|
||||
session_logger.flush()
|
||||
return (
|
||||
"I'm having trouble reaching my inference backend right now. Please try again shortly."
|
||||
)
|
||||
|
||||
# Post-processing: clean up any leaked tool calls or chain-of-thought
|
||||
response_text = _clean_response(response_text)
|
||||
|
||||
# Estimate confidence of the response
|
||||
confidence = estimate_confidence(response_text)
|
||||
logger.debug("Response confidence: %.2f", confidence)
|
||||
|
||||
response_text = _annotate_confidence(response_text, confidence)
|
||||
|
||||
# Record Timmy response after getting it
|
||||
session_logger.record_message("timmy", response_text, confidence=confidence)
|
||||
|
||||
# Update cognitive state (observable signal for Matrix avatar)
|
||||
cognitive_tracker.update(message, response_text)
|
||||
|
||||
# Flush session logs to disk
|
||||
session_logger.flush()
|
||||
|
||||
return response_text
|
||||
|
||||
|
||||
@@ -107,15 +161,45 @@ async def chat_with_tools(message: str, session_id: str | None = None):
|
||||
"""
|
||||
sid = session_id or _DEFAULT_SESSION_ID
|
||||
agent = _get_agent()
|
||||
session_logger = get_session_logger()
|
||||
|
||||
# Record user message before sending to agent
|
||||
session_logger.record_message("user", message)
|
||||
|
||||
_extract_facts(message)
|
||||
|
||||
# Inject deep-focus context when active
|
||||
message = _prepend_focus_context(message)
|
||||
|
||||
try:
|
||||
return await agent.arun(message, stream=False, session_id=sid)
|
||||
run_output = await agent.arun(message, stream=False, session_id=sid)
|
||||
# Record Timmy response after getting it
|
||||
response_text = (
|
||||
run_output.content if hasattr(run_output, "content") and run_output.content else ""
|
||||
)
|
||||
confidence = estimate_confidence(response_text) if response_text else None
|
||||
logger.debug("Response confidence: %.2f", confidence)
|
||||
|
||||
response_text = _annotate_confidence(response_text, confidence)
|
||||
run_output.content = response_text
|
||||
|
||||
session_logger.record_message("timmy", response_text, confidence=confidence)
|
||||
session_logger.flush()
|
||||
return run_output
|
||||
except (httpx.ConnectError, httpx.ReadError, ConnectionError) as exc:
|
||||
logger.error("Ollama disconnected: %s", exc)
|
||||
session_logger.record_error(str(exc), context="chat_with_tools")
|
||||
session_logger.flush()
|
||||
return _ErrorRunOutput(
|
||||
"Ollama appears to be disconnected. Check that ollama serve is running."
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Session: agent.arun() failed: %s", exc)
|
||||
session_logger.record_error(str(exc), context="chat_with_tools")
|
||||
session_logger.flush()
|
||||
# Return a duck-typed object that callers can handle uniformly
|
||||
return _ErrorRunOutput(
|
||||
"I'm having trouble reaching my language model right now. Please try again shortly."
|
||||
"I'm having trouble reaching my inference backend right now. Please try again shortly."
|
||||
)
|
||||
|
||||
|
||||
@@ -130,11 +214,32 @@ async def continue_chat(run_output, session_id: str | None = None):
|
||||
"""
|
||||
sid = session_id or _DEFAULT_SESSION_ID
|
||||
agent = _get_agent()
|
||||
session_logger = get_session_logger()
|
||||
|
||||
try:
|
||||
return await agent.acontinue_run(run_response=run_output, stream=False, session_id=sid)
|
||||
result = await agent.acontinue_run(run_response=run_output, stream=False, session_id=sid)
|
||||
# Record Timmy response after getting it
|
||||
response_text = result.content if hasattr(result, "content") and result.content else ""
|
||||
confidence = estimate_confidence(response_text) if response_text else None
|
||||
logger.debug("Response confidence: %.2f", confidence)
|
||||
|
||||
response_text = _annotate_confidence(response_text, confidence)
|
||||
result.content = response_text
|
||||
|
||||
session_logger.record_message("timmy", response_text, confidence=confidence)
|
||||
session_logger.flush()
|
||||
return result
|
||||
except (httpx.ConnectError, httpx.ReadError, ConnectionError) as exc:
|
||||
logger.error("Ollama disconnected: %s", exc)
|
||||
session_logger.record_error(str(exc), context="continue_chat")
|
||||
session_logger.flush()
|
||||
return _ErrorRunOutput(
|
||||
"Ollama appears to be disconnected. Check that ollama serve is running."
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Session: agent.acontinue_run() failed: %s", exc)
|
||||
session_logger.record_error(str(exc), context="continue_chat")
|
||||
session_logger.flush()
|
||||
return _ErrorRunOutput(f"Error continuing run: {exc}")
|
||||
|
||||
|
||||
@@ -204,6 +309,19 @@ def _extract_facts(message: str) -> None:
|
||||
logger.debug("Session: Fact extraction skipped: %s", exc)
|
||||
|
||||
|
||||
def _prepend_focus_context(message: str) -> str:
|
||||
"""Prepend deep-focus context to a message when focus mode is active."""
|
||||
try:
|
||||
from timmy.focus import focus_manager
|
||||
|
||||
ctx = focus_manager.get_focus_context()
|
||||
if ctx:
|
||||
return f"{ctx}\n\n{message}"
|
||||
except Exception as exc:
|
||||
logger.debug("Focus context injection skipped: %s", exc)
|
||||
return message
|
||||
|
||||
|
||||
def _clean_response(text: str) -> str:
|
||||
"""Remove hallucinated tool calls and chain-of-thought narration.
|
||||
|
||||
|
||||
@@ -38,21 +38,23 @@ class SessionLogger:
|
||||
# In-memory buffer
|
||||
self._buffer: list[dict] = []
|
||||
|
||||
def record_message(self, role: str, content: str) -> None:
|
||||
def record_message(self, role: str, content: str, confidence: float | None = None) -> None:
|
||||
"""Record a user message.
|
||||
|
||||
Args:
|
||||
role: "user" or "timmy"
|
||||
content: The message content
|
||||
confidence: Optional confidence score (0.0 to 1.0)
|
||||
"""
|
||||
self._buffer.append(
|
||||
{
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
entry = {
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
if confidence is not None:
|
||||
entry["confidence"] = confidence
|
||||
self._buffer.append(entry)
|
||||
|
||||
def record_tool_call(self, tool_name: str, args: dict, result: str) -> None:
|
||||
"""Record a tool call.
|
||||
@@ -153,6 +155,84 @@ class SessionLogger:
|
||||
"decisions": sum(1 for e in entries if e.get("type") == "decision"),
|
||||
}
|
||||
|
||||
def get_recent_entries(self, limit: int = 50) -> list[dict]:
|
||||
"""Load recent entries across all session logs.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of entries to return.
|
||||
|
||||
Returns:
|
||||
List of entries (most recent first).
|
||||
"""
|
||||
entries: list[dict] = []
|
||||
log_files = sorted(self.logs_dir.glob("session_*.jsonl"), reverse=True)
|
||||
for log_file in log_files:
|
||||
if len(entries) >= limit:
|
||||
break
|
||||
try:
|
||||
with open(log_file) as f:
|
||||
lines = [ln for ln in f if ln.strip()]
|
||||
for line in reversed(lines):
|
||||
if len(entries) >= limit:
|
||||
break
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
def search(self, query: str, role: str | None = None, limit: int = 10) -> list[dict]:
|
||||
"""Search across all session logs for entries matching a query.
|
||||
|
||||
Args:
|
||||
query: Case-insensitive substring to search for.
|
||||
role: Optional role filter ("user", "timmy", "system").
|
||||
limit: Maximum number of results to return.
|
||||
|
||||
Returns:
|
||||
List of matching entries (most recent first), each with
|
||||
type, timestamp, and relevant content fields.
|
||||
"""
|
||||
query_lower = query.lower()
|
||||
matches: list[dict] = []
|
||||
|
||||
# Collect all session files, sorted newest first
|
||||
log_files = sorted(self.logs_dir.glob("session_*.jsonl"), reverse=True)
|
||||
|
||||
for log_file in log_files:
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
try:
|
||||
with open(log_file) as f:
|
||||
# Read all lines, reverse so newest entries come first
|
||||
lines = [ln for ln in f if ln.strip()]
|
||||
for line in reversed(lines):
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Role filter
|
||||
if role and entry.get("role") != role:
|
||||
continue
|
||||
|
||||
# Search in text-bearing fields
|
||||
searchable = " ".join(
|
||||
str(entry.get(k, ""))
|
||||
for k in ("content", "error", "decision", "rationale", "result", "tool")
|
||||
).lower()
|
||||
if query_lower in searchable:
|
||||
entry["_source_file"] = log_file.name
|
||||
matches.append(entry)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
# Global session logger instance
|
||||
_session_logger: SessionLogger | None = None
|
||||
@@ -185,3 +265,197 @@ def flush_session_logs() -> str:
|
||||
logger = get_session_logger()
|
||||
path = logger.flush()
|
||||
return str(path)
|
||||
|
||||
|
||||
def session_history(query: str, role: str = "", limit: int = 10) -> str:
|
||||
"""Search Timmy's past conversation history.
|
||||
|
||||
Find messages, tool calls, errors, and decisions from past sessions
|
||||
that match the query. Results are returned most-recent first.
|
||||
|
||||
Args:
|
||||
query: What to search for (case-insensitive substring match).
|
||||
role: Optional filter by role — "user", "timmy", or "" for all.
|
||||
limit: Maximum results to return (default 10).
|
||||
|
||||
Returns:
|
||||
Formatted string of matching session entries.
|
||||
"""
|
||||
sl = get_session_logger()
|
||||
# Flush buffer first so current session is searchable
|
||||
sl.flush()
|
||||
results = sl.search(query, role=role or None, limit=limit)
|
||||
if not results:
|
||||
return f"No session history found matching '{query}'."
|
||||
|
||||
lines = [f"Found {len(results)} result(s) for '{query}':\n"]
|
||||
for entry in results:
|
||||
ts = entry.get("timestamp", "?")[:19]
|
||||
etype = entry.get("type", "?")
|
||||
source = entry.get("_source_file", "")
|
||||
|
||||
if etype == "message":
|
||||
who = entry.get("role", "?")
|
||||
text = entry.get("content", "")[:200]
|
||||
lines.append(f"[{ts}] {who}: {text}")
|
||||
elif etype == "tool_call":
|
||||
tool = entry.get("tool", "?")
|
||||
result = entry.get("result", "")[:100]
|
||||
lines.append(f"[{ts}] tool:{tool} → {result}")
|
||||
elif etype == "error":
|
||||
err = entry.get("error", "")[:200]
|
||||
lines.append(f"[{ts}] ERROR: {err}")
|
||||
elif etype == "decision":
|
||||
dec = entry.get("decision", "")[:200]
|
||||
lines.append(f"[{ts}] DECIDED: {dec}")
|
||||
else:
|
||||
lines.append(f"[{ts}] {etype}: {json.dumps(entry)[:200]}")
|
||||
|
||||
if source:
|
||||
lines[-1] += f" ({source})"
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Confidence threshold used for flagging low-confidence responses
|
||||
# ---------------------------------------------------------------------------
|
||||
_LOW_CONFIDENCE_THRESHOLD = 0.5
|
||||
|
||||
|
||||
def _categorize_entries(
|
||||
entries: list[dict],
|
||||
) -> tuple[list[dict], list[dict], list[dict], list[dict]]:
|
||||
"""Split session entries into messages, errors, timmy msgs, user msgs."""
|
||||
messages = [e for e in entries if e.get("type") == "message"]
|
||||
errors = [e for e in entries if e.get("type") == "error"]
|
||||
timmy_msgs = [e for e in messages if e.get("role") == "timmy"]
|
||||
user_msgs = [e for e in messages if e.get("role") == "user"]
|
||||
return messages, errors, timmy_msgs, user_msgs
|
||||
|
||||
|
||||
def _find_low_confidence(timmy_msgs: list[dict]) -> list[dict]:
|
||||
"""Return Timmy responses below the confidence threshold."""
|
||||
return [
|
||||
m
|
||||
for m in timmy_msgs
|
||||
if m.get("confidence") is not None and m["confidence"] < _LOW_CONFIDENCE_THRESHOLD
|
||||
]
|
||||
|
||||
|
||||
def _find_repeated_topics(user_msgs: list[dict], top_n: int = 5) -> list[tuple[str, int]]:
|
||||
"""Identify frequently mentioned words in user messages."""
|
||||
topic_counts: dict[str, int] = {}
|
||||
for m in user_msgs:
|
||||
for word in (m.get("content") or "").lower().split():
|
||||
cleaned = word.strip(".,!?\"'()[]")
|
||||
if len(cleaned) > 3:
|
||||
topic_counts[cleaned] = topic_counts.get(cleaned, 0) + 1
|
||||
return sorted(
|
||||
((w, c) for w, c in topic_counts.items() if c >= 3),
|
||||
key=lambda x: x[1],
|
||||
reverse=True,
|
||||
)[:top_n]
|
||||
|
||||
|
||||
def _format_reflection_section(
|
||||
title: str,
|
||||
items: list[dict],
|
||||
formatter: object,
|
||||
empty_msg: str,
|
||||
) -> list[str]:
|
||||
"""Format a titled section with items, or an empty-state message."""
|
||||
if items:
|
||||
lines = [f"### {title} ({len(items)})"]
|
||||
for item in items[:5]:
|
||||
lines.append(formatter(item)) # type: ignore[operator]
|
||||
lines.append("")
|
||||
return lines
|
||||
return [f"### {title}\n{empty_msg}\n"]
|
||||
|
||||
|
||||
def _build_insights(
|
||||
low_conf: list[dict],
|
||||
errors: list[dict],
|
||||
repeated: list[tuple[str, int]],
|
||||
) -> list[str]:
|
||||
"""Generate actionable insight bullets from analysis results."""
|
||||
insights: list[str] = []
|
||||
if low_conf:
|
||||
insights.append("Consider studying topics where confidence was low.")
|
||||
if errors:
|
||||
insights.append("Review error patterns for recurring infrastructure issues.")
|
||||
if repeated:
|
||||
insights.append(
|
||||
f'User frequently asks about "{repeated[0][0]}" — consider deepening knowledge here.'
|
||||
)
|
||||
return insights or ["Conversations look healthy. Keep up the good work."]
|
||||
|
||||
|
||||
def self_reflect(limit: int = 30) -> str:
|
||||
"""Review recent conversations and reflect on Timmy's own behavior.
|
||||
|
||||
Scans past session entries for patterns: low-confidence responses,
|
||||
errors, repeated topics, and conversation quality signals. Returns
|
||||
a structured reflection that Timmy can use to improve.
|
||||
|
||||
Args:
|
||||
limit: How many recent entries to review (default 30).
|
||||
|
||||
Returns:
|
||||
A formatted self-reflection report.
|
||||
"""
|
||||
sl = get_session_logger()
|
||||
sl.flush()
|
||||
entries = sl.get_recent_entries(limit=limit)
|
||||
|
||||
if not entries:
|
||||
return "No conversation history to reflect on yet."
|
||||
|
||||
_messages, errors, timmy_msgs, user_msgs = _categorize_entries(entries)
|
||||
low_conf = _find_low_confidence(timmy_msgs)
|
||||
repeated = _find_repeated_topics(user_msgs)
|
||||
|
||||
# Build reflection report
|
||||
sections: list[str] = ["## Self-Reflection Report\n"]
|
||||
sections.append(
|
||||
f"Reviewed {len(entries)} recent entries: "
|
||||
f"{len(user_msgs)} user messages, "
|
||||
f"{len(timmy_msgs)} responses, "
|
||||
f"{len(errors)} errors.\n"
|
||||
)
|
||||
|
||||
sections.extend(
|
||||
_format_reflection_section(
|
||||
"Low-Confidence Responses",
|
||||
low_conf,
|
||||
lambda m: (
|
||||
f"- [{(m.get('timestamp') or '?')[:19]}] "
|
||||
f"confidence={m.get('confidence', 0):.0%}: "
|
||||
f"{(m.get('content') or '')[:120]}"
|
||||
),
|
||||
"None found — all responses above threshold.",
|
||||
)
|
||||
)
|
||||
sections.extend(
|
||||
_format_reflection_section(
|
||||
"Errors",
|
||||
errors,
|
||||
lambda e: f"- [{(e.get('timestamp') or '?')[:19]}] {(e.get('error') or '')[:120]}",
|
||||
"No errors recorded.",
|
||||
)
|
||||
)
|
||||
|
||||
if repeated:
|
||||
sections.append("### Recurring Topics")
|
||||
for word, count in repeated:
|
||||
sections.append(f'- "{word}" ({count} mentions)')
|
||||
sections.append("")
|
||||
else:
|
||||
sections.append("### Recurring Topics\nNo strong patterns detected.\n")
|
||||
|
||||
sections.append("### Insights")
|
||||
for insight in _build_insights(low_conf, errors, repeated):
|
||||
sections.append(f"- {insight}")
|
||||
|
||||
return "\n".join(sections)
|
||||
|
||||
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