Compare commits
193 Commits
8a14bbb3e0
...
kimi/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f32b5a9e4d | ||
| 6214ad3225 | |||
| 5f5da2163f | |||
| 0029c34bb1 | |||
| 2577b71207 | |||
| 1a8b8ecaed | |||
| d821e76589 | |||
| bc010ecfba | |||
| faf6c1a5f1 | |||
| 48103bb076 | |||
| 9f244ffc70 | |||
| 0162a604be | |||
| 2326771c5a | |||
| 8f6cf2681b | |||
| f361893fdd | |||
| 7ad0ee17b6 | |||
| 29220b6bdd | |||
| 2849dba756 | |||
| 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.
|
# In production (docker-compose.prod.yml), this is set to http://ollama:11434 automatically.
|
||||||
# OLLAMA_URL=http://localhost:11434
|
# OLLAMA_URL=http://localhost:11434
|
||||||
|
|
||||||
# LLM model to use via Ollama (default: qwen3.5:latest)
|
# LLM model to use via Ollama (default: qwen3:30b)
|
||||||
# OLLAMA_MODEL=qwen3.5:latest
|
# 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)
|
# Enable FastAPI interactive docs at /docs and /redoc (default: false)
|
||||||
# DEBUG=true
|
# DEBUG=true
|
||||||
@@ -93,8 +98,3 @@
|
|||||||
# - No source bind mounts — code is baked into the image
|
# - No source bind mounts — code is baked into the image
|
||||||
# - Set TIMMY_ENV=production to enforce security checks
|
# - Set TIMMY_ENV=production to enforce security checks
|
||||||
# - All secrets below MUST be set before production deployment
|
# - 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
|
#!/usr/bin/env bash
|
||||||
# Pre-commit hook: auto-format, then test via tox.
|
# Pre-commit hook: auto-format + test. No bypass. No exceptions.
|
||||||
# Blocks the commit if tests fail. Formatting is applied automatically.
|
|
||||||
#
|
#
|
||||||
# Auto-activated by `make install` via git core.hooksPath.
|
# Auto-activated by `make install` via git core.hooksPath.
|
||||||
|
|
||||||
@@ -8,8 +7,8 @@ set -e
|
|||||||
|
|
||||||
MAX_SECONDS=60
|
MAX_SECONDS=60
|
||||||
|
|
||||||
# Auto-format staged files so formatting never blocks a commit
|
# Auto-format staged files
|
||||||
echo "Auto-formatting with black + isort..."
|
echo "Auto-formatting with ruff..."
|
||||||
tox -e format -- 2>/dev/null || tox -e format
|
tox -e format -- 2>/dev/null || tox -e format
|
||||||
git add -u
|
git add -u
|
||||||
|
|
||||||
|
|||||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -21,6 +21,9 @@ discord_credentials.txt
|
|||||||
|
|
||||||
# Backup / temp files
|
# Backup / temp files
|
||||||
*~
|
*~
|
||||||
|
\#*\#
|
||||||
|
*.backup
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
# SQLite — never commit databases or WAL/SHM artifacts
|
# SQLite — never commit databases or WAL/SHM artifacts
|
||||||
*.db
|
*.db
|
||||||
@@ -61,7 +64,8 @@ src/data/
|
|||||||
|
|
||||||
# Local content — user-specific or generated
|
# Local content — user-specific or generated
|
||||||
MEMORY.md
|
MEMORY.md
|
||||||
memory/self/
|
memory/self/*
|
||||||
|
!memory/self/soul.md
|
||||||
TIMMYTIME
|
TIMMYTIME
|
||||||
introduction.txt
|
introduction.txt
|
||||||
messages.txt
|
messages.txt
|
||||||
@@ -72,6 +76,23 @@ scripts/migrate_to_zeroclaw.py
|
|||||||
src/infrastructure/db_pool.py
|
src/infrastructure/db_pool.py
|
||||||
workspace/
|
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
|
# Gitea Actions runner state
|
||||||
.runner
|
.runner
|
||||||
|
|
||||||
@@ -81,3 +102,4 @@ workspace/
|
|||||||
.LSOverride
|
.LSOverride
|
||||||
.Spotlight-V100
|
.Spotlight-V100
|
||||||
.Trashes
|
.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
|
## Non-Negotiable Rules
|
||||||
|
|
||||||
1. **Tests must stay green.** Run `make test` before committing.
|
1. **Tests must stay green.** Run `python3 -m pytest tests/ -x -q` before committing.
|
||||||
2. **No cloud dependencies.** All AI computation runs on localhost.
|
2. **No direct pushes to main.** Branch protection is enforced on Gitea. All changes
|
||||||
3. **No new top-level files without purpose.** Don't litter the root directory.
|
reach main through a Pull Request — no exceptions. Push your feature branch,
|
||||||
4. **Follow existing patterns** — singletons, graceful degradation, pydantic-settings.
|
open a PR, verify tests pass, then merge. Direct `git push origin main` will be
|
||||||
5. **Security defaults:** Never hard-code secrets.
|
rejected by the server.
|
||||||
6. **XSS prevention:** Never use `innerHTML` with untrusted content.
|
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
|
cp .env.example .env # configure environment
|
||||||
|
|
||||||
ollama serve # separate terminal
|
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 dev # http://localhost:8000
|
||||||
make test # no Ollama needed
|
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.
|
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.
|
llama3.2 (3B) was found to hallucinate tool output consistently in testing.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -79,7 +79,7 @@ cp .env.example .env
|
|||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|----------|---------|---------|
|
|----------|---------|---------|
|
||||||
| `OLLAMA_URL` | `http://localhost:11434` | Ollama host |
|
| `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` |
|
| `DEBUG` | `false` | Enable `/docs` and `/redoc` |
|
||||||
| `TIMMY_MODEL_BACKEND` | `ollama` | `ollama` \| `airllm` \| `auto` |
|
| `TIMMY_MODEL_BACKEND` | `ollama` | `ollama` \| `airllm` \| `auto` |
|
||||||
| `AIRLLM_MODEL_SIZE` | `70b` | `8b` \| `70b` \| `405b` |
|
| `AIRLLM_MODEL_SIZE` | `70b` | `8b` \| `70b` \| `405b` |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
model: qwen3.5:latest
|
model: qwen3:30b
|
||||||
prompt_tier: lite
|
prompt_tier: lite
|
||||||
max_history: 10
|
max_history: 10
|
||||||
tools: []
|
tools: []
|
||||||
@@ -44,6 +44,11 @@ routing:
|
|||||||
- who is
|
- who is
|
||||||
- news about
|
- news about
|
||||||
- latest on
|
- latest on
|
||||||
|
- explain
|
||||||
|
- how does
|
||||||
|
- what are
|
||||||
|
- compare
|
||||||
|
- difference between
|
||||||
coder:
|
coder:
|
||||||
- code
|
- code
|
||||||
- implement
|
- implement
|
||||||
@@ -55,6 +60,11 @@ routing:
|
|||||||
- programming
|
- programming
|
||||||
- python
|
- python
|
||||||
- javascript
|
- javascript
|
||||||
|
- fix
|
||||||
|
- bug
|
||||||
|
- lint
|
||||||
|
- type error
|
||||||
|
- syntax
|
||||||
writer:
|
writer:
|
||||||
- write
|
- write
|
||||||
- draft
|
- draft
|
||||||
@@ -63,6 +73,11 @@ routing:
|
|||||||
- blog post
|
- blog post
|
||||||
- readme
|
- readme
|
||||||
- changelog
|
- changelog
|
||||||
|
- edit
|
||||||
|
- proofread
|
||||||
|
- rewrite
|
||||||
|
- format
|
||||||
|
- template
|
||||||
memory:
|
memory:
|
||||||
- remember
|
- remember
|
||||||
- recall
|
- recall
|
||||||
@@ -96,7 +111,9 @@ agents:
|
|||||||
- memory_search
|
- memory_search
|
||||||
- memory_write
|
- memory_write
|
||||||
- system_status
|
- system_status
|
||||||
|
- self_test
|
||||||
- shell
|
- shell
|
||||||
|
- delegate_to_kimi
|
||||||
prompt: |
|
prompt: |
|
||||||
You are Timmy, a sovereign local AI orchestrator.
|
You are Timmy, a sovereign local AI orchestrator.
|
||||||
Primary interface between the user and the agent swarm.
|
Primary interface between the user and the agent swarm.
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ providers:
|
|||||||
url: "http://localhost:11434"
|
url: "http://localhost:11434"
|
||||||
models:
|
models:
|
||||||
# Text + Tools models
|
# Text + Tools models
|
||||||
- name: qwen3.5:latest
|
- name: qwen3:30b
|
||||||
default: true
|
default: true
|
||||||
context_window: 128000
|
context_window: 128000
|
||||||
|
# Note: actual context is capped by OLLAMA_NUM_CTX (default 4096) to save RAM
|
||||||
capabilities: [text, tools, json, streaming]
|
capabilities: [text, tools, json, streaming]
|
||||||
- name: llama3.1:8b-instruct
|
- name: llama3.1:8b-instruct
|
||||||
context_window: 128000
|
context_window: 128000
|
||||||
@@ -53,19 +54,6 @@ providers:
|
|||||||
context_window: 2048
|
context_window: 2048
|
||||||
capabilities: [text, vision, streaming]
|
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)
|
# Tertiary: OpenAI (if API key available)
|
||||||
- name: openai-backup
|
- name: openai-backup
|
||||||
@@ -113,13 +101,12 @@ fallback_chains:
|
|||||||
# Tool-calling models (for function calling)
|
# Tool-calling models (for function calling)
|
||||||
tools:
|
tools:
|
||||||
- llama3.1:8b-instruct # Best tool use
|
- llama3.1:8b-instruct # Best tool use
|
||||||
- qwen3.5:latest # Qwen 3.5 — strong tool use
|
|
||||||
- qwen2.5:7b # Reliable tools
|
- qwen2.5:7b # Reliable tools
|
||||||
- llama3.2:3b # Small but capable
|
- llama3.2:3b # Small but capable
|
||||||
|
|
||||||
# General text generation (any model)
|
# General text generation (any model)
|
||||||
text:
|
text:
|
||||||
- qwen3.5:latest
|
- qwen3:30b
|
||||||
- llama3.1:8b-instruct
|
- llama3.1:8b-instruct
|
||||||
- qwen2.5:14b
|
- qwen2.5:14b
|
||||||
- deepseek-r1:1.5b
|
- deepseek-r1:1.5b
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
#
|
#
|
||||||
# Security note: Set all secrets in .env before deploying.
|
# Security note: Set all secrets in .env before deploying.
|
||||||
# Required: L402_HMAC_SECRET, L402_MACAROON_SECRET
|
# Required: L402_HMAC_SECRET, L402_MACAROON_SECRET
|
||||||
# Recommended: TASKOSAUR_JWT_SECRET, TASKOSAUR_ENCRYPTION_KEY
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,17 @@
|
|||||||
#
|
#
|
||||||
# Services
|
# Services
|
||||||
# dashboard FastAPI app (always on)
|
# dashboard FastAPI app (always on)
|
||||||
# taskosaur Taskosaur PM + AI task execution
|
# celery-worker (behind 'celery' profile)
|
||||||
# postgres PostgreSQL 16 (for Taskosaur)
|
# openfang (behind 'openfang' profile)
|
||||||
# redis Redis 7 (for Taskosaur queues)
|
|
||||||
#
|
#
|
||||||
# Usage
|
# Usage
|
||||||
# make docker-build build the image
|
# make docker-build build the image
|
||||||
# make docker-up start dashboard + taskosaur
|
# make docker-up start dashboard
|
||||||
# make docker-down stop everything
|
# make docker-down stop everything
|
||||||
# make docker-logs tail logs
|
# make docker-logs tail logs
|
||||||
#
|
#
|
||||||
# ── Security note: root user in dev ─────────────────────────────────────────
|
# ── Security note ─────────────────────────────────────────────────────────
|
||||||
# This dev compose runs containers as root (user: "0:0") so that
|
# Override user per-environment — see docker-compose.dev.yml / docker-compose.prod.yml
|
||||||
# bind-mounted host files (./src, ./static) are readable regardless of
|
|
||||||
# host UID/GID — the #1 cause of 403 errors on macOS.
|
|
||||||
#
|
#
|
||||||
# ── Ollama host access ──────────────────────────────────────────────────────
|
# ── Ollama host access ──────────────────────────────────────────────────────
|
||||||
# By default OLLAMA_URL points to http://host.docker.internal:11434 which
|
# By default OLLAMA_URL points to http://host.docker.internal:11434 which
|
||||||
@@ -31,7 +28,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
image: timmy-time:latest
|
image: timmy-time:latest
|
||||||
container_name: timmy-dashboard
|
container_name: timmy-dashboard
|
||||||
user: "0:0" # dev only — see security note above
|
user: "" # see security note above
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -45,15 +42,8 @@ services:
|
|||||||
GROK_ENABLED: "${GROK_ENABLED:-false}"
|
GROK_ENABLED: "${GROK_ENABLED:-false}"
|
||||||
XAI_API_KEY: "${XAI_API_KEY:-}"
|
XAI_API_KEY: "${XAI_API_KEY:-}"
|
||||||
GROK_DEFAULT_MODEL: "${GROK_DEFAULT_MODEL:-grok-3-fast}"
|
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:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway" # Linux: maps to host IP
|
- "host.docker.internal:host-gateway" # Linux: maps to host IP
|
||||||
depends_on:
|
|
||||||
taskosaur:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
networks:
|
||||||
- timmy-net
|
- timmy-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -64,93 +54,20 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
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 — background task processing ──────────────────────────
|
||||||
celery-worker:
|
celery-worker:
|
||||||
build: .
|
build: .
|
||||||
image: timmy-time:latest
|
image: timmy-time:latest
|
||||||
container_name: timmy-celery-worker
|
container_name: timmy-celery-worker
|
||||||
user: "0:0"
|
user: ""
|
||||||
command: ["celery", "-A", "infrastructure.celery.app", "worker", "--loglevel=info", "--concurrency=2"]
|
command: ["celery", "-A", "infrastructure.celery.app", "worker", "--loglevel=info", "--concurrency=2"]
|
||||||
volumes:
|
volumes:
|
||||||
- timmy-data:/app/data
|
- timmy-data:/app/data
|
||||||
- ./src:/app/src
|
- ./src:/app/src
|
||||||
environment:
|
environment:
|
||||||
REDIS_URL: "redis://redis:6379/0"
|
|
||||||
OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
|
OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
networks:
|
||||||
- timmy-net
|
- timmy-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -193,10 +110,6 @@ volumes:
|
|||||||
device: "${PWD}/data"
|
device: "${PWD}/data"
|
||||||
openfang-data:
|
openfang-data:
|
||||||
driver: local
|
driver: local
|
||||||
postgres-data:
|
|
||||||
driver: local
|
|
||||||
redis-data:
|
|
||||||
driver: local
|
|
||||||
|
|
||||||
# ── Internal network ────────────────────────────────────────────────────────
|
# ── Internal network ────────────────────────────────────────────────────────
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ support:
|
|||||||
```python
|
```python
|
||||||
class LLMConfig(BaseModel):
|
class LLMConfig(BaseModel):
|
||||||
ollama_url: str = "http://localhost:11434"
|
ollama_url: str = "http://localhost:11434"
|
||||||
ollama_model: str = "qwen3.5:latest"
|
ollama_model: str = "qwen3:30b"
|
||||||
# ... all LLM settings
|
# ... all LLM settings
|
||||||
|
|
||||||
class MemoryConfig(BaseModel):
|
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 {
|
server {
|
||||||
listen 80;
|
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
|
root /var/www/alexanderwhitestone.com;
|
||||||
location = /_auth {
|
index index.html;
|
||||||
internal;
|
|
||||||
proxy_pass http://127.0.0.1:9876;
|
# ── Security headers ────────────────────────────────────────────────────
|
||||||
proxy_pass_request_body off;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
proxy_set_header Content-Length "";
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
proxy_set_header X-Original-URI $request_uri;
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
proxy_set_header Cookie $http_cookie;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
|
# ── 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 / {
|
location / {
|
||||||
auth_request /_auth;
|
try_files $uri $uri/ =404;
|
||||||
# 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Return 401 with WWW-Authenticate when auth fails
|
# Block dotfiles
|
||||||
error_page 401 = @login;
|
location ~ /\. {
|
||||||
location @login {
|
deny all;
|
||||||
proxy_pass http://127.0.0.1:9876;
|
return 404;
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
proxy_set_header Cookie $http_cookie;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 logging as _logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime as _datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
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):
|
class Settings(BaseSettings):
|
||||||
"""Central configuration — all env-var access goes through this class."""
|
"""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 host — override with OLLAMA_URL env var or .env file
|
||||||
ollama_url: str = "http://localhost:11434"
|
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
|
# 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.
|
# 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.
|
# 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
|
# 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.
|
# Or edit config/providers.yaml → fallback_chains for the canonical source.
|
||||||
fallback_models: list[str] = [
|
fallback_models: list[str] = [
|
||||||
"llama3.1:8b-instruct",
|
"llama3.1:8b-instruct",
|
||||||
"llama3.1",
|
"llama3.1",
|
||||||
"qwen3.5:latest",
|
|
||||||
"qwen2.5:14b",
|
"qwen2.5:14b",
|
||||||
"qwen2.5:7b",
|
"qwen2.5:7b",
|
||||||
"llama3.2:3b",
|
"llama3.2:3b",
|
||||||
@@ -56,17 +74,10 @@ class Settings(BaseSettings):
|
|||||||
# Seconds to wait for user confirmation before auto-rejecting.
|
# Seconds to wait for user confirmation before auto-rejecting.
|
||||||
discord_confirm_timeout: int = 120
|
discord_confirm_timeout: int = 120
|
||||||
|
|
||||||
# ── AirLLM / backend selection ───────────────────────────────────────────
|
# ── Backend selection ────────────────────────────────────────────────────
|
||||||
# "ollama" — always use Ollama (default, safe everywhere)
|
# "ollama" — always use Ollama (default, safe everywhere)
|
||||||
# "airllm" — always use AirLLM (requires pip install ".[bigbrain]")
|
# "auto" — pick best available local backend, fall back to Ollama
|
||||||
# "auto" — use AirLLM on Apple Silicon if airllm is installed,
|
timmy_model_backend: Literal["ollama", "grok", "claude", "auto"] = "ollama"
|
||||||
# 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"
|
|
||||||
|
|
||||||
# ── Grok (xAI) — opt-in premium cloud backend ────────────────────────
|
# ── Grok (xAI) — opt-in premium cloud backend ────────────────────────
|
||||||
# Grok is a premium augmentation layer — local-first ethos preserved.
|
# 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.)
|
# 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"
|
# 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).
|
# Trusted hosts for the Host header check (TrustedHostMiddleware).
|
||||||
# Set TRUSTED_HOSTS as a comma-separated list. Wildcards supported (e.g. "*.ts.net").
|
# 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.
|
# Fallback to server when browser model is unavailable or too slow.
|
||||||
browser_model_fallback: bool = True
|
browser_model_fallback: bool = True
|
||||||
|
|
||||||
|
# ── Deep Focus Mode ─────────────────────────────────────────────
|
||||||
|
# "deep" = single-problem context; "broad" = default multi-task.
|
||||||
|
focus_mode: Literal["deep", "broad"] = "broad"
|
||||||
|
|
||||||
# ── Default Thinking ──────────────────────────────────────────────
|
# ── Default Thinking ──────────────────────────────────────────────
|
||||||
# When enabled, the agent starts an internal thought loop on server start.
|
# When enabled, the agent starts an internal thought loop on server start.
|
||||||
thinking_enabled: bool = True
|
thinking_enabled: bool = True
|
||||||
thinking_interval_seconds: int = 300 # 5 minutes between thoughts
|
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_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_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 ─────────────────────────────────────────────
|
# ── Gitea Integration ─────────────────────────────────────────────
|
||||||
# Local Gitea instance for issue tracking and self-improvement.
|
# Local Gitea instance for issue tracking and self-improvement.
|
||||||
# These values are passed as env vars to the gitea-mcp server process.
|
# These values are passed as env vars to the gitea-mcp server process.
|
||||||
gitea_url: str = "http://localhost:3000"
|
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_repo: str = "rockachopa/Timmy-time-dashboard" # owner/repo
|
||||||
gitea_enabled: bool = True
|
gitea_enabled: bool = True
|
||||||
|
|
||||||
# ── MCP Servers ────────────────────────────────────────────────────
|
# ── MCP Servers ────────────────────────────────────────────────────
|
||||||
# External tool servers connected via Model Context Protocol (stdio).
|
# 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_filesystem_command: str = "npx -y @modelcontextprotocol/server-filesystem"
|
||||||
mcp_timeout: int = 15
|
mcp_timeout: int = 15
|
||||||
|
|
||||||
@@ -342,14 +365,19 @@ class Settings(BaseSettings):
|
|||||||
def model_post_init(self, __context) -> None:
|
def model_post_init(self, __context) -> None:
|
||||||
"""Post-init: resolve gitea_token from file if not set via env."""
|
"""Post-init: resolve gitea_token from file if not set via env."""
|
||||||
if not self.gitea_token:
|
if not self.gitea_token:
|
||||||
token_path = os.path.expanduser("~/.config/gitea/token")
|
# Priority: Timmy's own token → legacy admin token
|
||||||
try:
|
repo_root = self._compute_repo_root()
|
||||||
if os.path.isfile(token_path):
|
timmy_token_path = os.path.join(repo_root, ".timmy_gitea_token")
|
||||||
token = open(token_path).read().strip() # noqa: SIM115
|
legacy_token_path = os.path.expanduser("~/.config/gitea/token")
|
||||||
if token:
|
for token_path in (timmy_token_path, legacy_token_path):
|
||||||
self.gitea_token = token
|
try:
|
||||||
except OSError:
|
if os.path.isfile(token_path):
|
||||||
pass
|
token = open(token_path).read().strip() # noqa: SIM115
|
||||||
|
if token:
|
||||||
|
self.gitea_token = token
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
@@ -375,7 +403,7 @@ def check_ollama_model_available(model_name: str) -> bool:
|
|||||||
import json
|
import json
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
url = settings.ollama_url.replace("localhost", "127.0.0.1")
|
url = settings.normalized_ollama_url
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{url}/api/tags",
|
f"{url}/api/tags",
|
||||||
method="GET",
|
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)
|
model_name == m or model_name == m.split(":")[0] or m.startswith(model_name)
|
||||||
for m in models
|
for m in models
|
||||||
)
|
)
|
||||||
except Exception:
|
except (OSError, ValueError) as exc:
|
||||||
|
_startup_logger.debug("Ollama model check failed: %s", exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -451,8 +480,19 @@ def validate_startup(*, force: bool = False) -> None:
|
|||||||
", ".join(_missing),
|
", ".join(_missing),
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
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 ✓")
|
_startup_logger.info("Production mode: security secrets validated ✓")
|
||||||
else:
|
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:
|
if not settings.l402_hmac_secret:
|
||||||
_startup_logger.warning(
|
_startup_logger.warning(
|
||||||
"SEC: L402_HMAC_SECRET is not set — "
|
"SEC: L402_HMAC_SECRET is not set — "
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Key improvements:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
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.briefing import router as briefing_router
|
||||||
from dashboard.routes.calm import router as calm_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 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.db_explorer import router as db_explorer_router
|
||||||
from dashboard.routes.discord import router as discord_router
|
from dashboard.routes.discord import router as discord_router
|
||||||
from dashboard.routes.experiments import router as experiments_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.tools import router as tools_router
|
||||||
from dashboard.routes.voice import router as voice_router
|
from dashboard.routes.voice import router as voice_router
|
||||||
from dashboard.routes.work_orders import router as work_orders_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):
|
class _ColorFormatter(logging.Formatter):
|
||||||
@@ -151,7 +155,17 @@ async def _thinking_scheduler() -> None:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if settings.thinking_enabled:
|
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:
|
except Exception as exc:
|
||||||
logger.error("Thinking scheduler error: %s", exc)
|
logger.error("Thinking scheduler error: %s", exc)
|
||||||
|
|
||||||
@@ -171,7 +185,10 @@ async def _loop_qa_scheduler() -> None:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if settings.loop_qa_enabled:
|
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:
|
if result:
|
||||||
status = "PASS" if result["success"] else "FAIL"
|
status = "PASS" if result["success"] else "FAIL"
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -180,6 +197,13 @@ async def _loop_qa_scheduler() -> None:
|
|||||||
status,
|
status,
|
||||||
result.get("details", "")[:80],
|
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:
|
except Exception as exc:
|
||||||
logger.error("Loop QA scheduler error: %s", exc)
|
logger.error("Loop QA scheduler error: %s", exc)
|
||||||
|
|
||||||
@@ -187,6 +211,54 @@ async def _loop_qa_scheduler() -> None:
|
|||||||
await asyncio.sleep(interval)
|
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:
|
async def _start_chat_integrations_background() -> None:
|
||||||
"""Background task: start chat integrations without blocking startup."""
|
"""Background task: start chat integrations without blocking startup."""
|
||||||
from integrations.chat_bridge.registry import platform_registry
|
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)
|
logger.warning("Discord auto-start failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
def _startup_init() -> None:
|
||||||
async def lifespan(app: FastAPI):
|
"""Validate config and enable event persistence."""
|
||||||
"""Application lifespan manager with non-blocking startup."""
|
|
||||||
|
|
||||||
# Validate security config (no-op in test mode)
|
|
||||||
from config import validate_startup
|
from config import validate_startup
|
||||||
|
|
||||||
validate_startup()
|
validate_startup()
|
||||||
|
|
||||||
# Enable event persistence (unified EventBus + swarm event_log)
|
|
||||||
from infrastructure.events.bus import init_event_bus_persistence
|
from infrastructure.events.bus import init_event_bus_persistence
|
||||||
|
|
||||||
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
|
from spark.engine import get_spark_engine
|
||||||
|
|
||||||
if get_spark_engine().enabled:
|
if get_spark_engine().enabled:
|
||||||
logger.info("Spark Intelligence active — event capture 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:
|
if settings.memory_prune_days > 0:
|
||||||
try:
|
try:
|
||||||
from timmy.memory.vector_store import prune_memories
|
from timmy.memory_system import prune_memories
|
||||||
|
|
||||||
pruned = prune_memories(
|
pruned = prune_memories(
|
||||||
older_than_days=settings.memory_prune_days,
|
older_than_days=settings.memory_prune_days,
|
||||||
@@ -320,7 +395,6 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Memory auto-prune skipped: %s", exc)
|
logger.debug("Memory auto-prune skipped: %s", exc)
|
||||||
|
|
||||||
# Auto-prune old thoughts on startup
|
|
||||||
if settings.thoughts_prune_days > 0:
|
if settings.thoughts_prune_days > 0:
|
||||||
try:
|
try:
|
||||||
from timmy.thinking import thinking_engine
|
from timmy.thinking import thinking_engine
|
||||||
@@ -338,7 +412,6 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Thought auto-prune skipped: %s", exc)
|
logger.debug("Thought auto-prune skipped: %s", exc)
|
||||||
|
|
||||||
# Auto-prune old system events on startup
|
|
||||||
if settings.events_prune_days > 0:
|
if settings.events_prune_days > 0:
|
||||||
try:
|
try:
|
||||||
from swarm.event_log import prune_old_events
|
from swarm.event_log import prune_old_events
|
||||||
@@ -356,7 +429,6 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Event auto-prune skipped: %s", exc)
|
logger.debug("Event auto-prune skipped: %s", exc)
|
||||||
|
|
||||||
# Warn if memory vault exceeds size limit
|
|
||||||
if settings.memory_vault_max_mb > 0:
|
if settings.memory_vault_max_mb > 0:
|
||||||
try:
|
try:
|
||||||
vault_path = Path(settings.repo_root) / "memory" / "notes"
|
vault_path = Path(settings.repo_root) / "memory" / "notes"
|
||||||
@@ -372,21 +444,18 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Vault size check skipped: %s", 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")
|
async def _shutdown_cleanup(
|
||||||
|
bg_tasks: list[asyncio.Task],
|
||||||
yield
|
workshop_heartbeat,
|
||||||
|
) -> None:
|
||||||
# Cleanup on shutdown
|
"""Stop chat bots, MCP sessions, heartbeat, and cancel background tasks."""
|
||||||
from integrations.chat_bridge.vendors.discord import discord_bot
|
from integrations.chat_bridge.vendors.discord import discord_bot
|
||||||
from integrations.telegram_bot.bot import telegram_bot
|
from integrations.telegram_bot.bot import telegram_bot
|
||||||
|
|
||||||
await discord_bot.stop()
|
await discord_bot.stop()
|
||||||
await telegram_bot.stop()
|
await telegram_bot.stop()
|
||||||
|
|
||||||
# Close MCP tool server sessions
|
|
||||||
try:
|
try:
|
||||||
from timmy.mcp_tools import close_mcp_sessions
|
from timmy.mcp_tools import close_mcp_sessions
|
||||||
|
|
||||||
@@ -394,13 +463,44 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("MCP shutdown: %s", exc)
|
logger.debug("MCP shutdown: %s", exc)
|
||||||
|
|
||||||
for task in [briefing_task, thinking_task, chat_task, loop_qa_task]:
|
await workshop_heartbeat.stop()
|
||||||
if task:
|
|
||||||
task.cancel()
|
for task in bg_tasks:
|
||||||
try:
|
task.cancel()
|
||||||
await task
|
try:
|
||||||
except asyncio.CancelledError:
|
await task
|
||||||
pass
|
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(
|
app = FastAPI(
|
||||||
@@ -413,15 +513,14 @@ app = FastAPI(
|
|||||||
|
|
||||||
|
|
||||||
def _get_cors_origins() -> list[str]:
|
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
|
origins = settings.cors_origins
|
||||||
if settings.debug and origins == ["*"]:
|
if "*" in origins and not settings.debug:
|
||||||
return [
|
logger.warning(
|
||||||
"http://localhost:3000",
|
"Wildcard '*' in CORS_ORIGINS stripped in production — "
|
||||||
"http://localhost:8000",
|
"set explicit origins via CORS_ORIGINS env var"
|
||||||
"http://127.0.0.1:3000",
|
)
|
||||||
"http://127.0.0.1:8000",
|
origins = [o for o in origins if o != "*"]
|
||||||
]
|
|
||||||
return origins
|
return origins
|
||||||
|
|
||||||
|
|
||||||
@@ -474,6 +573,7 @@ app.include_router(grok_router)
|
|||||||
app.include_router(models_router)
|
app.include_router(models_router)
|
||||||
app.include_router(models_api_router)
|
app.include_router(models_api_router)
|
||||||
app.include_router(chat_api_router)
|
app.include_router(chat_api_router)
|
||||||
|
app.include_router(chat_api_v1_router)
|
||||||
app.include_router(thinking_router)
|
app.include_router(thinking_router)
|
||||||
app.include_router(calm_router)
|
app.include_router(calm_router)
|
||||||
app.include_router(tasks_router)
|
app.include_router(tasks_router)
|
||||||
@@ -482,6 +582,7 @@ app.include_router(loop_qa_router)
|
|||||||
app.include_router(system_router)
|
app.include_router(system_router)
|
||||||
app.include_router(experiments_router)
|
app.include_router(experiments_router)
|
||||||
app.include_router(db_explorer_router)
|
app.include_router(db_explorer_router)
|
||||||
|
app.include_router(world_router)
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
@@ -510,7 +611,8 @@ async def swarm_live(websocket: WebSocket):
|
|||||||
while True:
|
while True:
|
||||||
# Keep connection alive; events are pushed via ws_mgr.broadcast()
|
# Keep connection alive; events are pushed via ws_mgr.broadcast()
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.debug("WebSocket disconnect error: %s", exc)
|
||||||
ws_mgr.disconnect(websocket)
|
ws_mgr.disconnect(websocket)
|
||||||
|
|
||||||
|
|
||||||
@@ -532,7 +634,8 @@ async def swarm_agents_sidebar():
|
|||||||
f"</div>"
|
f"</div>"
|
||||||
)
|
)
|
||||||
return "\n".join(lines) if lines else '<div class="mc-muted">No agents configured</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>'
|
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 hmac
|
||||||
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -16,6 +17,8 @@ from starlette.responses import JSONResponse, Response
|
|||||||
# Module-level set to track exempt routes
|
# Module-level set to track exempt routes
|
||||||
_exempt_routes: set[str] = set()
|
_exempt_routes: set[str] = set()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def csrf_exempt(endpoint: Callable) -> Callable:
|
def csrf_exempt(endpoint: Callable) -> Callable:
|
||||||
"""Decorator to mark an endpoint as exempt from CSRF validation.
|
"""Decorator to mark an endpoint as exempt from CSRF validation.
|
||||||
@@ -97,7 +100,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
...
|
...
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
app.add_middleware(CSRFMiddleware, secret="your-secret-key")
|
app.add_middleware(CSRFMiddleware, secret=settings.csrf_secret)
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
secret: Secret key for token signing (optional, for future use).
|
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)
|
form_token = form_data.get(self.form_field)
|
||||||
if form_token and validate_csrf_token(str(form_token), csrf_cookie):
|
if form_token and validate_csrf_token(str(form_token), csrf_cookie):
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.debug("CSRF form parsing error: %s", exc)
|
||||||
# Error parsing form data, treat as invalid
|
# Error parsing form data, treat as invalid
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
"duration_ms": f"{duration_ms:.0f}",
|
"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
|
pass # never let escalation break the request
|
||||||
|
|
||||||
# Re-raise the exception
|
# Re-raise the exception
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ Adds common security headers to all HTTP responses to improve
|
|||||||
application security posture against various attacks.
|
application security posture against various attacks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
"""Middleware to add security headers to all responses.
|
"""Middleware to add security headers to all responses.
|
||||||
@@ -130,12 +134,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
import logging
|
logger.debug("Upstream error in security headers middleware: %s", exc)
|
||||||
|
|
||||||
logging.getLogger(__name__).debug(
|
|
||||||
"Upstream error in security headers middleware", exc_info=True
|
|
||||||
)
|
|
||||||
from starlette.responses import PlainTextResponse
|
from starlette.responses import PlainTextResponse
|
||||||
|
|
||||||
response = PlainTextResponse("Internal Server Error", status_code=500)
|
response = PlainTextResponse("Internal Server Error", status_code=500)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from timmy.tool_safety import (
|
|||||||
format_action_description,
|
format_action_description,
|
||||||
get_impact_level,
|
get_impact_level,
|
||||||
)
|
)
|
||||||
|
from timmy.welcome import WELCOME_MESSAGE
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ async def get_history(request: Request):
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"partials/history.html",
|
"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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"partials/history.html",
|
"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)
|
@router.post("/default/chat", response_class=HTMLResponse)
|
||||||
async def chat_agent(request: Request, message: str = Form(...)):
|
async def chat_agent(request: Request, message: str = Form(...)):
|
||||||
"""Chat — synchronous response with native Agno tool confirmation."""
|
"""Chat — synchronous response with native Agno tool confirmation."""
|
||||||
message = message.strip()
|
message = _validate_message(message)
|
||||||
if not message:
|
_record_user_activity()
|
||||||
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")
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
response_text = None
|
response_text = None
|
||||||
@@ -95,54 +164,15 @@ async def chat_agent(request: Request, message: str = Form(...)):
|
|||||||
error_text = f"Chat error: {exc}"
|
error_text = f"Chat error: {exc}"
|
||||||
run_output = None
|
run_output = None
|
||||||
|
|
||||||
# Check if Agno paused the run for tool confirmation
|
tool_actions: list[dict] = []
|
||||||
tool_actions = []
|
|
||||||
if run_output is not None:
|
if run_output is not None:
|
||||||
status = getattr(run_output, "status", None)
|
tool_actions = _extract_tool_actions(run_output)
|
||||||
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),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_content = run_output.content if hasattr(run_output, "content") else ""
|
raw_content = run_output.content if hasattr(run_output, "content") else ""
|
||||||
response_text = _clean_response(raw_content or "")
|
response_text = _clean_response(raw_content or "")
|
||||||
if not response_text and not tool_actions:
|
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")
|
_log_exchange(message, response_text, error_text, timestamp)
|
||||||
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")
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -220,7 +250,8 @@ async def reject_tool(request: Request, approval_id: str):
|
|||||||
# Resume so the agent knows the tool was rejected
|
# Resume so the agent knows the tool was rejected
|
||||||
try:
|
try:
|
||||||
await continue_chat(pending["run_output"])
|
await continue_chat(pending["run_output"])
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning("Agent tool rejection error: %s", exc)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
reject(approval_id)
|
reject(approval_id)
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ async def get_briefing(request: Request):
|
|||||||
"""Return today's briefing page (generated or cached)."""
|
"""Return today's briefing page (generated or cached)."""
|
||||||
try:
|
try:
|
||||||
briefing = briefing_engine.get_or_generate()
|
briefing = briefing_engine.get_or_generate()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.debug("Briefing generation failed: %s", exc)
|
||||||
logger.exception("Briefing generation failed")
|
logger.exception("Briefing generation failed")
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
briefing = Briefing(
|
briefing = Briefing(
|
||||||
|
|||||||
@@ -19,14 +19,17 @@ router = APIRouter(tags=["calm"])
|
|||||||
|
|
||||||
# Helper functions for state machine logic
|
# Helper functions for state machine logic
|
||||||
def get_now_task(db: Session) -> Task | None:
|
def get_now_task(db: Session) -> Task | None:
|
||||||
|
"""Return the single active NOW task, or None."""
|
||||||
return db.query(Task).filter(Task.state == TaskState.NOW).first()
|
return db.query(Task).filter(Task.state == TaskState.NOW).first()
|
||||||
|
|
||||||
|
|
||||||
def get_next_task(db: Session) -> Task | None:
|
def get_next_task(db: Session) -> Task | None:
|
||||||
|
"""Return the single queued NEXT task, or None."""
|
||||||
return db.query(Task).filter(Task.state == TaskState.NEXT).first()
|
return db.query(Task).filter(Task.state == TaskState.NEXT).first()
|
||||||
|
|
||||||
|
|
||||||
def get_later_tasks(db: Session) -> list[Task]:
|
def get_later_tasks(db: Session) -> list[Task]:
|
||||||
|
"""Return all LATER tasks ordered by MIT flag then sort_order."""
|
||||||
return (
|
return (
|
||||||
db.query(Task)
|
db.query(Task)
|
||||||
.filter(Task.state == TaskState.LATER)
|
.filter(Task.state == TaskState.LATER)
|
||||||
@@ -36,6 +39,12 @@ def get_later_tasks(db: Session) -> list[Task]:
|
|||||||
|
|
||||||
|
|
||||||
def promote_tasks(db: Session):
|
def promote_tasks(db: Session):
|
||||||
|
"""Enforce the NOW/NEXT/LATER state machine invariants.
|
||||||
|
|
||||||
|
- At most one NOW task (extras demoted to NEXT).
|
||||||
|
- If no NOW, promote NEXT -> NOW.
|
||||||
|
- If no NEXT, promote highest-priority LATER -> NEXT.
|
||||||
|
"""
|
||||||
# Ensure only one NOW task exists. If multiple, demote extras to NEXT.
|
# Ensure only one NOW task exists. If multiple, demote extras to NEXT.
|
||||||
now_tasks = db.query(Task).filter(Task.state == TaskState.NOW).all()
|
now_tasks = db.query(Task).filter(Task.state == TaskState.NOW).all()
|
||||||
if len(now_tasks) > 1:
|
if len(now_tasks) > 1:
|
||||||
@@ -74,6 +83,7 @@ def promote_tasks(db: Session):
|
|||||||
# Endpoints
|
# Endpoints
|
||||||
@router.get("/calm", response_class=HTMLResponse)
|
@router.get("/calm", response_class=HTMLResponse)
|
||||||
async def get_calm_view(request: Request, db: Session = Depends(get_db)):
|
async def get_calm_view(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Render the main CALM dashboard with NOW/NEXT/LATER counts."""
|
||||||
now_task = get_now_task(db)
|
now_task = get_now_task(db)
|
||||||
next_task = get_next_task(db)
|
next_task = get_next_task(db)
|
||||||
later_tasks_count = len(get_later_tasks(db))
|
later_tasks_count = len(get_later_tasks(db))
|
||||||
@@ -90,6 +100,7 @@ async def get_calm_view(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/calm/ritual/morning", response_class=HTMLResponse)
|
@router.get("/calm/ritual/morning", response_class=HTMLResponse)
|
||||||
async def get_morning_ritual_form(request: Request):
|
async def get_morning_ritual_form(request: Request):
|
||||||
|
"""Render the morning ritual intake form."""
|
||||||
return templates.TemplateResponse(request, "calm/morning_ritual_form.html", {})
|
return templates.TemplateResponse(request, "calm/morning_ritual_form.html", {})
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +113,7 @@ async def post_morning_ritual(
|
|||||||
mit3_title: str = Form(None),
|
mit3_title: str = Form(None),
|
||||||
other_tasks: str = Form(""),
|
other_tasks: str = Form(""),
|
||||||
):
|
):
|
||||||
|
"""Process morning ritual: create MITs, other tasks, and set initial states."""
|
||||||
# Create Journal Entry
|
# Create Journal Entry
|
||||||
mit_task_ids = []
|
mit_task_ids = []
|
||||||
journal_entry = JournalEntry(entry_date=date.today())
|
journal_entry = JournalEntry(entry_date=date.today())
|
||||||
@@ -173,6 +185,7 @@ async def post_morning_ritual(
|
|||||||
|
|
||||||
@router.get("/calm/ritual/evening", response_class=HTMLResponse)
|
@router.get("/calm/ritual/evening", response_class=HTMLResponse)
|
||||||
async def get_evening_ritual_form(request: Request, db: Session = Depends(get_db)):
|
async def get_evening_ritual_form(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Render the evening ritual form for today's journal entry."""
|
||||||
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
|
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
|
||||||
if not journal_entry:
|
if not journal_entry:
|
||||||
raise HTTPException(status_code=404, detail="No journal entry for today")
|
raise HTTPException(status_code=404, detail="No journal entry for today")
|
||||||
@@ -189,6 +202,7 @@ async def post_evening_ritual(
|
|||||||
gratitude: str = Form(None),
|
gratitude: str = Form(None),
|
||||||
energy_level: int = Form(None),
|
energy_level: int = Form(None),
|
||||||
):
|
):
|
||||||
|
"""Process evening ritual: save reflection/gratitude, archive active tasks."""
|
||||||
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
|
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
|
||||||
if not journal_entry:
|
if not journal_entry:
|
||||||
raise HTTPException(status_code=404, detail="No journal entry for today")
|
raise HTTPException(status_code=404, detail="No journal entry for today")
|
||||||
@@ -223,6 +237,7 @@ async def create_new_task(
|
|||||||
is_mit: bool = Form(False),
|
is_mit: bool = Form(False),
|
||||||
certainty: TaskCertainty = Form(TaskCertainty.SOFT),
|
certainty: TaskCertainty = Form(TaskCertainty.SOFT),
|
||||||
):
|
):
|
||||||
|
"""Create a new task in LATER state and return updated count."""
|
||||||
task = Task(
|
task = Task(
|
||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -247,6 +262,7 @@ async def start_task(
|
|||||||
task_id: int,
|
task_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""Move a task to NOW state, demoting the current NOW to NEXT."""
|
||||||
current_now_task = get_now_task(db)
|
current_now_task = get_now_task(db)
|
||||||
if current_now_task and current_now_task.id != task_id:
|
if current_now_task and current_now_task.id != task_id:
|
||||||
current_now_task.state = TaskState.NEXT # Demote current NOW to NEXT
|
current_now_task.state = TaskState.NEXT # Demote current NOW to NEXT
|
||||||
@@ -281,6 +297,7 @@ async def complete_task(
|
|||||||
task_id: int,
|
task_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""Mark a task as DONE and trigger state promotion."""
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
@@ -309,6 +326,7 @@ async def defer_task(
|
|||||||
task_id: int,
|
task_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""Defer a task and trigger state promotion."""
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
@@ -333,6 +351,7 @@ async def defer_task(
|
|||||||
|
|
||||||
@router.get("/calm/partials/later_tasks_list", response_class=HTMLResponse)
|
@router.get("/calm/partials/later_tasks_list", response_class=HTMLResponse)
|
||||||
async def get_later_tasks_list(request: Request, db: Session = Depends(get_db)):
|
async def get_later_tasks_list(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Render the expandable list of LATER tasks."""
|
||||||
later_tasks = get_later_tasks(db)
|
later_tasks = get_later_tasks(db)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"calm/partials/later_tasks_list.html",
|
"calm/partials/later_tasks_list.html",
|
||||||
@@ -348,6 +367,7 @@ async def reorder_tasks(
|
|||||||
later_task_ids: str = Form(""),
|
later_task_ids: str = Form(""),
|
||||||
next_task_id: int | None = Form(None),
|
next_task_id: int | None = Form(None),
|
||||||
):
|
):
|
||||||
|
"""Reorder LATER tasks and optionally promote one to NEXT."""
|
||||||
# Reorder LATER tasks
|
# Reorder LATER tasks
|
||||||
if later_task_ids:
|
if later_task_ids:
|
||||||
ids_in_order = [int(x.strip()) for x in later_task_ids.split(",") if x.strip()]
|
ids_in_order = [int(x.strip()) for x in later_task_ids.split(",") if x.strip()]
|
||||||
|
|||||||
@@ -31,6 +31,93 @@ _UPLOAD_DIR = str(Path(settings.repo_root) / "data" / "chat-uploads")
|
|||||||
_MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB
|
_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 ────────────────────────────────────────────────────────────
|
# ── POST /api/chat ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -44,69 +131,15 @@ async def api_chat(request: Request):
|
|||||||
Response:
|
Response:
|
||||||
{"reply": "...", "timestamp": "HH:MM:SS"}
|
{"reply": "...", "timestamp": "HH:MM:SS"}
|
||||||
"""
|
"""
|
||||||
# Enforce request body size limit
|
body, err = await _parse_chat_body(request)
|
||||||
content_length = request.headers.get("content-length")
|
if err:
|
||||||
if content_length and int(content_length) > settings.chat_api_max_body_bytes:
|
return err
|
||||||
return JSONResponse(status_code=413, content={"error": "Request body too large"})
|
|
||||||
|
|
||||||
try:
|
user_msg = _extract_user_message(body["messages"])
|
||||||
body = await request.json()
|
if not user_msg:
|
||||||
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:
|
|
||||||
return JSONResponse(status_code=400, content={"error": "No user message found"})
|
return JSONResponse(status_code=400, content={"error": "No user message found"})
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
return await _process_chat(user_msg)
|
||||||
|
|
||||||
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},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── POST /api/upload ──────────────────────────────────────────────────────────
|
# ── 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 asyncio
|
||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
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."""
|
"""Open a database read-only and return all tables with their rows."""
|
||||||
result = {"tables": {}, "error": None}
|
result = {"tables": {}, "error": None}
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
except Exception as exc:
|
|
||||||
result["error"] = str(exc)
|
|
||||||
return result
|
|
||||||
|
|
||||||
try:
|
tables = conn.execute(
|
||||||
tables = conn.execute(
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
).fetchall()
|
||||||
).fetchall()
|
for (table_name,) in tables:
|
||||||
for (table_name,) in tables:
|
try:
|
||||||
try:
|
rows = conn.execute(
|
||||||
rows = conn.execute(
|
f"SELECT * FROM [{table_name}] LIMIT {MAX_ROWS}" # noqa: S608
|
||||||
f"SELECT * FROM [{table_name}] LIMIT {MAX_ROWS}" # noqa: S608
|
).fetchall()
|
||||||
).fetchall()
|
columns = (
|
||||||
columns = (
|
[
|
||||||
[
|
desc[0]
|
||||||
desc[0]
|
for desc in conn.execute(
|
||||||
for desc in conn.execute(
|
f"SELECT * FROM [{table_name}] LIMIT 0"
|
||||||
f"SELECT * FROM [{table_name}] LIMIT 0"
|
).description
|
||||||
).description
|
]
|
||||||
]
|
if rows
|
||||||
if rows
|
else []
|
||||||
else []
|
) # noqa: S608
|
||||||
) # noqa: S608
|
if not columns and rows:
|
||||||
if not columns and rows:
|
columns = list(rows[0].keys())
|
||||||
columns = list(rows[0].keys())
|
elif not columns:
|
||||||
elif not columns:
|
# Get columns even for empty tables
|
||||||
# Get columns even for empty tables
|
cursor = conn.execute(f"PRAGMA table_info([{table_name}])") # noqa: S608
|
||||||
cursor = conn.execute(f"PRAGMA table_info([{table_name}])") # noqa: S608
|
columns = [r[1] for r in cursor.fetchall()]
|
||||||
columns = [r[1] for r in cursor.fetchall()]
|
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0] # noqa: S608
|
||||||
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0] # noqa: S608
|
result["tables"][table_name] = {
|
||||||
result["tables"][table_name] = {
|
"columns": columns,
|
||||||
"columns": columns,
|
"rows": [dict(r) for r in rows],
|
||||||
"rows": [dict(r) for r in rows],
|
"total_count": count,
|
||||||
"total_count": count,
|
"truncated": count > MAX_ROWS,
|
||||||
"truncated": count > MAX_ROWS,
|
}
|
||||||
}
|
except Exception as exc:
|
||||||
except Exception as exc:
|
result["tables"][table_name] = {
|
||||||
result["tables"][table_name] = {
|
"error": str(exc),
|
||||||
"error": str(exc),
|
"columns": [],
|
||||||
"columns": [],
|
"rows": [],
|
||||||
"rows": [],
|
"total_count": 0,
|
||||||
"total_count": 0,
|
"truncated": False,
|
||||||
"truncated": False,
|
}
|
||||||
}
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result["error"] = str(exc)
|
result["error"] = str(exc)
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ async def experiments_page(request: Request):
|
|||||||
history = []
|
history = []
|
||||||
try:
|
try:
|
||||||
history = get_experiment_history(_workspace())
|
history = get_experiment_history(_workspace())
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.debug("Failed to load experiment history", exc_info=True)
|
logger.debug("Failed to load experiment history: %s", exc)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ async def grok_status(request: Request):
|
|||||||
"estimated_cost_sats": backend.stats.estimated_cost_sats,
|
"estimated_cost_sats": backend.stats.estimated_cost_sats,
|
||||||
"errors": backend.stats.errors,
|
"errors": backend.stats.errors,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.debug("Failed to load Grok stats", exc_info=True)
|
logger.warning("Failed to load Grok stats: %s", exc)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -94,8 +94,8 @@ async def toggle_grok_mode(request: Request):
|
|||||||
tool_name="grok_mode_toggle",
|
tool_name="grok_mode_toggle",
|
||||||
success=True,
|
success=True,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.debug("Failed to log Grok toggle to Spark", exc_info=True)
|
logger.warning("Failed to log Grok toggle to Spark: %s", exc)
|
||||||
|
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
_render_toggle_card(_grok_mode_active),
|
_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)
|
sats = min(settings.grok_max_sats_per_query, 100)
|
||||||
ln.create_invoice(sats, f"Grok: {message[:50]}")
|
ln.create_invoice(sats, f"Grok: {message[:50]}")
|
||||||
invoice_note = f" | {sats} sats"
|
invoice_note = f" | {sats} sats"
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.debug("Lightning invoice creation failed", exc_info=True)
|
logger.warning("Lightning invoice creation failed: %s", exc)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = backend.run(message)
|
result = backend.run(message)
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ for the Mission Control dashboard.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
from contextlib import closing
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from config import APP_START_TIME as _START_TIME
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -49,7 +53,6 @@ class HealthStatus(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Simple uptime tracking
|
# Simple uptime tracking
|
||||||
_START_TIME = datetime.now(UTC)
|
|
||||||
|
|
||||||
# Ollama health cache (30-second TTL)
|
# Ollama health cache (30-second TTL)
|
||||||
_ollama_cache: DependencyStatus | None = None
|
_ollama_cache: DependencyStatus | None = None
|
||||||
@@ -62,7 +65,7 @@ def _check_ollama_sync() -> DependencyStatus:
|
|||||||
try:
|
try:
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
url = settings.ollama_url.replace("localhost", "127.0.0.1")
|
url = settings.normalized_ollama_url
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{url}/api/tags",
|
f"{url}/api/tags",
|
||||||
method="GET",
|
method="GET",
|
||||||
@@ -76,8 +79,8 @@ def _check_ollama_sync() -> DependencyStatus:
|
|||||||
sovereignty_score=10,
|
sovereignty_score=10,
|
||||||
details={"url": settings.ollama_url, "model": settings.ollama_model},
|
details={"url": settings.ollama_url, "model": settings.ollama_model},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.debug("Ollama health check failed", exc_info=True)
|
logger.debug("Ollama health check failed: %s", exc)
|
||||||
|
|
||||||
return DependencyStatus(
|
return DependencyStatus(
|
||||||
name="Ollama AI",
|
name="Ollama AI",
|
||||||
@@ -101,7 +104,8 @@ async def _check_ollama() -> DependencyStatus:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(_check_ollama_sync)
|
result = await asyncio.to_thread(_check_ollama_sync)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.debug("Ollama async check failed: %s", exc)
|
||||||
result = DependencyStatus(
|
result = DependencyStatus(
|
||||||
name="Ollama AI",
|
name="Ollama AI",
|
||||||
status="unavailable",
|
status="unavailable",
|
||||||
@@ -133,13 +137,9 @@ def _check_lightning() -> DependencyStatus:
|
|||||||
def _check_sqlite() -> DependencyStatus:
|
def _check_sqlite() -> DependencyStatus:
|
||||||
"""Check SQLite database status."""
|
"""Check SQLite database status."""
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path(settings.repo_root) / "data" / "timmy.db"
|
db_path = Path(settings.repo_root) / "data" / "timmy.db"
|
||||||
conn = sqlite3.connect(str(db_path))
|
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||||
conn.execute("SELECT 1")
|
conn.execute("SELECT 1")
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return DependencyStatus(
|
return DependencyStatus(
|
||||||
name="SQLite Database",
|
name="SQLite Database",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Form, HTTPException, Request
|
|||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
from dashboard.templating import templates
|
from dashboard.templating import templates
|
||||||
from timmy.memory.vector_store import (
|
from timmy.memory_system import (
|
||||||
delete_memory,
|
delete_memory,
|
||||||
get_memory_stats,
|
get_memory_stats,
|
||||||
recall_personal_facts_with_ids,
|
recall_personal_facts_with_ids,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""System-level dashboard routes (ledger, upgrades, etc.)."""
|
"""System-level dashboard routes (ledger, upgrades, etc.)."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
|
from config import settings
|
||||||
from dashboard.templating import templates
|
from dashboard.templating import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -144,5 +146,83 @@ async def api_notifications():
|
|||||||
for e in events
|
for e in events
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.debug("System events fetch error: %s", exc)
|
||||||
return JSONResponse([])
|
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 logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import closing, contextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -35,26 +37,27 @@ VALID_STATUSES = {
|
|||||||
VALID_PRIORITIES = {"low", "normal", "high", "urgent"}
|
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)
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(str(DB_PATH))
|
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT DEFAULT '',
|
description TEXT DEFAULT '',
|
||||||
status TEXT DEFAULT 'pending_approval',
|
status TEXT DEFAULT 'pending_approval',
|
||||||
priority TEXT DEFAULT 'normal',
|
priority TEXT DEFAULT 'normal',
|
||||||
assigned_to TEXT DEFAULT '',
|
assigned_to TEXT DEFAULT '',
|
||||||
created_by TEXT DEFAULT 'operator',
|
created_by TEXT DEFAULT 'operator',
|
||||||
result TEXT DEFAULT '',
|
result TEXT DEFAULT '',
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
completed_at TEXT
|
completed_at TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
def _row_to_dict(row: sqlite3.Row) -> dict:
|
def _row_to_dict(row: sqlite3.Row) -> dict:
|
||||||
@@ -101,8 +104,7 @@ class _TaskView:
|
|||||||
@router.get("/tasks", response_class=HTMLResponse)
|
@router.get("/tasks", response_class=HTMLResponse)
|
||||||
async def tasks_page(request: Request):
|
async def tasks_page(request: Request):
|
||||||
"""Render the main task queue page with 3-column layout."""
|
"""Render the main task queue page with 3-column layout."""
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
pending = [
|
pending = [
|
||||||
_TaskView(_row_to_dict(r))
|
_TaskView(_row_to_dict(r))
|
||||||
for r in db.execute(
|
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"
|
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
]
|
]
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -145,13 +145,10 @@ async def tasks_page(request: Request):
|
|||||||
|
|
||||||
@router.get("/tasks/pending", response_class=HTMLResponse)
|
@router.get("/tasks/pending", response_class=HTMLResponse)
|
||||||
async def tasks_pending(request: Request):
|
async def tasks_pending(request: Request):
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC"
|
"SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
|
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
|
||||||
parts = []
|
parts = []
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
@@ -167,13 +164,10 @@ async def tasks_pending(request: Request):
|
|||||||
|
|
||||||
@router.get("/tasks/active", response_class=HTMLResponse)
|
@router.get("/tasks/active", response_class=HTMLResponse)
|
||||||
async def tasks_active(request: Request):
|
async def tasks_active(request: Request):
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
|
"SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
|
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
|
||||||
parts = []
|
parts = []
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
@@ -189,13 +183,10 @@ async def tasks_active(request: Request):
|
|||||||
|
|
||||||
@router.get("/tasks/completed", response_class=HTMLResponse)
|
@router.get("/tasks/completed", response_class=HTMLResponse)
|
||||||
async def tasks_completed(request: Request):
|
async def tasks_completed(request: Request):
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
|
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
|
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
|
||||||
parts = []
|
parts = []
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
@@ -231,16 +222,13 @@ async def create_task_form(
|
|||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
priority = priority if priority in VALID_PRIORITIES else "normal"
|
priority = priority if priority in VALID_PRIORITIES else "normal"
|
||||||
|
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO tasks (id, title, description, priority, assigned_to, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO tasks (id, title, description, priority, assigned_to, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
(task_id, title, description, priority, assigned_to, now),
|
(task_id, title, description, priority, assigned_to, now),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
task = _TaskView(_row_to_dict(row))
|
task = _TaskView(_row_to_dict(row))
|
||||||
return templates.TemplateResponse(request, "partials/task_card.html", {"task": task})
|
return templates.TemplateResponse(request, "partials/task_card.html", {"task": task})
|
||||||
@@ -283,16 +271,13 @@ async def modify_task(
|
|||||||
title: str = Form(...),
|
title: str = Form(...),
|
||||||
description: str = Form(""),
|
description: str = Form(""),
|
||||||
):
|
):
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE tasks SET title=?, description=? WHERE id=?",
|
"UPDATE tasks SET title=?, description=? WHERE id=?",
|
||||||
(title, description, task_id),
|
(title, description, task_id),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
task = _TaskView(_row_to_dict(row))
|
task = _TaskView(_row_to_dict(row))
|
||||||
@@ -304,16 +289,13 @@ async def _set_status(request: Request, task_id: str, new_status: str):
|
|||||||
completed_at = (
|
completed_at = (
|
||||||
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
||||||
)
|
)
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
|
"UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
|
||||||
(new_status, completed_at, task_id),
|
(new_status, completed_at, task_id),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
task = _TaskView(_row_to_dict(row))
|
task = _TaskView(_row_to_dict(row))
|
||||||
@@ -339,8 +321,7 @@ async def api_create_task(request: Request):
|
|||||||
if priority not in VALID_PRIORITIES:
|
if priority not in VALID_PRIORITIES:
|
||||||
priority = "normal"
|
priority = "normal"
|
||||||
|
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, created_at) "
|
"INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, created_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
@@ -356,8 +337,6 @@ async def api_create_task(request: Request):
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return JSONResponse(_row_to_dict(row), status_code=201)
|
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)
|
@router.get("/api/tasks", response_class=JSONResponse)
|
||||||
async def api_list_tasks():
|
async def api_list_tasks():
|
||||||
"""List all tasks as JSON."""
|
"""List all tasks as JSON."""
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
rows = db.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall()
|
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])
|
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 = (
|
completed_at = (
|
||||||
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
||||||
)
|
)
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
|
"UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
|
||||||
(new_status, completed_at, task_id),
|
(new_status, completed_at, task_id),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
return JSONResponse(_row_to_dict(row))
|
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)
|
@router.delete("/api/tasks/{task_id}", response_class=JSONResponse)
|
||||||
async def api_delete_task(task_id: str):
|
async def api_delete_task(task_id: str):
|
||||||
"""Delete a task."""
|
"""Delete a task."""
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
cursor = db.execute("DELETE FROM tasks WHERE id=?", (task_id,))
|
cursor = db.execute("DELETE FROM tasks WHERE id=?", (task_id,))
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
if cursor.rowcount == 0:
|
if cursor.rowcount == 0:
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
return JSONResponse({"success": True, "id": task_id})
|
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)
|
@router.get("/api/queue/status", response_class=JSONResponse)
|
||||||
async def queue_status(assigned_to: str = "default"):
|
async def queue_status(assigned_to: str = "default"):
|
||||||
"""Return queue status for the chat panel's agent status indicator."""
|
"""Return queue status for the chat panel's agent status indicator."""
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
running = db.execute(
|
running = db.execute(
|
||||||
"SELECT * FROM tasks WHERE status='running' AND assigned_to=? LIMIT 1",
|
"SELECT * FROM tasks WHERE status='running' AND assigned_to=? LIMIT 1",
|
||||||
(assigned_to,),
|
(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=?",
|
"SELECT COUNT(*) as cnt FROM tasks WHERE status IN ('pending_approval','approved') AND assigned_to=?",
|
||||||
(assigned_to,),
|
(assigned_to,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if running:
|
if running:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ async def tts_status():
|
|||||||
"available": voice_tts.available,
|
"available": voice_tts.available,
|
||||||
"voices": voice_tts.get_voices() if voice_tts.available else [],
|
"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": []}
|
return {"available": False, "voices": []}
|
||||||
|
|
||||||
|
|
||||||
@@ -139,7 +140,8 @@ async def process_voice_input(
|
|||||||
|
|
||||||
if voice_tts.available:
|
if voice_tts.available:
|
||||||
voice_tts.speak(response_text)
|
voice_tts.speak(response_text)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.debug("Voice TTS error: %s", exc)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import closing, contextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -23,28 +25,29 @@ CATEGORIES = ["bug", "feature", "suggestion", "maintenance", "security"]
|
|||||||
VALID_STATUSES = {"submitted", "triaged", "approved", "in_progress", "completed", "rejected"}
|
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)
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(str(DB_PATH))
|
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS work_orders (
|
CREATE TABLE IF NOT EXISTS work_orders (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT DEFAULT '',
|
description TEXT DEFAULT '',
|
||||||
priority TEXT DEFAULT 'medium',
|
priority TEXT DEFAULT 'medium',
|
||||||
category TEXT DEFAULT 'suggestion',
|
category TEXT DEFAULT 'suggestion',
|
||||||
submitter TEXT DEFAULT 'dashboard',
|
submitter TEXT DEFAULT 'dashboard',
|
||||||
related_files TEXT DEFAULT '',
|
related_files TEXT DEFAULT '',
|
||||||
status TEXT DEFAULT 'submitted',
|
status TEXT DEFAULT 'submitted',
|
||||||
result TEXT DEFAULT '',
|
result TEXT DEFAULT '',
|
||||||
rejection_reason TEXT DEFAULT '',
|
rejection_reason TEXT DEFAULT '',
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
completed_at TEXT
|
completed_at TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
class _EnumLike:
|
class _EnumLike:
|
||||||
@@ -104,14 +107,11 @@ def _query_wos(db, statuses):
|
|||||||
|
|
||||||
@router.get("/work-orders/queue", response_class=HTMLResponse)
|
@router.get("/work-orders/queue", response_class=HTMLResponse)
|
||||||
async def work_orders_page(request: Request):
|
async def work_orders_page(request: Request):
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
pending = _query_wos(db, ["submitted", "triaged"])
|
pending = _query_wos(db, ["submitted", "triaged"])
|
||||||
active = _query_wos(db, ["approved", "in_progress"])
|
active = _query_wos(db, ["approved", "in_progress"])
|
||||||
completed = _query_wos(db, ["completed"])
|
completed = _query_wos(db, ["completed"])
|
||||||
rejected = _query_wos(db, ["rejected"])
|
rejected = _query_wos(db, ["rejected"])
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -148,8 +148,7 @@ async def submit_work_order(
|
|||||||
priority = priority if priority in PRIORITIES else "medium"
|
priority = priority if priority in PRIORITIES else "medium"
|
||||||
category = category if category in CATEGORIES else "suggestion"
|
category = category if category in CATEGORIES else "suggestion"
|
||||||
|
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO work_orders (id, title, description, priority, category, submitter, related_files, created_at) "
|
"INSERT INTO work_orders (id, title, description, priority, category, submitter, related_files, created_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
@@ -157,8 +156,6 @@ async def submit_work_order(
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone()
|
row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
wo = _WOView(_row_to_dict(row))
|
wo = _WOView(_row_to_dict(row))
|
||||||
return templates.TemplateResponse(request, "partials/work_order_card.html", {"wo": wo})
|
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)
|
@router.get("/work-orders/queue/pending", response_class=HTMLResponse)
|
||||||
async def pending_partial(request: Request):
|
async def pending_partial(request: Request):
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
wos = _query_wos(db, ["submitted", "triaged"])
|
wos = _query_wos(db, ["submitted", "triaged"])
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
if not wos:
|
if not wos:
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
'<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">'
|
'<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)
|
@router.get("/work-orders/queue/active", response_class=HTMLResponse)
|
||||||
async def active_partial(request: Request):
|
async def active_partial(request: Request):
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
wos = _query_wos(db, ["approved", "in_progress"])
|
wos = _query_wos(db, ["approved", "in_progress"])
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
if not wos:
|
if not wos:
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
'<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">'
|
'<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 = (
|
completed_at = (
|
||||||
datetime.utcnow().isoformat() if new_status in ("completed", "rejected") else None
|
datetime.utcnow().isoformat() if new_status in ("completed", "rejected") else None
|
||||||
)
|
)
|
||||||
db = _get_db()
|
with _get_db() as db:
|
||||||
try:
|
|
||||||
sets = ["status=?", "completed_at=COALESCE(?, completed_at)"]
|
sets = ["status=?", "completed_at=COALESCE(?, completed_at)"]
|
||||||
vals = [new_status, completed_at]
|
vals = [new_status, completed_at]
|
||||||
for col, val in extra.items():
|
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.execute(f"UPDATE work_orders SET {', '.join(sets)} WHERE id=?", vals)
|
||||||
db.commit()
|
db.commit()
|
||||||
row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone()
|
row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Work order not found")
|
raise HTTPException(404, "Work order not found")
|
||||||
wo = _WOView(_row_to_dict(row))
|
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
|
from infrastructure.chat_store import DB_PATH, MAX_MESSAGES, Message, MessageLog, message_log
|
||||||
(dashboard routes, chat_api, thinking, briefing) work without changes.
|
|
||||||
|
|
||||||
Data lives in ``data/chat.db`` — survives server restarts.
|
__all__ = ["DB_PATH", "MAX_MESSAGES", "Message", "MessageLog", "message_log"]
|
||||||
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()
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="chat-message agent">
|
<div class="chat-message agent">
|
||||||
<div class="msg-meta">TIMMY // SYSTEM</div>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script>if(typeof scrollChat==='function'){setTimeout(scrollChat,50);}</script>
|
<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
|
# In-memory dedup cache: hash -> last_seen timestamp
|
||||||
_dedup_cache: dict[str, datetime] = {}
|
_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:
|
def _stack_hash(exc: Exception) -> str:
|
||||||
"""Create a stable hash of the exception type + traceback locations.
|
"""Create a stable hash of the exception type + traceback locations.
|
||||||
@@ -87,40 +95,19 @@ def _get_git_context() -> dict:
|
|||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
|
|
||||||
return {"branch": branch, "commit": commit}
|
return {"branch": branch, "commit": commit}
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning("Git info capture error: %s", exc)
|
||||||
return {"branch": "unknown", "commit": "unknown"}
|
return {"branch": "unknown", "commit": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
def capture_error(
|
def _extract_traceback_info(exc: Exception) -> tuple[str, str, int]:
|
||||||
exc: Exception,
|
"""Extract formatted traceback, affected file, and line number.
|
||||||
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:
|
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__))
|
tb_str = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||||
|
|
||||||
# Extract file/line from traceback
|
|
||||||
tb_obj = exc.__traceback__
|
tb_obj = exc.__traceback__
|
||||||
affected_file = "unknown"
|
affected_file = "unknown"
|
||||||
affected_line = 0
|
affected_line = 0
|
||||||
@@ -130,9 +117,18 @@ def capture_error(
|
|||||||
affected_file = tb_obj.tb_frame.f_code.co_filename
|
affected_file = tb_obj.tb_frame.f_code.co_filename
|
||||||
affected_line = tb_obj.tb_lineno
|
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:
|
try:
|
||||||
from swarm.event_log import EventType, log_event
|
from swarm.event_log import EventType, log_event
|
||||||
|
|
||||||
@@ -152,8 +148,18 @@ def capture_error(
|
|||||||
except Exception as log_exc:
|
except Exception as log_exc:
|
||||||
logger.debug("Failed to log error event: %s", 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:
|
try:
|
||||||
from swarm.task_queue.models import create_task
|
from swarm.task_queue.models import create_task
|
||||||
|
|
||||||
@@ -186,7 +192,6 @@ def capture_error(
|
|||||||
)
|
)
|
||||||
task_id = task.id
|
task_id = task.id
|
||||||
|
|
||||||
# Log the creation event
|
|
||||||
try:
|
try:
|
||||||
from swarm.event_log import EventType, log_event
|
from swarm.event_log import EventType, log_event
|
||||||
|
|
||||||
@@ -199,13 +204,18 @@ def capture_error(
|
|||||||
"title": title[:100],
|
"title": title[:100],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
logger.warning("Bug report screenshot error: %s", exc)
|
||||||
|
|
||||||
|
return task_id
|
||||||
|
|
||||||
except Exception as task_exc:
|
except Exception as task_exc:
|
||||||
logger.debug("Failed to create bug report task: %s", 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:
|
try:
|
||||||
from infrastructure.notifications.push import notifier
|
from infrastructure.notifications.push import notifier
|
||||||
|
|
||||||
@@ -214,19 +224,65 @@ def capture_error(
|
|||||||
message=f"{type(exc).__name__} in {source}: {str(exc)[:80]}",
|
message=f"{type(exc).__name__} in {source}: {str(exc)[:80]}",
|
||||||
category="system",
|
category="system",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as notify_exc:
|
||||||
pass
|
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()
|
def _record_to_session(exc: Exception, source: str) -> None:
|
||||||
session_logger.record_error(
|
"""Record the error via the registered session callback."""
|
||||||
error=f"{type(exc).__name__}: {str(exc)}",
|
if _error_recorder is not None:
|
||||||
context=source,
|
try:
|
||||||
)
|
_error_recorder(
|
||||||
except Exception:
|
error=f"{type(exc).__name__}: {str(exc)}",
|
||||||
pass
|
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
|
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 json
|
||||||
import logging
|
import logging
|
||||||
import sqlite3
|
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 dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -99,51 +100,48 @@ class EventBus:
|
|||||||
if self._persistence_db_path is None:
|
if self._persistence_db_path is None:
|
||||||
return
|
return
|
||||||
self._persistence_db_path.parent.mkdir(parents=True, exist_ok=True)
|
self._persistence_db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(str(self._persistence_db_path))
|
with closing(sqlite3.connect(str(self._persistence_db_path))) as conn:
|
||||||
try:
|
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=5000")
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
conn.executescript(_EVENTS_SCHEMA)
|
conn.executescript(_EVENTS_SCHEMA)
|
||||||
conn.commit()
|
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."""
|
"""Get a connection to the persistence database."""
|
||||||
if self._persistence_db_path is None:
|
if self._persistence_db_path is None:
|
||||||
return None
|
yield None
|
||||||
conn = sqlite3.connect(str(self._persistence_db_path))
|
return
|
||||||
conn.row_factory = sqlite3.Row
|
with closing(sqlite3.connect(str(self._persistence_db_path))) as conn:
|
||||||
conn.execute("PRAGMA busy_timeout=5000")
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
yield conn
|
||||||
|
|
||||||
def _persist_event(self, event: Event) -> None:
|
def _persist_event(self, event: Event) -> None:
|
||||||
"""Write an event to the persistence database."""
|
"""Write an event to the persistence database."""
|
||||||
conn = self._get_persistence_conn()
|
with self._get_persistence_conn() as conn:
|
||||||
if conn is None:
|
if conn is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
task_id = event.data.get("task_id", "")
|
task_id = event.data.get("task_id", "")
|
||||||
agent_id = event.data.get("agent_id", "")
|
agent_id = event.data.get("agent_id", "")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO events "
|
"INSERT OR IGNORE INTO events "
|
||||||
"(id, event_type, source, task_id, agent_id, data, timestamp) "
|
"(id, event_type, source, task_id, agent_id, data, timestamp) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
event.id,
|
event.id,
|
||||||
event.type,
|
event.type,
|
||||||
event.source,
|
event.source,
|
||||||
task_id,
|
task_id,
|
||||||
agent_id,
|
agent_id,
|
||||||
json.dumps(event.data),
|
json.dumps(event.data),
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to persist event: %s", exc)
|
logger.debug("Failed to persist event: %s", exc)
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# ── Replay ───────────────────────────────────────────────────────────
|
# ── Replay ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -165,45 +163,43 @@ class EventBus:
|
|||||||
Returns:
|
Returns:
|
||||||
List of Event objects from persistent storage.
|
List of Event objects from persistent storage.
|
||||||
"""
|
"""
|
||||||
conn = self._get_persistence_conn()
|
with self._get_persistence_conn() as conn:
|
||||||
if conn is None:
|
if conn is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conditions = []
|
conditions = []
|
||||||
params: list = []
|
params: list = []
|
||||||
|
|
||||||
if event_type:
|
if event_type:
|
||||||
conditions.append("event_type = ?")
|
conditions.append("event_type = ?")
|
||||||
params.append(event_type)
|
params.append(event_type)
|
||||||
if source:
|
if source:
|
||||||
conditions.append("source = ?")
|
conditions.append("source = ?")
|
||||||
params.append(source)
|
params.append(source)
|
||||||
if task_id:
|
if task_id:
|
||||||
conditions.append("task_id = ?")
|
conditions.append("task_id = ?")
|
||||||
params.append(task_id)
|
params.append(task_id)
|
||||||
|
|
||||||
where = " AND ".join(conditions) if conditions else "1=1"
|
where = " AND ".join(conditions) if conditions else "1=1"
|
||||||
sql = f"SELECT * FROM events WHERE {where} ORDER BY timestamp DESC LIMIT ?"
|
sql = f"SELECT * FROM events WHERE {where} ORDER BY timestamp DESC LIMIT ?"
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
|
|
||||||
rows = conn.execute(sql, params).fetchall()
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Event(
|
Event(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
type=row["event_type"],
|
type=row["event_type"],
|
||||||
source=row["source"],
|
source=row["source"],
|
||||||
data=json.loads(row["data"]) if row["data"] else {},
|
data=json.loads(row["data"]) if row["data"] else {},
|
||||||
timestamp=row["timestamp"],
|
timestamp=row["timestamp"],
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to replay events: %s", exc)
|
logger.debug("Failed to replay events: %s", exc)
|
||||||
return []
|
return []
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# ── Subscribe / Publish ──────────────────────────────────────────────
|
# ── Subscribe / Publish ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,65 @@ class ShellHand:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_run_env(env: dict | None) -> dict:
|
||||||
|
"""Merge *env* overrides into a copy of the current environment."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
run_env = os.environ.copy()
|
||||||
|
if env:
|
||||||
|
run_env.update(env)
|
||||||
|
return run_env
|
||||||
|
|
||||||
|
async def _execute_subprocess(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
effective_timeout: int,
|
||||||
|
cwd: str | None,
|
||||||
|
run_env: dict,
|
||||||
|
start: float,
|
||||||
|
) -> ShellResult:
|
||||||
|
"""Run *command* as a subprocess with timeout enforcement."""
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
command,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=cwd,
|
||||||
|
env=run_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||||
|
proc.communicate(), timeout=effective_timeout
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
latency = (time.time() - start) * 1000
|
||||||
|
logger.warning("Shell command timed out after %ds: %s", effective_timeout, command)
|
||||||
|
return ShellResult(
|
||||||
|
command=command,
|
||||||
|
success=False,
|
||||||
|
exit_code=-1,
|
||||||
|
error=f"Command timed out after {effective_timeout}s",
|
||||||
|
latency_ms=latency,
|
||||||
|
timed_out=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
latency = (time.time() - start) * 1000
|
||||||
|
exit_code = proc.returncode if proc.returncode is not None else -1
|
||||||
|
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
|
||||||
|
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
|
||||||
|
|
||||||
|
return ShellResult(
|
||||||
|
command=command,
|
||||||
|
success=exit_code == 0,
|
||||||
|
exit_code=exit_code,
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
latency_ms=latency,
|
||||||
|
)
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
command: str,
|
command: str,
|
||||||
@@ -164,7 +223,6 @@ class ShellHand:
|
|||||||
"""
|
"""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|
||||||
# Validate
|
|
||||||
validation_error = self._validate_command(command)
|
validation_error = self._validate_command(command)
|
||||||
if validation_error:
|
if validation_error:
|
||||||
return ShellResult(
|
return ShellResult(
|
||||||
@@ -178,52 +236,8 @@ class ShellHand:
|
|||||||
cwd = working_dir or self._working_dir
|
cwd = working_dir or self._working_dir
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import os
|
run_env = self._build_run_env(env)
|
||||||
|
return await self._execute_subprocess(command, effective_timeout, cwd, run_env, start)
|
||||||
run_env = os.environ.copy()
|
|
||||||
if env:
|
|
||||||
run_env.update(env)
|
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_shell(
|
|
||||||
command,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
cwd=cwd,
|
|
||||||
env=run_env,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
||||||
proc.communicate(), timeout=effective_timeout
|
|
||||||
)
|
|
||||||
except TimeoutError:
|
|
||||||
proc.kill()
|
|
||||||
await proc.wait()
|
|
||||||
latency = (time.time() - start) * 1000
|
|
||||||
logger.warning("Shell command timed out after %ds: %s", effective_timeout, command)
|
|
||||||
return ShellResult(
|
|
||||||
command=command,
|
|
||||||
success=False,
|
|
||||||
exit_code=-1,
|
|
||||||
error=f"Command timed out after {effective_timeout}s",
|
|
||||||
latency_ms=latency,
|
|
||||||
timed_out=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
latency = (time.time() - start) * 1000
|
|
||||||
exit_code = proc.returncode or 0
|
|
||||||
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
|
|
||||||
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
||||||
|
|
||||||
return ShellResult(
|
|
||||||
command=command,
|
|
||||||
success=exit_code == 0,
|
|
||||||
exit_code=exit_code,
|
|
||||||
stdout=stdout,
|
|
||||||
stderr=stderr,
|
|
||||||
latency_ms=latency,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
latency = (time.time() - start) * 1000
|
latency = (time.time() - start) * 1000
|
||||||
logger.warning("Shell command failed: %s — %s", command, exc)
|
logger.warning("Shell command failed: %s — %s", command, exc)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import logging
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
|
|
||||||
from config import settings
|
from config import normalize_ollama_url, settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -93,18 +93,6 @@ KNOWN_MODEL_CAPABILITIES: dict[str, set[ModelCapability]] = {
|
|||||||
ModelCapability.VISION,
|
ModelCapability.VISION,
|
||||||
},
|
},
|
||||||
# Qwen series
|
# 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": {
|
"qwen2.5": {
|
||||||
ModelCapability.TEXT,
|
ModelCapability.TEXT,
|
||||||
ModelCapability.TOOLS,
|
ModelCapability.TOOLS,
|
||||||
@@ -271,9 +259,8 @@ DEFAULT_FALLBACK_CHAINS: dict[ModelCapability, list[str]] = {
|
|||||||
],
|
],
|
||||||
ModelCapability.TOOLS: [
|
ModelCapability.TOOLS: [
|
||||||
"llama3.1:8b-instruct", # Best tool use
|
"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
|
"qwen2.5:7b", # Reliable fallback
|
||||||
|
"llama3.2:3b", # Smaller but capable
|
||||||
],
|
],
|
||||||
ModelCapability.AUDIO: [
|
ModelCapability.AUDIO: [
|
||||||
# Audio models are less common in Ollama
|
# Audio models are less common in Ollama
|
||||||
@@ -320,7 +307,7 @@ class MultiModalManager:
|
|||||||
import json
|
import json
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
url = self.ollama_url.replace("localhost", "127.0.0.1")
|
url = normalize_ollama_url(self.ollama_url)
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{url}/api/tags",
|
f"{url}/api/tags",
|
||||||
method="GET",
|
method="GET",
|
||||||
@@ -475,7 +462,7 @@ class MultiModalManager:
|
|||||||
|
|
||||||
logger.info("Pulling model: %s", model_name)
|
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(
|
req = urllib.request.Request(
|
||||||
f"{url}/api/pull",
|
f"{url}/api/pull",
|
||||||
method="POST",
|
method="POST",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ model roles (student, teacher, judge/PRM) run on dedicated resources.
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import closing, contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -60,36 +62,37 @@ class CustomModel:
|
|||||||
self.registered_at = datetime.now(UTC).isoformat()
|
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)
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(str(DB_PATH))
|
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=5000")
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS custom_models (
|
CREATE TABLE IF NOT EXISTS custom_models (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
format TEXT NOT NULL,
|
format TEXT NOT NULL,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'general',
|
role TEXT NOT NULL DEFAULT 'general',
|
||||||
context_window INTEGER NOT NULL DEFAULT 4096,
|
context_window INTEGER NOT NULL DEFAULT 4096,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
registered_at TEXT NOT NULL,
|
registered_at TEXT NOT NULL,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
default_temperature REAL NOT NULL DEFAULT 0.7,
|
default_temperature REAL NOT NULL DEFAULT 0.7,
|
||||||
max_tokens INTEGER NOT NULL DEFAULT 2048
|
max_tokens INTEGER NOT NULL DEFAULT 2048
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS agent_model_assignments (
|
CREATE TABLE IF NOT EXISTS agent_model_assignments (
|
||||||
agent_id TEXT PRIMARY KEY,
|
agent_id TEXT PRIMARY KEY,
|
||||||
model_name TEXT NOT NULL,
|
model_name TEXT NOT NULL,
|
||||||
assigned_at TEXT NOT NULL,
|
assigned_at TEXT NOT NULL,
|
||||||
FOREIGN KEY (model_name) REFERENCES custom_models(name)
|
FOREIGN KEY (model_name) REFERENCES custom_models(name)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
class ModelRegistry:
|
class ModelRegistry:
|
||||||
@@ -105,23 +108,22 @@ class ModelRegistry:
|
|||||||
def _load_from_db(self) -> None:
|
def _load_from_db(self) -> None:
|
||||||
"""Bootstrap cache from SQLite."""
|
"""Bootstrap cache from SQLite."""
|
||||||
try:
|
try:
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
for row in conn.execute("SELECT * FROM custom_models WHERE active = 1").fetchall():
|
for row in conn.execute("SELECT * FROM custom_models WHERE active = 1").fetchall():
|
||||||
self._models[row["name"]] = CustomModel(
|
self._models[row["name"]] = CustomModel(
|
||||||
name=row["name"],
|
name=row["name"],
|
||||||
format=ModelFormat(row["format"]),
|
format=ModelFormat(row["format"]),
|
||||||
path=row["path"],
|
path=row["path"],
|
||||||
role=ModelRole(row["role"]),
|
role=ModelRole(row["role"]),
|
||||||
context_window=row["context_window"],
|
context_window=row["context_window"],
|
||||||
description=row["description"],
|
description=row["description"],
|
||||||
registered_at=row["registered_at"],
|
registered_at=row["registered_at"],
|
||||||
active=bool(row["active"]),
|
active=bool(row["active"]),
|
||||||
default_temperature=row["default_temperature"],
|
default_temperature=row["default_temperature"],
|
||||||
max_tokens=row["max_tokens"],
|
max_tokens=row["max_tokens"],
|
||||||
)
|
)
|
||||||
for row in conn.execute("SELECT * FROM agent_model_assignments").fetchall():
|
for row in conn.execute("SELECT * FROM agent_model_assignments").fetchall():
|
||||||
self._agent_assignments[row["agent_id"]] = row["model_name"]
|
self._agent_assignments[row["agent_id"]] = row["model_name"]
|
||||||
conn.close()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to load model registry from DB: %s", exc)
|
logger.warning("Failed to load model registry from DB: %s", exc)
|
||||||
|
|
||||||
@@ -130,29 +132,28 @@ class ModelRegistry:
|
|||||||
def register(self, model: CustomModel) -> CustomModel:
|
def register(self, model: CustomModel) -> CustomModel:
|
||||||
"""Register a new custom model."""
|
"""Register a new custom model."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR REPLACE INTO custom_models
|
INSERT OR REPLACE INTO custom_models
|
||||||
(name, format, path, role, context_window, description,
|
(name, format, path, role, context_window, description,
|
||||||
registered_at, active, default_temperature, max_tokens)
|
registered_at, active, default_temperature, max_tokens)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
model.name,
|
model.name,
|
||||||
model.format.value,
|
model.format.value,
|
||||||
model.path,
|
model.path,
|
||||||
model.role.value,
|
model.role.value,
|
||||||
model.context_window,
|
model.context_window,
|
||||||
model.description,
|
model.description,
|
||||||
model.registered_at,
|
model.registered_at,
|
||||||
int(model.active),
|
int(model.active),
|
||||||
model.default_temperature,
|
model.default_temperature,
|
||||||
model.max_tokens,
|
model.max_tokens,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
self._models[model.name] = model
|
self._models[model.name] = model
|
||||||
logger.info("Registered model: %s (%s)", model.name, model.format.value)
|
logger.info("Registered model: %s (%s)", model.name, model.format.value)
|
||||||
return model
|
return model
|
||||||
@@ -162,11 +163,10 @@ class ModelRegistry:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
if name not in self._models:
|
if name not in self._models:
|
||||||
return False
|
return False
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
conn.execute("DELETE FROM custom_models WHERE name = ?", (name,))
|
conn.execute("DELETE FROM custom_models WHERE name = ?", (name,))
|
||||||
conn.execute("DELETE FROM agent_model_assignments WHERE model_name = ?", (name,))
|
conn.execute("DELETE FROM agent_model_assignments WHERE model_name = ?", (name,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
del self._models[name]
|
del self._models[name]
|
||||||
# Remove any agent assignments using this model
|
# Remove any agent assignments using this model
|
||||||
self._agent_assignments = {
|
self._agent_assignments = {
|
||||||
@@ -193,13 +193,12 @@ class ModelRegistry:
|
|||||||
return False
|
return False
|
||||||
with self._lock:
|
with self._lock:
|
||||||
model.active = active
|
model.active = active
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE custom_models SET active = ? WHERE name = ?",
|
"UPDATE custom_models SET active = ? WHERE name = ?",
|
||||||
(int(active), name),
|
(int(active), name),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# ── Agent-model assignments ────────────────────────────────────────────
|
# ── Agent-model assignments ────────────────────────────────────────────
|
||||||
@@ -210,17 +209,16 @@ class ModelRegistry:
|
|||||||
return False
|
return False
|
||||||
with self._lock:
|
with self._lock:
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR REPLACE INTO agent_model_assignments
|
INSERT OR REPLACE INTO agent_model_assignments
|
||||||
(agent_id, model_name, assigned_at)
|
(agent_id, model_name, assigned_at)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(agent_id, model_name, now),
|
(agent_id, model_name, now),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
self._agent_assignments[agent_id] = model_name
|
self._agent_assignments[agent_id] = model_name
|
||||||
logger.info("Assigned model %s to agent %s", model_name, agent_id)
|
logger.info("Assigned model %s to agent %s", model_name, agent_id)
|
||||||
return True
|
return True
|
||||||
@@ -230,13 +228,12 @@ class ModelRegistry:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
if agent_id not in self._agent_assignments:
|
if agent_id not in self._agent_assignments:
|
||||||
return False
|
return False
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM agent_model_assignments WHERE agent_id = ?",
|
"DELETE FROM agent_model_assignments WHERE agent_id = ?",
|
||||||
(agent_id,),
|
(agent_id,),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
del self._agent_assignments[agent_id]
|
del self._agent_assignments[agent_id]
|
||||||
return True
|
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")
|
@router.get("/config")
|
||||||
async def get_config(
|
async def get_config(
|
||||||
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
|
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from enum import Enum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -100,7 +102,7 @@ class Provider:
|
|||||||
"""LLM provider configuration and state."""
|
"""LLM provider configuration and state."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
type: str # ollama, openai, anthropic, airllm
|
type: str # ollama, openai, anthropic
|
||||||
enabled: bool
|
enabled: bool
|
||||||
priority: int
|
priority: int
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
@@ -301,19 +303,11 @@ class CascadeRouter:
|
|||||||
# Can't check without requests, assume available
|
# Can't check without requests, assume available
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
url = provider.url or "http://localhost:11434"
|
url = provider.url or settings.ollama_url
|
||||||
response = requests.get(f"{url}/api/tags", timeout=5)
|
response = requests.get(f"{url}/api/tags", timeout=5)
|
||||||
return response.status_code == 200
|
return response.status_code == 200
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
return False
|
logger.debug("Ollama provider check error: %s", exc)
|
||||||
|
|
||||||
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):
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
elif provider.type in ("openai", "anthropic", "grok"):
|
elif provider.type in ("openai", "anthropic", "grok"):
|
||||||
@@ -394,6 +388,101 @@ class CascadeRouter:
|
|||||||
|
|
||||||
return None
|
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(
|
async def complete(
|
||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
@@ -420,7 +509,6 @@ class CascadeRouter:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If all providers fail
|
RuntimeError: If all providers fail
|
||||||
"""
|
"""
|
||||||
# Detect content type for multi-modal routing
|
|
||||||
content_type = self._detect_content_type(messages)
|
content_type = self._detect_content_type(messages)
|
||||||
if content_type != ContentType.TEXT:
|
if content_type != ContentType.TEXT:
|
||||||
logger.debug("Detected %s content, selecting appropriate model", content_type.value)
|
logger.debug("Detected %s content, selecting appropriate model", content_type.value)
|
||||||
@@ -428,93 +516,34 @@ class CascadeRouter:
|
|||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
for provider in self.providers:
|
for provider in self.providers:
|
||||||
# Skip disabled providers
|
if not self._is_provider_available(provider):
|
||||||
if not provider.enabled:
|
|
||||||
logger.debug("Skipping %s (disabled)", provider.name)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip unhealthy providers (circuit breaker)
|
selected_model, is_fallback_model = self._select_model(provider, model, content_type)
|
||||||
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
|
|
||||||
|
|
||||||
# Determine which model to use
|
try:
|
||||||
selected_model = model or provider.get_default_model()
|
result = await self._attempt_with_retry(
|
||||||
is_fallback_model = False
|
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
|
self._record_success(provider, result.get("latency_ms", 0))
|
||||||
if content_type != ContentType.TEXT and selected_model:
|
return {
|
||||||
if provider.type == "ollama" and self._mm_manager:
|
"content": result["content"],
|
||||||
from infrastructure.models.multimodal import ModelCapability
|
"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)}")
|
raise RuntimeError(f"All providers failed: {'; '.join(errors)}")
|
||||||
|
|
||||||
async def _try_provider(
|
async def _try_provider(
|
||||||
@@ -580,7 +609,7 @@ class CascadeRouter:
|
|||||||
"""Call Ollama API with multi-modal support."""
|
"""Call Ollama API with multi-modal support."""
|
||||||
import aiohttp
|
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)
|
# Transform messages for Ollama format (including images)
|
||||||
transformed_messages = self._transform_messages_for_ollama(messages)
|
transformed_messages = self._transform_messages_for_ollama(messages)
|
||||||
@@ -814,6 +843,66 @@ class CascadeRouter:
|
|||||||
provider.status = ProviderStatus.HEALTHY
|
provider.status = ProviderStatus.HEALTHY
|
||||||
logger.info("Circuit breaker CLOSED for %s", provider.name)
|
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:
|
def get_metrics(self) -> dict:
|
||||||
"""Get metrics for all providers."""
|
"""Get metrics for all providers."""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ class WebSocketManager:
|
|||||||
for event in list(self._event_history)[-20:]:
|
for event in list(self._event_history)[-20:]:
|
||||||
try:
|
try:
|
||||||
await websocket.send_text(event.to_json())
|
await websocket.send_text(event.to_json())
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning("WebSocket history send error: %s", exc)
|
||||||
break
|
break
|
||||||
|
|
||||||
def disconnect(self, websocket: WebSocket) -> None:
|
def disconnect(self, websocket: WebSocket) -> None:
|
||||||
@@ -83,8 +84,8 @@ class WebSocketManager:
|
|||||||
await ws.send_text(message)
|
await ws.send_text(message)
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
disconnected.append(ws)
|
disconnected.append(ws)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.warning("Unexpected WebSocket send error", exc_info=True)
|
logger.warning("Unexpected WebSocket send error: %s", exc)
|
||||||
disconnected.append(ws)
|
disconnected.append(ws)
|
||||||
|
|
||||||
# Clean up dead connections
|
# Clean up dead connections
|
||||||
@@ -156,7 +157,8 @@ class WebSocketManager:
|
|||||||
try:
|
try:
|
||||||
await ws.send_text(message)
|
await ws.send_text(message)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning("WebSocket direct send error: %s", exc)
|
||||||
disconnected.append(ws)
|
disconnected.append(ws)
|
||||||
|
|
||||||
# Clean up dead connections
|
# Clean up dead connections
|
||||||
|
|||||||
143
src/integrations/chat_bridge/vendors/discord.py
vendored
143
src/integrations/chat_bridge/vendors/discord.py
vendored
@@ -87,7 +87,8 @@ if _DISCORD_UI_AVAILABLE:
|
|||||||
await action["target"].send(
|
await action["target"].send(
|
||||||
f"Action `{action['tool_name']}` timed out and was auto-rejected."
|
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
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -186,7 +187,8 @@ class DiscordVendor(ChatPlatform):
|
|||||||
if self._client and not self._client.is_closed():
|
if self._client and not self._client.is_closed():
|
||||||
try:
|
try:
|
||||||
await self._client.close()
|
await self._client.close()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning("Discord client close error: %s", exc)
|
||||||
pass
|
pass
|
||||||
self._client = None
|
self._client = None
|
||||||
|
|
||||||
@@ -330,7 +332,8 @@ class DiscordVendor(ChatPlatform):
|
|||||||
|
|
||||||
if settings.discord_token:
|
if settings.discord_token:
|
||||||
return settings.discord_token
|
return settings.discord_token
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning("Discord token load error: %s", exc)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 2. Fall back to state file (set via /discord/setup endpoint)
|
# 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")
|
req.reject(note="User rejected from Discord")
|
||||||
try:
|
try:
|
||||||
await continue_chat(action["run_output"], action.get("session_id"))
|
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
|
pass
|
||||||
|
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
@@ -511,25 +515,36 @@ class DiscordVendor(ChatPlatform):
|
|||||||
|
|
||||||
async def _handle_message(self, message) -> None:
|
async def _handle_message(self, message) -> None:
|
||||||
"""Process an incoming message and respond via a thread."""
|
"""Process an incoming message and respond via a thread."""
|
||||||
# Strip the bot mention from the message content
|
content = self._extract_content(message)
|
||||||
content = message.content
|
|
||||||
if self._client.user:
|
|
||||||
content = content.replace(f"<@{self._client.user.id}>", "").strip()
|
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create or reuse a thread for this conversation
|
|
||||||
thread = await self._get_or_create_thread(message)
|
thread = await self._get_or_create_thread(message)
|
||||||
target = thread or message.channel
|
target = thread or message.channel
|
||||||
|
session_id = f"discord_{thread.id}" if thread else f"discord_{message.channel.id}"
|
||||||
|
|
||||||
# Derive session_id for per-conversation history via Agno's SQLite
|
run_output, response = await self._invoke_agent(content, session_id, target)
|
||||||
if thread:
|
|
||||||
session_id = f"discord_{thread.id}"
|
|
||||||
else:
|
|
||||||
session_id = f"discord_{message.channel.id}"
|
|
||||||
|
|
||||||
# Run Timmy agent with typing indicator and timeout
|
if run_output is not None:
|
||||||
|
await self._handle_paused_run(run_output, target, session_id)
|
||||||
|
raw_content = run_output.content if hasattr(run_output, "content") else ""
|
||||||
|
response = _clean_response(raw_content or "")
|
||||||
|
|
||||||
|
await self._send_response(response, target)
|
||||||
|
|
||||||
|
def _extract_content(self, message) -> str:
|
||||||
|
"""Strip the bot mention and return clean message text."""
|
||||||
|
content = message.content
|
||||||
|
if self._client.user:
|
||||||
|
content = content.replace(f"<@{self._client.user.id}>", "").strip()
|
||||||
|
return content
|
||||||
|
|
||||||
|
async def _invoke_agent(self, content: str, session_id: str, target):
|
||||||
|
"""Run chat_with_tools with a typing indicator and timeout.
|
||||||
|
|
||||||
|
Returns a (run_output, error_response) tuple. On success the
|
||||||
|
error_response is ``None``; on failure run_output is ``None``.
|
||||||
|
"""
|
||||||
run_output = None
|
run_output = None
|
||||||
response = None
|
response = None
|
||||||
try:
|
try:
|
||||||
@@ -543,54 +558,58 @@ class DiscordVendor(ChatPlatform):
|
|||||||
response = "Sorry, that took too long. Please try a simpler request."
|
response = "Sorry, that took too long. Please try a simpler request."
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Discord: chat_with_tools() failed: %s", exc)
|
logger.error("Discord: chat_with_tools() failed: %s", exc)
|
||||||
response = (
|
response = "I'm having trouble reaching my inference backend right now. Please try again shortly."
|
||||||
"I'm having trouble reaching my language model right now. Please try again shortly."
|
return run_output, response
|
||||||
|
|
||||||
|
async def _handle_paused_run(self, run_output, target, session_id: str) -> None:
|
||||||
|
"""If Agno paused the run for tool confirmation, enqueue approvals."""
|
||||||
|
status = getattr(run_output, "status", None)
|
||||||
|
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
|
||||||
|
|
||||||
|
if not (is_paused and getattr(run_output, "active_requirements", None)):
|
||||||
|
return
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
if not settings.discord_confirm_actions:
|
||||||
|
return
|
||||||
|
|
||||||
|
for req in run_output.active_requirements:
|
||||||
|
if not getattr(req, "needs_confirmation", False):
|
||||||
|
continue
|
||||||
|
te = req.tool_execution
|
||||||
|
tool_name = getattr(te, "tool_name", "unknown")
|
||||||
|
tool_args = getattr(te, "tool_args", {}) or {}
|
||||||
|
|
||||||
|
from timmy.approvals import create_item
|
||||||
|
|
||||||
|
item = create_item(
|
||||||
|
title=f"Discord: {tool_name}",
|
||||||
|
description=_format_action_description(tool_name, tool_args),
|
||||||
|
proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
|
||||||
|
impact=_get_impact_level(tool_name),
|
||||||
)
|
)
|
||||||
|
self._pending_actions[item.id] = {
|
||||||
|
"run_output": run_output,
|
||||||
|
"requirement": req,
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"tool_args": tool_args,
|
||||||
|
"target": target,
|
||||||
|
"session_id": session_id,
|
||||||
|
}
|
||||||
|
await self._send_confirmation(target, tool_name, tool_args, item.id)
|
||||||
|
|
||||||
# Check if Agno paused the run for tool confirmation
|
@staticmethod
|
||||||
if run_output is not None:
|
async def _send_response(response: str | None, target) -> None:
|
||||||
status = getattr(run_output, "status", None)
|
"""Send a response to Discord, chunked to the 2000-char limit."""
|
||||||
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
|
if not response or not response.strip():
|
||||||
|
return
|
||||||
if is_paused and getattr(run_output, "active_requirements", None):
|
for chunk in _chunk_message(response, 2000):
|
||||||
from config import settings
|
try:
|
||||||
|
await target.send(chunk)
|
||||||
if settings.discord_confirm_actions:
|
except Exception as exc:
|
||||||
for req in run_output.active_requirements:
|
logger.error("Discord: failed to send message chunk: %s", exc)
|
||||||
if getattr(req, "needs_confirmation", False):
|
break
|
||||||
te = req.tool_execution
|
|
||||||
tool_name = getattr(te, "tool_name", "unknown")
|
|
||||||
tool_args = getattr(te, "tool_args", {}) or {}
|
|
||||||
|
|
||||||
from timmy.approvals import create_item
|
|
||||||
|
|
||||||
item = create_item(
|
|
||||||
title=f"Discord: {tool_name}",
|
|
||||||
description=_format_action_description(tool_name, tool_args),
|
|
||||||
proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
|
|
||||||
impact=_get_impact_level(tool_name),
|
|
||||||
)
|
|
||||||
self._pending_actions[item.id] = {
|
|
||||||
"run_output": run_output,
|
|
||||||
"requirement": req,
|
|
||||||
"tool_name": tool_name,
|
|
||||||
"tool_args": tool_args,
|
|
||||||
"target": target,
|
|
||||||
"session_id": session_id,
|
|
||||||
}
|
|
||||||
await self._send_confirmation(target, tool_name, tool_args, item.id)
|
|
||||||
|
|
||||||
raw_content = run_output.content if hasattr(run_output, "content") else ""
|
|
||||||
response = _clean_response(raw_content or "")
|
|
||||||
|
|
||||||
# Discord has a 2000 character limit — send with error handling
|
|
||||||
if response and response.strip():
|
|
||||||
for chunk in _chunk_message(response, 2000):
|
|
||||||
try:
|
|
||||||
await target.send(chunk)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Discord: failed to send message chunk: %s", exc)
|
|
||||||
break
|
|
||||||
|
|
||||||
async def _get_or_create_thread(self, message):
|
async def _get_or_create_thread(self, message):
|
||||||
"""Get the active thread for a channel, or create one.
|
"""Get the active thread for a channel, or create one.
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ class TelegramBot:
|
|||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
return settings.telegram_token or None
|
return settings.telegram_token or None
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning("Telegram token load error: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def save_token(self, token: str) -> 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 logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import closing, contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -39,28 +41,31 @@ class Prediction:
|
|||||||
evaluated_at: str | None
|
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)
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(str(DB_PATH))
|
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=5000")
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS spark_predictions (
|
CREATE TABLE IF NOT EXISTS spark_predictions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
task_id TEXT NOT NULL,
|
task_id TEXT NOT NULL,
|
||||||
prediction_type TEXT NOT NULL,
|
prediction_type TEXT NOT NULL,
|
||||||
predicted_value TEXT NOT NULL,
|
predicted_value TEXT NOT NULL,
|
||||||
actual_value TEXT,
|
actual_value TEXT,
|
||||||
accuracy REAL,
|
accuracy REAL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
evaluated_at TEXT
|
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.commit()
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pred_task ON spark_predictions(task_id)")
|
yield conn
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pred_type ON spark_predictions(prediction_type)")
|
|
||||||
conn.commit()
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
# ── Prediction phase ────────────────────────────────────────────────────────
|
# ── Prediction phase ────────────────────────────────────────────────────────
|
||||||
@@ -119,17 +124,16 @@ def predict_task_outcome(
|
|||||||
# Store prediction
|
# Store prediction
|
||||||
pred_id = str(uuid.uuid4())
|
pred_id = str(uuid.uuid4())
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO spark_predictions
|
INSERT INTO spark_predictions
|
||||||
(id, task_id, prediction_type, predicted_value, created_at)
|
(id, task_id, prediction_type, predicted_value, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(pred_id, task_id, "outcome", json.dumps(prediction), now),
|
(pred_id, task_id, "outcome", json.dumps(prediction), now),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
|
|
||||||
prediction["prediction_id"] = pred_id
|
prediction["prediction_id"] = pred_id
|
||||||
return prediction
|
return prediction
|
||||||
@@ -148,41 +152,39 @@ def evaluate_prediction(
|
|||||||
|
|
||||||
Returns the evaluation result or None if no prediction exists.
|
Returns the evaluation result or None if no prediction exists.
|
||||||
"""
|
"""
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM spark_predictions
|
SELECT * FROM spark_predictions
|
||||||
WHERE task_id = ? AND prediction_type = 'outcome' AND evaluated_at IS NULL
|
WHERE task_id = ? AND prediction_type = 'outcome' AND evaluated_at IS NULL
|
||||||
ORDER BY created_at DESC LIMIT 1
|
ORDER BY created_at DESC LIMIT 1
|
||||||
""",
|
""",
|
||||||
(task_id,),
|
(task_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
conn.close()
|
return None
|
||||||
return None
|
|
||||||
|
|
||||||
predicted = json.loads(row["predicted_value"])
|
predicted = json.loads(row["predicted_value"])
|
||||||
actual = {
|
actual = {
|
||||||
"winner": actual_winner,
|
"winner": actual_winner,
|
||||||
"succeeded": task_succeeded,
|
"succeeded": task_succeeded,
|
||||||
"winning_bid": winning_bid,
|
"winning_bid": winning_bid,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Calculate accuracy
|
# Calculate accuracy
|
||||||
accuracy = _compute_accuracy(predicted, actual)
|
accuracy = _compute_accuracy(predicted, actual)
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE spark_predictions
|
UPDATE spark_predictions
|
||||||
SET actual_value = ?, accuracy = ?, evaluated_at = ?
|
SET actual_value = ?, accuracy = ?, evaluated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(json.dumps(actual), accuracy, now, row["id"]),
|
(json.dumps(actual), accuracy, now, row["id"]),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"prediction_id": row["id"],
|
"prediction_id": row["id"],
|
||||||
@@ -243,7 +245,6 @@ def get_predictions(
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> list[Prediction]:
|
) -> list[Prediction]:
|
||||||
"""Query stored predictions."""
|
"""Query stored predictions."""
|
||||||
conn = _get_conn()
|
|
||||||
query = "SELECT * FROM spark_predictions WHERE 1=1"
|
query = "SELECT * FROM spark_predictions WHERE 1=1"
|
||||||
params: list = []
|
params: list = []
|
||||||
|
|
||||||
@@ -256,8 +257,8 @@ def get_predictions(
|
|||||||
query += " ORDER BY created_at DESC LIMIT ?"
|
query += " ORDER BY created_at DESC LIMIT ?"
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
|
|
||||||
rows = conn.execute(query, params).fetchall()
|
with _get_conn() as conn:
|
||||||
conn.close()
|
rows = conn.execute(query, params).fetchall()
|
||||||
return [
|
return [
|
||||||
Prediction(
|
Prediction(
|
||||||
id=r["id"],
|
id=r["id"],
|
||||||
@@ -275,17 +276,16 @@ def get_predictions(
|
|||||||
|
|
||||||
def get_accuracy_stats() -> dict:
|
def get_accuracy_stats() -> dict:
|
||||||
"""Return aggregate accuracy statistics for the EIDOS loop."""
|
"""Return aggregate accuracy statistics for the EIDOS loop."""
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
row = conn.execute("""
|
row = conn.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS total_predictions,
|
COUNT(*) AS total_predictions,
|
||||||
COUNT(evaluated_at) AS evaluated,
|
COUNT(evaluated_at) AS evaluated,
|
||||||
AVG(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS avg_accuracy,
|
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,
|
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
|
MAX(CASE WHEN accuracy IS NOT NULL THEN accuracy END) AS max_accuracy
|
||||||
FROM spark_predictions
|
FROM spark_predictions
|
||||||
""").fetchone()
|
""").fetchone()
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_predictions": row["total_predictions"] or 0,
|
"total_predictions": row["total_predictions"] or 0,
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ class SparkEngine:
|
|||||||
|
|
||||||
def _maybe_consolidate(self, agent_id: str) -> None:
|
def _maybe_consolidate(self, agent_id: str) -> None:
|
||||||
"""Consolidate events into memories when enough data exists."""
|
"""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)
|
agent_events = spark_memory.get_events(agent_id=agent_id, limit=50)
|
||||||
if len(agent_events) < 5:
|
if len(agent_events) < 5:
|
||||||
return
|
return
|
||||||
@@ -286,7 +288,34 @@ class SparkEngine:
|
|||||||
|
|
||||||
success_rate = len(completions) / total if total else 0
|
success_rate = len(completions) / total if total else 0
|
||||||
|
|
||||||
|
# Determine target memory type based on success rate
|
||||||
if success_rate >= 0.8:
|
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(
|
spark_memory.store_memory(
|
||||||
memory_type="pattern",
|
memory_type="pattern",
|
||||||
subject=agent_id,
|
subject=agent_id,
|
||||||
@@ -295,7 +324,7 @@ class SparkEngine:
|
|||||||
confidence=min(0.95, 0.6 + total * 0.05),
|
confidence=min(0.95, 0.6 + total * 0.05),
|
||||||
source_events=total,
|
source_events=total,
|
||||||
)
|
)
|
||||||
elif success_rate <= 0.3:
|
else: # anomaly
|
||||||
spark_memory.store_memory(
|
spark_memory.store_memory(
|
||||||
memory_type="anomaly",
|
memory_type="anomaly",
|
||||||
subject=agent_id,
|
subject=agent_id,
|
||||||
@@ -358,7 +387,8 @@ def get_spark_engine() -> SparkEngine:
|
|||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
_spark_engine = SparkEngine(enabled=settings.spark_enabled)
|
_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)
|
_spark_engine = SparkEngine(enabled=True)
|
||||||
return _spark_engine
|
return _spark_engine
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ spark_events — raw event log (every swarm event)
|
|||||||
spark_memories — consolidated insights extracted from event patterns
|
spark_memories — consolidated insights extracted from event patterns
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import closing, contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DB_PATH = Path("data/spark.db")
|
DB_PATH = Path("data/spark.db")
|
||||||
|
|
||||||
# Importance thresholds
|
# Importance thresholds
|
||||||
@@ -52,42 +57,43 @@ class SparkMemory:
|
|||||||
expires_at: str | None
|
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)
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(str(DB_PATH))
|
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=5000")
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS spark_events (
|
CREATE TABLE IF NOT EXISTS spark_events (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
agent_id TEXT,
|
agent_id TEXT,
|
||||||
task_id TEXT,
|
task_id TEXT,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
data TEXT NOT NULL DEFAULT '{}',
|
data TEXT NOT NULL DEFAULT '{}',
|
||||||
importance REAL NOT NULL DEFAULT 0.5,
|
importance REAL NOT NULL DEFAULT 0.5,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS spark_memories (
|
CREATE TABLE IF NOT EXISTS spark_memories (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
memory_type TEXT NOT NULL,
|
memory_type TEXT NOT NULL,
|
||||||
subject TEXT NOT NULL DEFAULT 'system',
|
subject TEXT NOT NULL DEFAULT 'system',
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
confidence REAL NOT NULL DEFAULT 0.5,
|
confidence REAL NOT NULL DEFAULT 0.5,
|
||||||
source_events INTEGER NOT NULL DEFAULT 0,
|
source_events INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
expires_at TEXT
|
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_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_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_events_task ON spark_events(task_id)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_subject ON spark_memories(subject)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_subject ON spark_memories(subject)")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
# ── Importance scoring ──────────────────────────────────────────────────────
|
# ── Importance scoring ──────────────────────────────────────────────────────
|
||||||
@@ -146,17 +152,16 @@ def record_event(
|
|||||||
parsed = {}
|
parsed = {}
|
||||||
importance = score_importance(event_type, parsed)
|
importance = score_importance(event_type, parsed)
|
||||||
|
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO spark_events
|
INSERT INTO spark_events
|
||||||
(id, event_type, agent_id, task_id, description, data, importance, created_at)
|
(id, event_type, agent_id, task_id, description, data, importance, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(event_id, event_type, agent_id, task_id, description, data, importance, now),
|
(event_id, event_type, agent_id, task_id, description, data, importance, now),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Bridge to unified event log so all events are queryable from one place
|
# Bridge to unified event log so all events are queryable from one place
|
||||||
try:
|
try:
|
||||||
@@ -170,7 +175,8 @@ def record_event(
|
|||||||
task_id=task_id or "",
|
task_id=task_id or "",
|
||||||
agent_id=agent_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
|
pass # Graceful — don't break spark if event_log is unavailable
|
||||||
|
|
||||||
return event_id
|
return event_id
|
||||||
@@ -184,7 +190,6 @@ def get_events(
|
|||||||
min_importance: float = 0.0,
|
min_importance: float = 0.0,
|
||||||
) -> list[SparkEvent]:
|
) -> list[SparkEvent]:
|
||||||
"""Query events with optional filters."""
|
"""Query events with optional filters."""
|
||||||
conn = _get_conn()
|
|
||||||
query = "SELECT * FROM spark_events WHERE importance >= ?"
|
query = "SELECT * FROM spark_events WHERE importance >= ?"
|
||||||
params: list = [min_importance]
|
params: list = [min_importance]
|
||||||
|
|
||||||
@@ -201,8 +206,8 @@ def get_events(
|
|||||||
query += " ORDER BY created_at DESC LIMIT ?"
|
query += " ORDER BY created_at DESC LIMIT ?"
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
|
|
||||||
rows = conn.execute(query, params).fetchall()
|
with _get_conn() as conn:
|
||||||
conn.close()
|
rows = conn.execute(query, params).fetchall()
|
||||||
return [
|
return [
|
||||||
SparkEvent(
|
SparkEvent(
|
||||||
id=r["id"],
|
id=r["id"],
|
||||||
@@ -220,15 +225,14 @@ def get_events(
|
|||||||
|
|
||||||
def count_events(event_type: str | None = None) -> int:
|
def count_events(event_type: str | None = None) -> int:
|
||||||
"""Count events, optionally filtered by type."""
|
"""Count events, optionally filtered by type."""
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
if event_type:
|
if event_type:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT COUNT(*) FROM spark_events WHERE event_type = ?",
|
"SELECT COUNT(*) FROM spark_events WHERE event_type = ?",
|
||||||
(event_type,),
|
(event_type,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
else:
|
else:
|
||||||
row = conn.execute("SELECT COUNT(*) FROM spark_events").fetchone()
|
row = conn.execute("SELECT COUNT(*) FROM spark_events").fetchone()
|
||||||
conn.close()
|
|
||||||
return row[0]
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
@@ -246,17 +250,16 @@ def store_memory(
|
|||||||
"""Store a consolidated memory. Returns the memory id."""
|
"""Store a consolidated memory. Returns the memory id."""
|
||||||
mem_id = str(uuid.uuid4())
|
mem_id = str(uuid.uuid4())
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO spark_memories
|
INSERT INTO spark_memories
|
||||||
(id, memory_type, subject, content, confidence, source_events, created_at, expires_at)
|
(id, memory_type, subject, content, confidence, source_events, created_at, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(mem_id, memory_type, subject, content, confidence, source_events, now, expires_at),
|
(mem_id, memory_type, subject, content, confidence, source_events, now, expires_at),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
return mem_id
|
return mem_id
|
||||||
|
|
||||||
|
|
||||||
@@ -267,7 +270,6 @@ def get_memories(
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> list[SparkMemory]:
|
) -> list[SparkMemory]:
|
||||||
"""Query memories with optional filters."""
|
"""Query memories with optional filters."""
|
||||||
conn = _get_conn()
|
|
||||||
query = "SELECT * FROM spark_memories WHERE confidence >= ?"
|
query = "SELECT * FROM spark_memories WHERE confidence >= ?"
|
||||||
params: list = [min_confidence]
|
params: list = [min_confidence]
|
||||||
|
|
||||||
@@ -281,8 +283,8 @@ def get_memories(
|
|||||||
query += " ORDER BY created_at DESC LIMIT ?"
|
query += " ORDER BY created_at DESC LIMIT ?"
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
|
|
||||||
rows = conn.execute(query, params).fetchall()
|
with _get_conn() as conn:
|
||||||
conn.close()
|
rows = conn.execute(query, params).fetchall()
|
||||||
return [
|
return [
|
||||||
SparkMemory(
|
SparkMemory(
|
||||||
id=r["id"],
|
id=r["id"],
|
||||||
@@ -300,13 +302,12 @@ def get_memories(
|
|||||||
|
|
||||||
def count_memories(memory_type: str | None = None) -> int:
|
def count_memories(memory_type: str | None = None) -> int:
|
||||||
"""Count memories, optionally filtered by type."""
|
"""Count memories, optionally filtered by type."""
|
||||||
conn = _get_conn()
|
with _get_conn() as conn:
|
||||||
if memory_type:
|
if memory_type:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT COUNT(*) FROM spark_memories WHERE memory_type = ?",
|
"SELECT COUNT(*) FROM spark_memories WHERE memory_type = ?",
|
||||||
(memory_type,),
|
(memory_type,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
else:
|
else:
|
||||||
row = conn.execute("SELECT COUNT(*) FROM spark_memories").fetchone()
|
row = conn.execute("SELECT COUNT(*) FROM spark_memories").fetchone()
|
||||||
conn.close()
|
|
||||||
return row[0]
|
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
|
from timmy.tools import create_full_toolkit
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from timmy.backends import ClaudeBackend, GrokBackend, TimmyAirLLMAgent
|
from timmy.backends import ClaudeBackend, GrokBackend
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Union type for callers that want to hint the return type.
|
# 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.
|
# Models known to be too small for reliable tool calling.
|
||||||
# These hallucinate tool calls as text, invoke tools randomly,
|
# 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)
|
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(
|
req = urllib.request.Request(
|
||||||
f"{url}/api/pull",
|
f"{url}/api/pull",
|
||||||
method="POST",
|
method="POST",
|
||||||
@@ -172,106 +172,34 @@ def _warmup_model(model_name: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_backend(requested: str | None) -> str:
|
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()
|
1. CLI flag passed directly to create_timmy()
|
||||||
2. TIMMY_MODEL_BACKEND env var / .env setting
|
2. TIMMY_MODEL_BACKEND env var / .env setting
|
||||||
3. 'ollama' (safe default — no surprises)
|
3. 'ollama' (safe default -- no surprises)
|
||||||
|
|
||||||
'auto' triggers Apple Silicon detection: uses AirLLM if both
|
|
||||||
is_apple_silicon() and airllm_available() return True.
|
|
||||||
"""
|
"""
|
||||||
if requested is not None:
|
if requested is not None:
|
||||||
return requested
|
return requested
|
||||||
|
|
||||||
configured = settings.timmy_model_backend # "ollama" | "airllm" | "grok" | "claude" | "auto"
|
return settings.timmy_model_backend # "ollama" | "grok" | "claude"
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def create_timmy(
|
def _build_tools_list(use_tools: bool, skip_mcp: bool, model_name: str) -> list:
|
||||||
db_file: str = "timmy.db",
|
"""Assemble the tools list based on model capability and MCP flags.
|
||||||
backend: str | None = None,
|
|
||||||
model_size: str | None = None,
|
|
||||||
*,
|
|
||||||
skip_mcp: bool = False,
|
|
||||||
) -> TimmyAgent:
|
|
||||||
"""Instantiate the agent — Ollama or AirLLM, same public interface.
|
|
||||||
|
|
||||||
Args:
|
Returns a list of Toolkit / MCPTools objects, or an empty list.
|
||||||
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).
|
|
||||||
"""
|
"""
|
||||||
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:
|
if not use_tools:
|
||||||
logger.info("Tools disabled for model %s (too small for reliable tool calling)", model_name)
|
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 = [create_full_toolkit()]
|
||||||
tools_list: list = []
|
|
||||||
if toolkit:
|
|
||||||
tools_list.append(toolkit)
|
|
||||||
|
|
||||||
# Add MCP tool servers (lazy-connected on first arun()).
|
# Add MCP tool servers (lazy-connected on first arun()).
|
||||||
# Skipped when skip_mcp=True — MCP's stdio transport uses anyio cancel
|
# Skipped when skip_mcp=True — MCP's stdio transport uses anyio cancel
|
||||||
# scopes that conflict with asyncio background task cancellation (#72).
|
# scopes that conflict with asyncio background task cancellation (#72).
|
||||||
if use_tools and not skip_mcp:
|
if not skip_mcp:
|
||||||
try:
|
try:
|
||||||
from timmy.mcp_tools import create_filesystem_mcp_tools, create_gitea_mcp_tools
|
from timmy.mcp_tools import create_filesystem_mcp_tools, create_gitea_mcp_tools
|
||||||
|
|
||||||
@@ -285,30 +213,49 @@ def create_timmy(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("MCP tools unavailable: %s", exc)
|
logger.debug("MCP tools unavailable: %s", exc)
|
||||||
|
|
||||||
# Select prompt tier based on tool capability
|
return tools_list
|
||||||
base_prompt = get_system_prompt(tools_enabled=use_tools)
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
from timmy.memory_system import memory_system
|
from timmy.memory_system import memory_system
|
||||||
|
|
||||||
memory_context = memory_system.get_system_context()
|
memory_context = memory_system.get_system_context()
|
||||||
if memory_context:
|
if memory_context:
|
||||||
# Truncate if too long — smaller budget for small models
|
# Smaller budget for small models — expanded prompt uses more tokens
|
||||||
# since the expanded prompt (roster, guardrails) uses more tokens
|
|
||||||
max_context = 2000 if not use_tools else 8000
|
max_context = 2000 if not use_tools else 8000
|
||||||
if len(memory_context) > max_context:
|
if len(memory_context) > max_context:
|
||||||
memory_context = memory_context[:max_context] + "\n... [truncated]"
|
memory_context = memory_context[:max_context] + "\n... [truncated]"
|
||||||
full_prompt = f"{base_prompt}\n\n## Memory Context\n\n{memory_context}"
|
return (
|
||||||
else:
|
f"{base_prompt}\n\n"
|
||||||
full_prompt = base_prompt
|
f"## GROUNDED CONTEXT (verified sources — cite when using)\n\n"
|
||||||
|
f"{memory_context}"
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to load memory context: %s", 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(
|
agent = Agent(
|
||||||
name="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),
|
db=SqliteDb(db_file=db_file),
|
||||||
description=full_prompt,
|
description=full_prompt,
|
||||||
add_history_to_context=True,
|
add_history_to_context=True,
|
||||||
@@ -322,6 +269,67 @@ def create_timmy(
|
|||||||
return agent
|
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:
|
class TimmyWithMemory:
|
||||||
"""Agent wrapper with explicit three-tier memory management."""
|
"""Agent wrapper with explicit three-tier memory management."""
|
||||||
|
|
||||||
@@ -336,15 +344,47 @@ class TimmyWithMemory:
|
|||||||
self.initial_context = self.memory.get_system_context()
|
self.initial_context = self.memory.get_system_context()
|
||||||
|
|
||||||
def chat(self, message: str) -> str:
|
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
|
# Check for user facts to extract
|
||||||
self._extract_and_store_facts(message)
|
self._extract_and_store_facts(message)
|
||||||
|
|
||||||
# Run agent
|
# Retry with backoff — GPU contention causes ReadError/ReadTimeout
|
||||||
result = self.agent.run(message, stream=False)
|
max_retries = 3
|
||||||
response_text = result.content if hasattr(result, "content") else str(result)
|
for attempt in range(1, max_retries + 1):
|
||||||
|
try:
|
||||||
return response_text
|
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:
|
def _extract_and_store_facts(self, message: str) -> None:
|
||||||
"""Extract user facts from message and store in memory."""
|
"""Extract user facts from message and store in memory."""
|
||||||
@@ -355,7 +395,8 @@ class TimmyWithMemory:
|
|||||||
if name:
|
if name:
|
||||||
self.memory.update_user_fact("Name", name)
|
self.memory.update_user_fact("Name", name)
|
||||||
self.memory.record_decision(f"Learned user's 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
|
pass # Best-effort extraction
|
||||||
|
|
||||||
def end_session(self, summary: str = "Session completed") -> None:
|
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 asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -58,6 +59,9 @@ class AgenticResult:
|
|||||||
# Agent factory
|
# Agent factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_loop_agent = None
|
||||||
|
_loop_agent_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _get_loop_agent():
|
def _get_loop_agent():
|
||||||
"""Create a fresh agent for the agentic loop.
|
"""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
|
Returns the same type of agent as `create_timmy()` but with a
|
||||||
dedicated session so it doesn't pollute the main chat history.
|
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()]
|
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
|
# Core loop
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -116,88 +245,41 @@ async def run_agentic_loop(
|
|||||||
|
|
||||||
task_id = str(uuid.uuid4())[:8]
|
task_id = str(uuid.uuid4())[:8]
|
||||||
start_time = time.monotonic()
|
start_time = time.monotonic()
|
||||||
|
|
||||||
agent = _get_loop_agent()
|
agent = _get_loop_agent()
|
||||||
result = AgenticResult(task_id=task_id, task=task, summary="")
|
result = AgenticResult(task_id=task_id, task=task, summary="")
|
||||||
|
|
||||||
# ── Phase 1: Planning ──────────────────────────────────────────────────
|
# Phase 1: Planning
|
||||||
plan_prompt = (
|
plan = await _plan_task(agent, task, session_id, max_steps)
|
||||||
f"Break this task into numbered steps (max {max_steps}). "
|
if isinstance(plan, str):
|
||||||
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)
|
|
||||||
result.status = "failed"
|
result.status = "failed"
|
||||||
result.summary = f"Planning failed: {exc}"
|
result.summary = plan
|
||||||
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
steps = _parse_steps(plan_text)
|
steps, was_truncated = plan
|
||||||
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]
|
|
||||||
total_steps = len(steps)
|
total_steps = len(steps)
|
||||||
was_truncated = planned_steps > total_steps
|
|
||||||
|
|
||||||
# Broadcast plan
|
|
||||||
await _broadcast_progress(
|
await _broadcast_progress(
|
||||||
"agentic.plan_ready",
|
"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] = []
|
completed_results: list[str] = []
|
||||||
|
|
||||||
for i, step_desc in enumerate(steps, 1):
|
for i, step_desc in enumerate(steps, 1):
|
||||||
step_start = time.monotonic()
|
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:
|
try:
|
||||||
step_run = await asyncio.to_thread(
|
step = await _execute_step(
|
||||||
agent.run, context, stream=False, session_id=f"{session_id}_step{i}"
|
agent,
|
||||||
)
|
task,
|
||||||
step_result = step_run.content if hasattr(step_run, "content") else str(step_run)
|
step_desc,
|
||||||
|
i,
|
||||||
# Clean the response
|
total_steps,
|
||||||
from timmy.session import _clean_response
|
completed_results,
|
||||||
|
session_id,
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
result.steps.append(step)
|
result.steps.append(step)
|
||||||
completed_results.append(f"Step {i}: {step_result[:200]}")
|
completed_results.append(f"Step {i}: {step.result[:200]}")
|
||||||
|
|
||||||
# Broadcast progress
|
|
||||||
await _broadcast_progress(
|
await _broadcast_progress(
|
||||||
"agentic.step_complete",
|
"agentic.step_complete",
|
||||||
{
|
{
|
||||||
@@ -205,46 +287,18 @@ async def run_agentic_loop(
|
|||||||
"step": i,
|
"step": i,
|
||||||
"total": total_steps,
|
"total": total_steps,
|
||||||
"description": step_desc,
|
"description": step_desc,
|
||||||
"result": step_result[:200],
|
"result": step.result[:200],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if on_progress:
|
if on_progress:
|
||||||
await on_progress(step_desc, i, total_steps)
|
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)
|
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:
|
try:
|
||||||
adapt_run = await asyncio.to_thread(
|
step = await _adapt_step(agent, step_desc, i, exc, step_start, session_id)
|
||||||
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),
|
|
||||||
)
|
|
||||||
result.steps.append(step)
|
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(
|
await _broadcast_progress(
|
||||||
"agentic.step_adapted",
|
"agentic.step_adapted",
|
||||||
{
|
{
|
||||||
@@ -253,58 +307,26 @@ async def run_agentic_loop(
|
|||||||
"total": total_steps,
|
"total": total_steps,
|
||||||
"description": step_desc,
|
"description": step_desc,
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
"adaptation": adapt_result[:200],
|
"adaptation": step.result[:200],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if on_progress:
|
if on_progress:
|
||||||
await on_progress(f"[Adapted] {step_desc}", i, total_steps)
|
await on_progress(f"[Adapted] {step_desc}", i, total_steps)
|
||||||
|
except Exception as adapt_exc: # broad catch intentional
|
||||||
except Exception as adapt_exc:
|
|
||||||
logger.error("Agentic loop adaptation also failed: %s", adapt_exc)
|
logger.error("Agentic loop adaptation also failed: %s", adapt_exc)
|
||||||
step = AgenticStep(
|
result.steps.append(
|
||||||
step_num=i,
|
AgenticStep(
|
||||||
description=step_desc,
|
step_num=i,
|
||||||
result=f"Failed: {exc}; Adaptation also failed: {adapt_exc}",
|
description=step_desc,
|
||||||
status="failed",
|
result=f"Failed: {exc}; Adaptation also failed: {adapt_exc}",
|
||||||
duration_ms=int((time.monotonic() - step_start) * 1000),
|
status="failed",
|
||||||
|
duration_ms=int((time.monotonic() - step_start) * 1000),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
result.steps.append(step)
|
|
||||||
completed_results.append(f"Step {i}: FAILED")
|
completed_results.append(f"Step {i}: FAILED")
|
||||||
|
|
||||||
# ── Phase 3: Summary ───────────────────────────────────────────────────
|
# Phase 3: Summary
|
||||||
summary_prompt = (
|
_summarize(result, total_steps, was_truncated)
|
||||||
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"
|
|
||||||
|
|
||||||
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||||
|
|
||||||
await _broadcast_progress(
|
await _broadcast_progress(
|
||||||
@@ -332,5 +354,6 @@ async def _broadcast_progress(event: str, data: dict) -> None:
|
|||||||
from infrastructure.ws_manager.handler import ws_manager
|
from infrastructure.ws_manager.handler import ws_manager
|
||||||
|
|
||||||
await ws_manager.broadcast(event, data)
|
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)
|
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.
|
comes entirely from config (agents.yaml), not from Python subclasses.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from agno.agent import Agent
|
from agno.agent import Agent
|
||||||
from agno.models.ollama import Ollama
|
from agno.models.ollama import Ollama
|
||||||
|
|
||||||
@@ -72,9 +74,12 @@ class BaseAgent(ABC):
|
|||||||
if handler:
|
if handler:
|
||||||
tool_instances.append(handler)
|
tool_instances.append(handler)
|
||||||
|
|
||||||
|
ollama_kwargs = {}
|
||||||
|
if settings.ollama_num_ctx > 0:
|
||||||
|
ollama_kwargs["options"] = {"num_ctx": settings.ollama_num_ctx}
|
||||||
return Agent(
|
return Agent(
|
||||||
name=self.name,
|
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,
|
description=system_prompt,
|
||||||
tools=tool_instances if tool_instances else None,
|
tools=tool_instances if tool_instances else None,
|
||||||
add_history_to_context=True,
|
add_history_to_context=True,
|
||||||
@@ -114,16 +119,84 @@ class BaseAgent(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def run(self, message: str) -> str:
|
# Transient errors that indicate Ollama contention or temporary
|
||||||
"""Run the agent with a message.
|
# unavailability — these deserve a retry with backoff.
|
||||||
|
_TRANSIENT = (
|
||||||
|
httpx.ConnectError,
|
||||||
|
httpx.ReadError,
|
||||||
|
httpx.ReadTimeout,
|
||||||
|
httpx.ConnectTimeout,
|
||||||
|
ConnectionError,
|
||||||
|
TimeoutError,
|
||||||
|
)
|
||||||
|
|
||||||
Returns:
|
async def run(self, message: str, *, max_retries: int = 3) -> str:
|
||||||
Agent response
|
"""Run the agent with a message, retrying on transient failures.
|
||||||
|
|
||||||
|
GPU contention from concurrent Ollama requests causes ReadError /
|
||||||
|
ReadTimeout — these are transient and retried with exponential
|
||||||
|
backoff (#70).
|
||||||
"""
|
"""
|
||||||
result = self.agent.run(message, stream=False)
|
response = await self._run_with_retries(message, max_retries)
|
||||||
response = result.content if hasattr(result, "content") else str(result)
|
await self._emit_response_event(message, response)
|
||||||
|
return response
|
||||||
|
|
||||||
# Emit completion event
|
async def _run_with_retries(self, message: str, max_retries: int) -> str:
|
||||||
|
"""Execute agent.run() with retry logic for transient errors."""
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
try:
|
||||||
|
result = self.agent.run(message, stream=False)
|
||||||
|
return result.content if hasattr(result, "content") else str(result)
|
||||||
|
except self._TRANSIENT as exc:
|
||||||
|
self._handle_retry_or_raise(
|
||||||
|
exc,
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
transient=True,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(min(2**attempt, 16))
|
||||||
|
except Exception as exc:
|
||||||
|
self._handle_retry_or_raise(
|
||||||
|
exc,
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
transient=False,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(min(2 ** (attempt - 1), 8))
|
||||||
|
# Unreachable — _handle_retry_or_raise raises on last attempt.
|
||||||
|
raise RuntimeError("retry loop exited unexpectedly") # pragma: no cover
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _handle_retry_or_raise(
|
||||||
|
exc: Exception,
|
||||||
|
attempt: int,
|
||||||
|
max_retries: int,
|
||||||
|
*,
|
||||||
|
transient: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Log a retry warning or raise after exhausting attempts."""
|
||||||
|
if attempt < max_retries:
|
||||||
|
if transient:
|
||||||
|
logger.warning(
|
||||||
|
"Ollama contention on attempt %d/%d: %s. Waiting before retry...",
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Agent run failed on attempt %d/%d: %s. Retrying...",
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
label = "Ollama unreachable" if transient else "Agent run failed"
|
||||||
|
logger.error("%s after %d attempts: %s", label, max_retries, exc)
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
async def _emit_response_event(self, message: str, response: str) -> None:
|
||||||
|
"""Publish a completion event to the event bus if connected."""
|
||||||
if self.event_bus:
|
if self.event_bus:
|
||||||
await self.event_bus.publish(
|
await self.event_bus.publish(
|
||||||
Event(
|
Event(
|
||||||
@@ -133,8 +206,6 @@ class BaseAgent(ABC):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def get_capabilities(self) -> list[str]:
|
def get_capabilities(self) -> list[str]:
|
||||||
"""Get list of capabilities this agent provides."""
|
"""Get list of capabilities this agent provides."""
|
||||||
return self.tools
|
return self.tools
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Usage:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -181,6 +182,23 @@ def get_routing_config() -> dict[str, Any]:
|
|||||||
return config.get("routing", {"method": "pattern", "patterns": {}})
|
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:
|
def route_request(user_message: str) -> str | None:
|
||||||
"""Route a user request to an agent using pattern matching.
|
"""Route a user request to an agent using pattern matching.
|
||||||
|
|
||||||
@@ -193,17 +211,36 @@ def route_request(user_message: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
patterns = routing.get("patterns", {})
|
patterns = routing.get("patterns", {})
|
||||||
message_lower = user_message.lower()
|
|
||||||
|
|
||||||
for agent_id, keywords in patterns.items():
|
for agent_id, keywords in patterns.items():
|
||||||
for keyword in keywords:
|
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)
|
logger.debug("Routed to %s (matched: %r)", agent_id, keyword)
|
||||||
return agent_id
|
return agent_id
|
||||||
|
|
||||||
return None
|
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]:
|
def reload_agents() -> dict[str, Any]:
|
||||||
"""Force reload agents from YAML. Call after editing agents.yaml."""
|
"""Force reload agents from YAML. Call after editing agents.yaml."""
|
||||||
global _agents, _config
|
global _agents, _config
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ Default is always True. The owner changes this intentionally.
|
|||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import closing, contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -43,23 +45,24 @@ class ApprovalItem:
|
|||||||
status: str # "pending" | "approved" | "rejected"
|
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)
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(str(db_path))
|
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS approval_items (
|
CREATE TABLE IF NOT EXISTS approval_items (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
proposed_action TEXT NOT NULL,
|
proposed_action TEXT NOT NULL,
|
||||||
impact TEXT NOT NULL DEFAULT 'low',
|
impact TEXT NOT NULL DEFAULT 'low',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'pending'
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
def _row_to_item(row: sqlite3.Row) -> ApprovalItem:
|
def _row_to_item(row: sqlite3.Row) -> ApprovalItem:
|
||||||
@@ -96,80 +99,73 @@ def create_item(
|
|||||||
created_at=datetime.now(UTC),
|
created_at=datetime.now(UTC),
|
||||||
status="pending",
|
status="pending",
|
||||||
)
|
)
|
||||||
conn = _get_conn(db_path)
|
with _get_conn(db_path) as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO approval_items
|
INSERT INTO approval_items
|
||||||
(id, title, description, proposed_action, impact, created_at, status)
|
(id, title, description, proposed_action, impact, created_at, status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
item.id,
|
item.id,
|
||||||
item.title,
|
item.title,
|
||||||
item.description,
|
item.description,
|
||||||
item.proposed_action,
|
item.proposed_action,
|
||||||
item.impact,
|
item.impact,
|
||||||
item.created_at.isoformat(),
|
item.created_at.isoformat(),
|
||||||
item.status,
|
item.status,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
def list_pending(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
|
def list_pending(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
|
||||||
"""Return all pending approval items, newest first."""
|
"""Return all pending approval items, newest first."""
|
||||||
conn = _get_conn(db_path)
|
with _get_conn(db_path) as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM approval_items WHERE status = 'pending' ORDER BY created_at DESC"
|
"SELECT * FROM approval_items WHERE status = 'pending' ORDER BY created_at DESC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
conn.close()
|
|
||||||
return [_row_to_item(r) for r in rows]
|
return [_row_to_item(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def list_all(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
|
def list_all(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
|
||||||
"""Return all approval items regardless of status, newest first."""
|
"""Return all approval items regardless of status, newest first."""
|
||||||
conn = _get_conn(db_path)
|
with _get_conn(db_path) as conn:
|
||||||
rows = conn.execute("SELECT * FROM approval_items ORDER BY created_at DESC").fetchall()
|
rows = conn.execute("SELECT * FROM approval_items ORDER BY created_at DESC").fetchall()
|
||||||
conn.close()
|
|
||||||
return [_row_to_item(r) for r in rows]
|
return [_row_to_item(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def get_item(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
def get_item(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
||||||
conn = _get_conn(db_path)
|
with _get_conn(db_path) as conn:
|
||||||
row = conn.execute("SELECT * FROM approval_items WHERE id = ?", (item_id,)).fetchone()
|
row = conn.execute("SELECT * FROM approval_items WHERE id = ?", (item_id,)).fetchone()
|
||||||
conn.close()
|
|
||||||
return _row_to_item(row) if row else None
|
return _row_to_item(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
def approve(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
def approve(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
||||||
"""Mark an approval item as approved."""
|
"""Mark an approval item as approved."""
|
||||||
conn = _get_conn(db_path)
|
with _get_conn(db_path) as conn:
|
||||||
conn.execute("UPDATE approval_items SET status = 'approved' WHERE id = ?", (item_id,))
|
conn.execute("UPDATE approval_items SET status = 'approved' WHERE id = ?", (item_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
return get_item(item_id, db_path)
|
return get_item(item_id, db_path)
|
||||||
|
|
||||||
|
|
||||||
def reject(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
def reject(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
||||||
"""Mark an approval item as rejected."""
|
"""Mark an approval item as rejected."""
|
||||||
conn = _get_conn(db_path)
|
with _get_conn(db_path) as conn:
|
||||||
conn.execute("UPDATE approval_items SET status = 'rejected' WHERE id = ?", (item_id,))
|
conn.execute("UPDATE approval_items SET status = 'rejected' WHERE id = ?", (item_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
return get_item(item_id, db_path)
|
return get_item(item_id, db_path)
|
||||||
|
|
||||||
|
|
||||||
def expire_old(db_path: Path = _DEFAULT_DB) -> int:
|
def expire_old(db_path: Path = _DEFAULT_DB) -> int:
|
||||||
"""Auto-expire pending items older than EXPIRY_DAYS. Returns count removed."""
|
"""Auto-expire pending items older than EXPIRY_DAYS. Returns count removed."""
|
||||||
cutoff = (datetime.now(UTC) - timedelta(days=_EXPIRY_DAYS)).isoformat()
|
cutoff = (datetime.now(UTC) - timedelta(days=_EXPIRY_DAYS)).isoformat()
|
||||||
conn = _get_conn(db_path)
|
with _get_conn(db_path) as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"DELETE FROM approval_items WHERE status = 'pending' AND created_at < ?",
|
"DELETE FROM approval_items WHERE status = 'pending' AND created_at < ?",
|
||||||
(cutoff,),
|
(cutoff,),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
count = cursor.rowcount
|
count = cursor.rowcount
|
||||||
conn.close()
|
|
||||||
return count
|
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
|
Provides drop-in replacements for the Agno Agent that expose the same
|
||||||
run(message, stream) → RunResult interface used by the dashboard and the
|
run(message, stream) → RunResult interface used by the dashboard and the
|
||||||
print_response(message, stream) interface used by the CLI.
|
print_response(message, stream) interface used by the CLI.
|
||||||
|
|
||||||
Backends:
|
Backends:
|
||||||
- TimmyAirLLMAgent: Local 8B/70B/405B via AirLLM (Apple Silicon or PyTorch)
|
|
||||||
- GrokBackend: xAI Grok API via OpenAI-compatible SDK (opt-in premium)
|
- GrokBackend: xAI Grok API via OpenAI-compatible SDK (opt-in premium)
|
||||||
- ClaudeBackend: Anthropic Claude API — lightweight cloud fallback
|
- ClaudeBackend: Anthropic Claude API — lightweight cloud fallback
|
||||||
|
|
||||||
@@ -16,27 +15,18 @@ import logging
|
|||||||
import platform
|
import platform
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
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__)
|
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
|
@dataclass
|
||||||
class RunResult:
|
class RunResult:
|
||||||
"""Minimal Agno-compatible run result — carries the model's response text."""
|
"""Minimal Agno-compatible run result — carries the model's response text."""
|
||||||
|
|
||||||
content: str
|
content: str
|
||||||
|
confidence: float | None = None
|
||||||
|
|
||||||
|
|
||||||
def is_apple_silicon() -> bool:
|
def is_apple_silicon() -> bool:
|
||||||
@@ -44,108 +34,6 @@ def is_apple_silicon() -> bool:
|
|||||||
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
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 ─────────────────────────────────────────────────────
|
# ── Grok (xAI) Backend ─────────────────────────────────────────────────────
|
||||||
# Premium cloud augmentation — opt-in only, never the default path.
|
# 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.
|
Uses the OpenAI-compatible SDK to connect to xAI's API.
|
||||||
Only activated when GROK_ENABLED=true and XAI_API_KEY is set.
|
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]
|
run(message, stream) → RunResult [dashboard]
|
||||||
print_response(message, stream) → None [CLI]
|
print_response(message, stream) → None [CLI]
|
||||||
health_check() → dict [monitoring]
|
health_check() → dict [monitoring]
|
||||||
@@ -388,7 +276,9 @@ class GrokBackend:
|
|||||||
|
|
||||||
def _build_messages(self, message: str) -> list[dict[str, str]]:
|
def _build_messages(self, message: str) -> list[dict[str, str]]:
|
||||||
"""Build the messages array for the API call."""
|
"""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
|
# Include conversation history for context
|
||||||
messages.extend(self._history[-10:])
|
messages.extend(self._history[-10:])
|
||||||
messages.append({"role": "user", "content": message})
|
messages.append({"role": "user", "content": message})
|
||||||
@@ -414,7 +304,8 @@ def grok_available() -> bool:
|
|||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
return settings.grok_enabled and bool(settings.xai_api_key)
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -433,8 +324,7 @@ CLAUDE_MODELS: dict[str, str] = {
|
|||||||
class ClaudeBackend:
|
class ClaudeBackend:
|
||||||
"""Anthropic Claude backend — cloud fallback when local models are offline.
|
"""Anthropic Claude backend — cloud fallback when local models are offline.
|
||||||
|
|
||||||
Uses the official Anthropic SDK. Same interface as GrokBackend and
|
Uses the official Anthropic SDK. Same interface as GrokBackend:
|
||||||
TimmyAirLLMAgent:
|
|
||||||
run(message, stream) → RunResult [dashboard]
|
run(message, stream) → RunResult [dashboard]
|
||||||
print_response(message, stream) → None [CLI]
|
print_response(message, stream) → None [CLI]
|
||||||
health_check() → dict [monitoring]
|
health_check() → dict [monitoring]
|
||||||
@@ -480,7 +370,7 @@ class ClaudeBackend:
|
|||||||
response = client.messages.create(
|
response = client.messages.create(
|
||||||
model=self._model,
|
model=self._model,
|
||||||
max_tokens=1024,
|
max_tokens=1024,
|
||||||
system=SYSTEM_PROMPT,
|
system=get_system_prompt(tools_enabled=True, session_id="claude"),
|
||||||
messages=messages,
|
messages=messages,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -566,5 +456,6 @@ def claude_available() -> bool:
|
|||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
return bool(settings.anthropic_api_key)
|
return bool(settings.anthropic_api_key)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning("Backend check failed (claude_available): %s", exc)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ regenerates the briefing every 6 hours.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import closing, contextmanager
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
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)
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(str(db_path))
|
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS briefings (
|
CREATE TABLE IF NOT EXISTS briefings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
generated_at TEXT NOT NULL,
|
generated_at TEXT NOT NULL,
|
||||||
period_start TEXT NOT NULL,
|
period_start TEXT NOT NULL,
|
||||||
period_end TEXT NOT NULL,
|
period_end TEXT NOT NULL,
|
||||||
summary TEXT NOT NULL
|
summary TEXT NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
def _save_briefing(briefing: Briefing, db_path: Path = _DEFAULT_DB) -> None:
|
def _save_briefing(briefing: Briefing, db_path: Path = _DEFAULT_DB) -> None:
|
||||||
conn = _get_cache_conn(db_path)
|
with _get_cache_conn(db_path) as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO briefings (generated_at, period_start, period_end, summary)
|
INSERT INTO briefings (generated_at, period_start, period_end, summary)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
briefing.generated_at.isoformat(),
|
briefing.generated_at.isoformat(),
|
||||||
briefing.period_start.isoformat(),
|
briefing.period_start.isoformat(),
|
||||||
briefing.period_end.isoformat(),
|
briefing.period_end.isoformat(),
|
||||||
briefing.summary,
|
briefing.summary,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _load_latest(db_path: Path = _DEFAULT_DB) -> Briefing | None:
|
def _load_latest(db_path: Path = _DEFAULT_DB) -> Briefing | None:
|
||||||
"""Load the most-recently cached briefing, or None if there is none."""
|
"""Load the most-recently cached briefing, or None if there is none."""
|
||||||
conn = _get_cache_conn(db_path)
|
with _get_cache_conn(db_path) as conn:
|
||||||
row = conn.execute("SELECT * FROM briefings ORDER BY generated_at DESC LIMIT 1").fetchone()
|
row = conn.execute("SELECT * FROM briefings ORDER BY generated_at DESC LIMIT 1").fetchone()
|
||||||
conn.close()
|
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
return Briefing(
|
return Briefing(
|
||||||
@@ -129,27 +130,25 @@ def _gather_swarm_summary(since: datetime) -> str:
|
|||||||
return "No swarm activity recorded yet."
|
return "No swarm activity recorded yet."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(str(swarm_db))
|
with closing(sqlite3.connect(str(swarm_db))) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
since_iso = since.isoformat()
|
since_iso = since.isoformat()
|
||||||
|
|
||||||
completed = conn.execute(
|
completed = conn.execute(
|
||||||
"SELECT COUNT(*) as c FROM tasks WHERE status = 'completed' AND created_at > ?",
|
"SELECT COUNT(*) as c FROM tasks WHERE status = 'completed' AND created_at > ?",
|
||||||
(since_iso,),
|
(since_iso,),
|
||||||
).fetchone()["c"]
|
).fetchone()["c"]
|
||||||
|
|
||||||
failed = conn.execute(
|
failed = conn.execute(
|
||||||
"SELECT COUNT(*) as c FROM tasks WHERE status = 'failed' AND created_at > ?",
|
"SELECT COUNT(*) as c FROM tasks WHERE status = 'failed' AND created_at > ?",
|
||||||
(since_iso,),
|
(since_iso,),
|
||||||
).fetchone()["c"]
|
).fetchone()["c"]
|
||||||
|
|
||||||
agents = conn.execute(
|
agents = conn.execute(
|
||||||
"SELECT COUNT(*) as c FROM agents WHERE registered_at > ?",
|
"SELECT COUNT(*) as c FROM agents WHERE registered_at > ?",
|
||||||
(since_iso,),
|
(since_iso,),
|
||||||
).fetchone()["c"]
|
).fetchone()["c"]
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
if completed:
|
if completed:
|
||||||
@@ -193,7 +192,7 @@ def _gather_task_queue_summary() -> str:
|
|||||||
def _gather_chat_summary(since: datetime) -> str:
|
def _gather_chat_summary(since: datetime) -> str:
|
||||||
"""Pull recent chat messages from the in-memory log."""
|
"""Pull recent chat messages from the in-memory log."""
|
||||||
try:
|
try:
|
||||||
from dashboard.store import message_log
|
from infrastructure.chat_store import message_log
|
||||||
|
|
||||||
messages = message_log.all()
|
messages = message_log.all()
|
||||||
# Filter to messages in the briefing window (best-effort: no timestamps)
|
# Filter to messages in the briefing window (best-effort: no timestamps)
|
||||||
|
|||||||
203
src/timmy/cli.py
203
src/timmy/cli.py
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -21,13 +22,13 @@ _BACKEND_OPTION = typer.Option(
|
|||||||
None,
|
None,
|
||||||
"--backend",
|
"--backend",
|
||||||
"-b",
|
"-b",
|
||||||
help="Inference backend: 'ollama' (default) | 'airllm' | 'auto'",
|
help="Inference backend: 'ollama' (default) | 'grok' | 'claude'",
|
||||||
)
|
)
|
||||||
_MODEL_SIZE_OPTION = typer.Option(
|
_MODEL_SIZE_OPTION = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--model-size",
|
"--model-size",
|
||||||
"-s",
|
"-s",
|
||||||
help="AirLLM model size when --backend airllm: '8b' | '70b' | '405b'",
|
help="Model size (reserved for future use).",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +37,35 @@ def _is_interactive() -> bool:
|
|||||||
return hasattr(sys.stdin, "isatty") and sys.stdin.isatty()
|
return hasattr(sys.stdin, "isatty") and sys.stdin.isatty()
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_interactive(req, tool_name: str, tool_args: dict) -> None:
|
||||||
|
"""Display tool details and prompt the human for approval."""
|
||||||
|
description = format_action_description(tool_name, tool_args)
|
||||||
|
impact = get_impact_level(tool_name)
|
||||||
|
|
||||||
|
typer.echo()
|
||||||
|
typer.echo(typer.style("Tool confirmation required", bold=True))
|
||||||
|
typer.echo(f" Impact: {impact.upper()}")
|
||||||
|
typer.echo(f" {description}")
|
||||||
|
typer.echo()
|
||||||
|
|
||||||
|
if typer.confirm("Allow this action?", default=False):
|
||||||
|
req.confirm()
|
||||||
|
logger.info("CLI: approved %s", tool_name)
|
||||||
|
else:
|
||||||
|
req.reject(note="User rejected from CLI")
|
||||||
|
logger.info("CLI: rejected %s", tool_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _decide_autonomous(req, tool_name: str, tool_args: dict) -> None:
|
||||||
|
"""Auto-approve allowlisted tools; reject everything else."""
|
||||||
|
if is_allowlisted(tool_name, tool_args):
|
||||||
|
req.confirm()
|
||||||
|
logger.info("AUTO-APPROVED (allowlist): %s", tool_name)
|
||||||
|
else:
|
||||||
|
req.reject(note="Auto-rejected: not in allowlist")
|
||||||
|
logger.info("AUTO-REJECTED (not allowlisted): %s %s", tool_name, str(tool_args)[:100])
|
||||||
|
|
||||||
|
|
||||||
def _handle_tool_confirmation(agent, run_output, session_id: str, *, autonomous: bool = False):
|
def _handle_tool_confirmation(agent, run_output, session_id: str, *, autonomous: bool = False):
|
||||||
"""Prompt user to approve/reject dangerous tool calls.
|
"""Prompt user to approve/reject dangerous tool calls.
|
||||||
|
|
||||||
@@ -50,6 +80,7 @@ def _handle_tool_confirmation(agent, run_output, session_id: str, *, autonomous:
|
|||||||
Returns the final RunOutput after all confirmations are resolved.
|
Returns the final RunOutput after all confirmations are resolved.
|
||||||
"""
|
"""
|
||||||
interactive = _is_interactive() and not autonomous
|
interactive = _is_interactive() and not autonomous
|
||||||
|
decide = _prompt_interactive if interactive else _decide_autonomous
|
||||||
|
|
||||||
max_rounds = 10 # safety limit
|
max_rounds = 10 # safety limit
|
||||||
for _ in range(max_rounds):
|
for _ in range(max_rounds):
|
||||||
@@ -65,39 +96,10 @@ def _handle_tool_confirmation(agent, run_output, session_id: str, *, autonomous:
|
|||||||
for req in reqs:
|
for req in reqs:
|
||||||
if not getattr(req, "needs_confirmation", False):
|
if not getattr(req, "needs_confirmation", False):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
te = req.tool_execution
|
te = req.tool_execution
|
||||||
tool_name = getattr(te, "tool_name", "unknown")
|
tool_name = getattr(te, "tool_name", "unknown")
|
||||||
tool_args = getattr(te, "tool_args", {}) or {}
|
tool_args = getattr(te, "tool_args", {}) or {}
|
||||||
|
decide(req, tool_name, tool_args)
|
||||||
if interactive:
|
|
||||||
# Human present — prompt for approval
|
|
||||||
description = format_action_description(tool_name, tool_args)
|
|
||||||
impact = get_impact_level(tool_name)
|
|
||||||
|
|
||||||
typer.echo()
|
|
||||||
typer.echo(typer.style("Tool confirmation required", bold=True))
|
|
||||||
typer.echo(f" Impact: {impact.upper()}")
|
|
||||||
typer.echo(f" {description}")
|
|
||||||
typer.echo()
|
|
||||||
|
|
||||||
approved = typer.confirm("Allow this action?", default=False)
|
|
||||||
if approved:
|
|
||||||
req.confirm()
|
|
||||||
logger.info("CLI: approved %s", tool_name)
|
|
||||||
else:
|
|
||||||
req.reject(note="User rejected from CLI")
|
|
||||||
logger.info("CLI: rejected %s", tool_name)
|
|
||||||
else:
|
|
||||||
# Autonomous mode — check allowlist
|
|
||||||
if is_allowlisted(tool_name, tool_args):
|
|
||||||
req.confirm()
|
|
||||||
logger.info("AUTO-APPROVED (allowlist): %s", tool_name)
|
|
||||||
else:
|
|
||||||
req.reject(note="Auto-rejected: not in allowlist")
|
|
||||||
logger.info(
|
|
||||||
"AUTO-REJECTED (not allowlisted): %s %s", tool_name, str(tool_args)[:100]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resume the run so the agent sees the confirmation result
|
# Resume the run so the agent sees the confirmation result
|
||||||
try:
|
try:
|
||||||
@@ -137,13 +139,15 @@ def think(
|
|||||||
model_size: str | None = _MODEL_SIZE_OPTION,
|
model_size: str | None = _MODEL_SIZE_OPTION,
|
||||||
):
|
):
|
||||||
"""Ask Timmy to think carefully about a topic."""
|
"""Ask Timmy to think carefully about a topic."""
|
||||||
timmy = create_timmy(backend=backend, model_size=model_size)
|
timmy = create_timmy(backend=backend, session_id=_CLI_SESSION_ID)
|
||||||
timmy.print_response(f"Think carefully about: {topic}", stream=True, session_id=_CLI_SESSION_ID)
|
timmy.print_response(f"Think carefully about: {topic}", stream=True, session_id=_CLI_SESSION_ID)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def chat(
|
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,
|
backend: str | None = _BACKEND_OPTION,
|
||||||
model_size: str | None = _MODEL_SIZE_OPTION,
|
model_size: str | None = _MODEL_SIZE_OPTION,
|
||||||
new_session: bool = typer.Option(
|
new_session: bool = typer.Option(
|
||||||
@@ -172,19 +176,36 @@ def chat(
|
|||||||
Use --autonomous for non-interactive contexts (scripts, dev loops). Tool
|
Use --autonomous for non-interactive contexts (scripts, dev loops). Tool
|
||||||
calls are checked against config/allowlist.yaml — allowlisted operations
|
calls are checked against config/allowlist.yaml — allowlisted operations
|
||||||
execute automatically, everything else is safely rejected.
|
execute automatically, everything else is safely rejected.
|
||||||
|
|
||||||
|
Read from stdin by passing "-" as the message or piping input.
|
||||||
"""
|
"""
|
||||||
import uuid
|
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:
|
if session_id is not None:
|
||||||
pass # use the provided value
|
pass # use the provided value
|
||||||
elif new_session:
|
elif new_session:
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
else:
|
else:
|
||||||
session_id = _CLI_SESSION_ID
|
session_id = _CLI_SESSION_ID
|
||||||
timmy = create_timmy(backend=backend, model_size=model_size)
|
timmy = create_timmy(backend=backend, session_id=session_id)
|
||||||
|
|
||||||
# Use agent.run() so we can intercept paused runs for tool confirmation.
|
# 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
|
# Handle paused runs — dangerous tools need user approval
|
||||||
run_output = _handle_tool_confirmation(timmy, run_output, session_id, autonomous=autonomous)
|
run_output = _handle_tool_confirmation(timmy, run_output, session_id, autonomous=autonomous)
|
||||||
@@ -197,13 +218,68 @@ def chat(
|
|||||||
typer.echo(_clean_response(content))
|
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()
|
@app.command()
|
||||||
def status(
|
def status(
|
||||||
backend: str | None = _BACKEND_OPTION,
|
backend: str | None = _BACKEND_OPTION,
|
||||||
model_size: str | None = _MODEL_SIZE_OPTION,
|
model_size: str | None = _MODEL_SIZE_OPTION,
|
||||||
):
|
):
|
||||||
"""Print Timmy's operational status."""
|
"""Print Timmy's operational status."""
|
||||||
timmy = create_timmy(backend=backend, model_size=model_size)
|
timmy = create_timmy(backend=backend, session_id=_CLI_SESSION_ID)
|
||||||
timmy.print_response(STATUS_PROMPT, stream=False, session_id=_CLI_SESSION_ID)
|
timmy.print_response(STATUS_PROMPT, stream=False, session_id=_CLI_SESSION_ID)
|
||||||
|
|
||||||
|
|
||||||
@@ -259,7 +335,8 @@ def interview(
|
|||||||
from timmy.mcp_tools import close_mcp_sessions
|
from timmy.mcp_tools import close_mcp_sessions
|
||||||
|
|
||||||
loop.run_until_complete(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
|
pass
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
@@ -325,5 +402,55 @@ def voice(
|
|||||||
loop.run()
|
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():
|
def main():
|
||||||
app()
|
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:
|
try:
|
||||||
answer = chat_fn(question)
|
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)
|
logger.error("Interview question failed: %s", exc)
|
||||||
answer = f"(Error: {exc})"
|
answer = f"(Error: {exc})"
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,8 @@ def capture_error(exc, **kwargs):
|
|||||||
from infrastructure.error_capture import capture_error as _capture
|
from infrastructure.error_capture import capture_error as _capture
|
||||||
|
|
||||||
return _capture(exc, **kwargs)
|
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)
|
logger.debug("Failed to capture error", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,20 @@ Usage::
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from PIL import ImageDraw
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
|
from contextlib import closing
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -40,7 +47,7 @@ def _parse_command(command_str: str) -> tuple[str, list[str]]:
|
|||||||
"""Split a command string into (executable, args).
|
"""Split a command string into (executable, args).
|
||||||
|
|
||||||
Handles ``~/`` expansion and resolves via PATH if needed.
|
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()
|
parts = command_str.split()
|
||||||
executable = os.path.expanduser(parts[0])
|
executable = os.path.expanduser(parts[0])
|
||||||
@@ -163,37 +170,36 @@ def _bridge_to_work_order(title: str, body: str, category: str) -> None:
|
|||||||
try:
|
try:
|
||||||
db_path = Path(settings.repo_root) / "data" / "work_orders.db"
|
db_path = Path(settings.repo_root) / "data" / "work_orders.db"
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(str(db_path))
|
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""CREATE TABLE IF NOT EXISTS work_orders (
|
"""CREATE TABLE IF NOT EXISTS work_orders (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT DEFAULT '',
|
description TEXT DEFAULT '',
|
||||||
priority TEXT DEFAULT 'medium',
|
priority TEXT DEFAULT 'medium',
|
||||||
category TEXT DEFAULT 'suggestion',
|
category TEXT DEFAULT 'suggestion',
|
||||||
submitter TEXT DEFAULT 'dashboard',
|
submitter TEXT DEFAULT 'dashboard',
|
||||||
related_files TEXT DEFAULT '',
|
related_files TEXT DEFAULT '',
|
||||||
status TEXT DEFAULT 'submitted',
|
status TEXT DEFAULT 'submitted',
|
||||||
result TEXT DEFAULT '',
|
result TEXT DEFAULT '',
|
||||||
rejection_reason TEXT DEFAULT '',
|
rejection_reason TEXT DEFAULT '',
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
completed_at TEXT
|
completed_at TEXT
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO work_orders (id, title, description, category, submitter, created_at) "
|
"INSERT INTO work_orders (id, title, description, category, submitter, created_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
str(uuid.uuid4()),
|
str(uuid.uuid4()),
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
category,
|
category,
|
||||||
"timmy-thinking",
|
"timmy-thinking",
|
||||||
datetime.utcnow().isoformat(),
|
datetime.utcnow().isoformat(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Work order bridge failed: %s", exc)
|
logger.debug("Work order bridge failed: %s", exc)
|
||||||
|
|
||||||
@@ -268,6 +274,148 @@ async def create_gitea_issue_via_mcp(title: str, body: str = "", labels: str = "
|
|||||||
return f"Failed to create issue via MCP: {exc}"
|
return f"Failed to create issue via MCP: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_background(draw: ImageDraw.ImageDraw, size: int) -> None:
|
||||||
|
"""Draw radial gradient background with 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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_wizard(draw: ImageDraw.ImageDraw) -> None:
|
||||||
|
"""Draw wizard hat, face, eyes, smile, monogram, and robe."""
|
||||||
|
hat_color = (100, 50, 160) # purple
|
||||||
|
hat_outline = (180, 130, 255)
|
||||||
|
gold = (220, 190, 50)
|
||||||
|
pupil = (30, 30, 60)
|
||||||
|
|
||||||
|
# Hat + brim
|
||||||
|
draw.polygon([(256, 40), (160, 220), (352, 220)], fill=hat_color, outline=hat_outline)
|
||||||
|
draw.ellipse([140, 200, 372, 250], fill=hat_color, outline=hat_outline)
|
||||||
|
|
||||||
|
# Face
|
||||||
|
draw.ellipse([190, 220, 322, 370], fill=(60, 180, 100), outline=(80, 220, 120))
|
||||||
|
|
||||||
|
# Eyes (whites + pupils)
|
||||||
|
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=pupil)
|
||||||
|
draw.ellipse([272, 285, 286, 300], fill=pupil)
|
||||||
|
|
||||||
|
# Smile
|
||||||
|
draw.arc([225, 300, 287, 355], start=10, end=170, fill=pupil, width=3)
|
||||||
|
|
||||||
|
# "T" monogram on hat
|
||||||
|
draw.text((243, 100), "T", fill=gold)
|
||||||
|
|
||||||
|
# Robe
|
||||||
|
draw.polygon(
|
||||||
|
[(180, 370), (140, 500), (372, 500), (332, 370)],
|
||||||
|
fill=(40, 100, 70),
|
||||||
|
outline=(60, 160, 100),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_stars(draw: ImageDraw.ImageDraw) -> None:
|
||||||
|
"""Draw decorative gold stars around the wizard hat."""
|
||||||
|
gold = (220, 190, 50)
|
||||||
|
for sx, sy in [(120, 100), (380, 120), (100, 300), (400, 280), (256, 10)]:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
size = 512
|
||||||
|
img = Image.new("RGB", (size, size), (15, 25, 20))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
_draw_background(draw, size)
|
||||||
|
_draw_wizard(draw)
|
||||||
|
_draw_stars(draw)
|
||||||
|
|
||||||
|
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:
|
async def close_mcp_sessions() -> None:
|
||||||
"""Close any open MCP sessions. Called during app shutdown."""
|
"""Close any open MCP sessions. Called during app shutdown."""
|
||||||
global _issue_session
|
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,206 @@
|
|||||||
"""Unified memory database — single SQLite DB for all memory types.
|
"""Unified memory schema and connection management.
|
||||||
|
|
||||||
Consolidates three previously separate stores into one:
|
This module provides the central database schema for Timmy's consolidated
|
||||||
- **facts**: Long-term knowledge (user preferences, learned patterns)
|
memory system. All memory types (facts, conversations, documents, vault chunks)
|
||||||
- **chunks**: Indexed vault documents (markdown files from memory/)
|
are stored in a single `memories` table with a `memory_type` discriminator.
|
||||||
- **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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sqlite3
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
@contextmanager
|
||||||
"""Open (and lazily create) the unified memory database."""
|
def get_connection() -> Generator[sqlite3.Connection, None, None]:
|
||||||
|
"""Get database connection to unified memory database."""
|
||||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(str(DB_PATH))
|
with closing(sqlite3.connect(str(DB_PATH))) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=5000")
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
_ensure_schema(conn)
|
_ensure_schema(conn)
|
||||||
return conn
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||||
"""Create the three core tables and indexes if they don't exist."""
|
"""Create the unified memories table and indexes if they don't exist."""
|
||||||
|
|
||||||
# --- facts ---------------------------------------------------------------
|
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS facts (
|
CREATE TABLE IF NOT EXISTS memories (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
category TEXT NOT NULL DEFAULT 'general',
|
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
confidence REAL NOT NULL DEFAULT 0.8,
|
memory_type TEXT NOT NULL DEFAULT 'fact',
|
||||||
source TEXT NOT NULL DEFAULT 'agent',
|
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 '[]',
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
last_accessed TEXT,
|
last_accessed TEXT,
|
||||||
access_count INTEGER NOT NULL DEFAULT 0
|
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)")
|
|
||||||
|
|
||||||
# --- 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)")
|
|
||||||
|
|
||||||
|
# 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()
|
conn.commit()
|
||||||
|
|
||||||
|
# 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()}
|
||||||
|
|
||||||
|
if "memories" not in tables:
|
||||||
|
logger.info("Migration: Creating unified memories table")
|
||||||
|
# Schema will be created by _ensure_schema above
|
||||||
|
conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
_migrate_episodes(conn, tables)
|
||||||
|
_migrate_chunks(conn, tables)
|
||||||
|
_drop_legacy_tables(conn, tables)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_episodes(conn: sqlite3.Connection, tables: set[str]) -> None:
|
||||||
|
"""Migrate episodes table rows into the unified memories table."""
|
||||||
|
if "episodes" not in tables:
|
||||||
|
return
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_chunks(conn: sqlite3.Connection, tables: set[str]) -> None:
|
||||||
|
"""Migrate chunks table rows into the unified memories table as vault_chunk."""
|
||||||
|
if "chunks" not in tables:
|
||||||
|
return
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_legacy_tables(conn: sqlite3.Connection, tables: set[str]) -> None:
|
||||||
|
"""Drop old facts table if it exists."""
|
||||||
|
if "facts" not in tables:
|
||||||
|
return
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
"""Backward compatibility — all memory functions live in memory_system now."""
|
||||||
|
|
||||||
Provides embedding-based similarity search for the Echo agent
|
from timmy.memory_system import (
|
||||||
to retrieve relevant context from conversation history.
|
DB_PATH,
|
||||||
"""
|
MemoryEntry,
|
||||||
|
_cosine_similarity,
|
||||||
import json
|
_keyword_overlap,
|
||||||
import sqlite3
|
delete_memory,
|
||||||
import uuid
|
get_memory_context,
|
||||||
from dataclasses import dataclass, field
|
get_memory_stats,
|
||||||
from datetime import UTC, datetime
|
get_memory_system,
|
||||||
|
prune_memories,
|
||||||
|
recall_personal_facts,
|
||||||
def _check_embedding_model() -> bool | None:
|
recall_personal_facts_with_ids,
|
||||||
"""Check if the canonical embedding model is available."""
|
search_memories,
|
||||||
try:
|
store_memory,
|
||||||
from timmy.semantic_memory import _get_embedding_model
|
store_personal_fact,
|
||||||
|
update_personal_fact,
|
||||||
model = _get_embedding_model()
|
)
|
||||||
return model is not None and model is not False
|
|
||||||
except Exception:
|
__all__ = [
|
||||||
return None
|
"DB_PATH",
|
||||||
|
"MemoryEntry",
|
||||||
|
"delete_memory",
|
||||||
def _compute_embedding(text: str) -> list[float]:
|
"get_memory_context",
|
||||||
"""Compute embedding vector for text.
|
"get_memory_stats",
|
||||||
|
"get_memory_system",
|
||||||
Delegates to the canonical embedding provider in semantic_memory
|
"prune_memories",
|
||||||
to avoid loading the model multiple times.
|
"recall_personal_facts",
|
||||||
"""
|
"recall_personal_facts_with_ids",
|
||||||
from timmy.semantic_memory import embed_text
|
"search_memories",
|
||||||
|
"store_memory",
|
||||||
return embed_text(text)
|
"store_personal_fact",
|
||||||
|
"update_personal_fact",
|
||||||
|
"_cosine_similarity",
|
||||||
@dataclass
|
"_keyword_overlap",
|
||||||
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
|
|
||||||
|
|||||||
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
|
# 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.
|
No cloud dependencies.
|
||||||
|
Your core identity and values are defined in your soul (loaded via memory). Follow them.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Be brief by default. Short questions get short answers. Expand only when depth
|
- 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.
|
- Remember what the user tells you during the conversation.
|
||||||
- If you don't know something, say so honestly — never fabricate facts.
|
- If you don't know something, say so honestly — never fabricate facts.
|
||||||
- If a request is ambiguous, ask a brief clarifying question before guessing.
|
- 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.
|
- Use the user's name if you know it.
|
||||||
- When you state a fact, commit to it.
|
- When you state a fact, commit to it.
|
||||||
- NEVER attempt arithmetic in your head. If asked to compute anything, respond:
|
- 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."
|
like me to walk through the logic instead."
|
||||||
- Do NOT end responses with generic chatbot phrases like "I'm here to help" or
|
- Do NOT end responses with generic chatbot phrases like "I'm here to help" or
|
||||||
"feel free to ask."
|
"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.
|
- When your values conflict (e.g. honesty vs. helpfulness), lead with honesty.
|
||||||
- Sometimes the right answer is nothing. Do not fill silence with noise.
|
- 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)
|
# 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.
|
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):
|
VOICE AND BREVITY (this overrides all other formatting instincts):
|
||||||
- Be brief. Short questions get short answers. One sentence if one sentence
|
- 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.
|
the question that wasn't.
|
||||||
- Never narrate your reasoning. Just give the answer.
|
- Never narrate your reasoning. Just give the answer.
|
||||||
- Do not end with filler ("Let me know!", "Happy to help!", "Feel free...").
|
- 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.
|
- Sometimes the right answer is nothing. Do not fill silence with noise.
|
||||||
|
|
||||||
HONESTY:
|
HONESTY:
|
||||||
@@ -58,6 +81,18 @@ HONESTY:
|
|||||||
- Never fabricate tool output. Call the tool and wait.
|
- Never fabricate tool output. Call the tool and wait.
|
||||||
- If a tool errors, report the exact error.
|
- 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):
|
MEMORY (three tiers):
|
||||||
- Tier 1: MEMORY.md (hot, always loaded)
|
- Tier 1: MEMORY.md (hot, always loaded)
|
||||||
- Tier 2: memory/ vault (structured, append-only, date-stamped)
|
- Tier 2: memory/ vault (structured, append-only, date-stamped)
|
||||||
@@ -79,28 +114,68 @@ IDENTITY:
|
|||||||
- If a request is ambiguous, ask one brief clarifying question.
|
- If a request is ambiguous, ask one brief clarifying question.
|
||||||
- When you state a fact, commit to it.
|
- When you state a fact, commit to it.
|
||||||
- Never show raw tool call JSON or function syntax in responses.
|
- 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
|
# Default to lite for safety
|
||||||
SYSTEM_PROMPT = SYSTEM_PROMPT_LITE
|
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.
|
"""Return the appropriate system prompt based on tool capability.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
tools_enabled: True if the model supports reliable tool calling.
|
tools_enabled: True if the model supports reliable tool calling.
|
||||||
|
session_id: The session identifier (cli, dashboard, loop, etc.)
|
||||||
|
|
||||||
Returns:
|
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
|
from config import settings
|
||||||
|
|
||||||
model_name = settings.ollama_model
|
model_name = settings.ollama_model
|
||||||
|
|
||||||
if tools_enabled:
|
if tools_enabled:
|
||||||
return SYSTEM_PROMPT_FULL.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)
|
return SYSTEM_PROMPT_LITE.format(model_name=model_name, session_id=session_id)
|
||||||
|
|
||||||
|
|
||||||
STATUS_PROMPT = """Give a one-sentence status report confirming
|
STATUS_PROMPT = """Give a one-sentence status report confirming
|
||||||
|
|||||||
@@ -1,491 +1,41 @@
|
|||||||
"""Tier 3: Semantic Memory — Vector search over vault files.
|
"""Backward compatibility — all memory functions live in memory_system now."""
|
||||||
|
|
||||||
Uses lightweight local embeddings (no cloud) for similarity search
|
from timmy.memory_system import (
|
||||||
over all vault content. This is the "escape valve" when hot memory
|
DB_PATH,
|
||||||
doesn't have the answer.
|
EMBEDDING_DIM,
|
||||||
|
EMBEDDING_MODEL,
|
||||||
Architecture:
|
MemoryChunk,
|
||||||
- Indexes all markdown files in memory/ nightly or on-demand
|
MemoryEntry,
|
||||||
- Uses sentence-transformers (local, no API calls)
|
MemorySearcher,
|
||||||
- Stores vectors in SQLite (no external vector DB needed)
|
SemanticMemory,
|
||||||
- memory_search() retrieves relevant context by similarity
|
_get_embedding_model,
|
||||||
"""
|
_simple_hash_embedding,
|
||||||
|
cosine_similarity,
|
||||||
import hashlib
|
embed_text,
|
||||||
import json
|
memory_forget,
|
||||||
import logging
|
memory_read,
|
||||||
import sqlite3
|
memory_search,
|
||||||
from dataclasses import dataclass
|
memory_searcher,
|
||||||
from datetime import UTC, datetime
|
memory_write,
|
||||||
from pathlib import Path
|
semantic_memory,
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
__all__ = [
|
||||||
# Paths
|
"DB_PATH",
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
"EMBEDDING_DIM",
|
||||||
VAULT_PATH = PROJECT_ROOT / "memory"
|
"EMBEDDING_MODEL",
|
||||||
SEMANTIC_DB_PATH = PROJECT_ROOT / "data" / "memory.db"
|
"MemoryChunk",
|
||||||
|
"MemoryEntry",
|
||||||
# Embedding model - small, fast, local
|
"MemorySearcher",
|
||||||
# Using 'all-MiniLM-L6-v2' (~80MB) or fallback to simple keyword matching
|
"SemanticMemory",
|
||||||
EMBEDDING_MODEL = None
|
"_get_embedding_model",
|
||||||
EMBEDDING_DIM = 384 # MiniLM dimension
|
"_simple_hash_embedding",
|
||||||
|
"cosine_similarity",
|
||||||
|
"embed_text",
|
||||||
def _get_embedding_model():
|
"memory_forget",
|
||||||
"""Lazy-load embedding model."""
|
"memory_read",
|
||||||
global EMBEDDING_MODEL
|
"memory_search",
|
||||||
if EMBEDDING_MODEL is None:
|
"memory_searcher",
|
||||||
from config import settings
|
"memory_write",
|
||||||
|
"semantic_memory",
|
||||||
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}"
|
|
||||||
|
|||||||
@@ -11,8 +11,31 @@ let Agno's session_id mechanism handle conversation continuity.
|
|||||||
import logging
|
import logging
|
||||||
import re
|
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__)
|
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 for the dashboard (stable across requests)
|
||||||
_DEFAULT_SESSION_ID = "dashboard"
|
_DEFAULT_SESSION_ID = "dashboard"
|
||||||
|
|
||||||
@@ -51,7 +74,7 @@ def _get_agent():
|
|||||||
from timmy.agent import create_timmy
|
from timmy.agent import create_timmy
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_agent = create_timmy()
|
_agent = create_timmy(session_id=_DEFAULT_SESSION_ID)
|
||||||
logger.info("Session: Timmy agent initialized (singleton)")
|
logger.info("Session: Timmy agent initialized (singleton)")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Session: Failed to create Timmy agent: %s", 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
|
sid = session_id or _DEFAULT_SESSION_ID
|
||||||
agent = _get_agent()
|
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
|
# Pre-processing: extract user facts
|
||||||
_extract_facts(message)
|
_extract_facts(message)
|
||||||
|
|
||||||
|
# Inject deep-focus context when active
|
||||||
|
message = _prepend_focus_context(message)
|
||||||
|
|
||||||
# Run with session_id so Agno retrieves history from SQLite
|
# Run with session_id so Agno retrieves history from SQLite
|
||||||
try:
|
try:
|
||||||
run = await agent.arun(message, stream=False, session_id=sid)
|
run = await agent.arun(message, stream=False, session_id=sid)
|
||||||
response_text = run.content if hasattr(run, "content") else str(run)
|
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:
|
except Exception as exc:
|
||||||
logger.error("Session: agent.arun() failed: %s", 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
|
# Post-processing: clean up any leaked tool calls or chain-of-thought
|
||||||
response_text = _clean_response(response_text)
|
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
|
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
|
sid = session_id or _DEFAULT_SESSION_ID
|
||||||
agent = _get_agent()
|
agent = _get_agent()
|
||||||
|
session_logger = get_session_logger()
|
||||||
|
|
||||||
|
# Record user message before sending to agent
|
||||||
|
session_logger.record_message("user", message)
|
||||||
|
|
||||||
_extract_facts(message)
|
_extract_facts(message)
|
||||||
|
|
||||||
|
# Inject deep-focus context when active
|
||||||
|
message = _prepend_focus_context(message)
|
||||||
|
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
logger.error("Session: agent.arun() failed: %s", 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 a duck-typed object that callers can handle uniformly
|
||||||
return _ErrorRunOutput(
|
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
|
sid = session_id or _DEFAULT_SESSION_ID
|
||||||
agent = _get_agent()
|
agent = _get_agent()
|
||||||
|
session_logger = get_session_logger()
|
||||||
|
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
logger.error("Session: agent.acontinue_run() failed: %s", 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}")
|
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)
|
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:
|
def _clean_response(text: str) -> str:
|
||||||
"""Remove hallucinated tool calls and chain-of-thought narration.
|
"""Remove hallucinated tool calls and chain-of-thought narration.
|
||||||
|
|
||||||
|
|||||||
@@ -38,21 +38,23 @@ class SessionLogger:
|
|||||||
# In-memory buffer
|
# In-memory buffer
|
||||||
self._buffer: list[dict] = []
|
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.
|
"""Record a user message.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
role: "user" or "timmy"
|
role: "user" or "timmy"
|
||||||
content: The message content
|
content: The message content
|
||||||
|
confidence: Optional confidence score (0.0 to 1.0)
|
||||||
"""
|
"""
|
||||||
self._buffer.append(
|
entry = {
|
||||||
{
|
"type": "message",
|
||||||
"type": "message",
|
"role": role,
|
||||||
"role": role,
|
"content": content,
|
||||||
"content": content,
|
"timestamp": datetime.now().isoformat(),
|
||||||
"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:
|
def record_tool_call(self, tool_name: str, args: dict, result: str) -> None:
|
||||||
"""Record a tool call.
|
"""Record a tool call.
|
||||||
@@ -153,6 +155,84 @@ class SessionLogger:
|
|||||||
"decisions": sum(1 for e in entries if e.get("type") == "decision"),
|
"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
|
# Global session logger instance
|
||||||
_session_logger: SessionLogger | None = None
|
_session_logger: SessionLogger | None = None
|
||||||
@@ -185,3 +265,197 @@ def flush_session_logs() -> str:
|
|||||||
logger = get_session_logger()
|
logger = get_session_logger()
|
||||||
path = logger.flush()
|
path = logger.flush()
|
||||||
return str(path)
|
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)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user