feat: add default thinking thread — Timmy always ponders (#75)
This commit is contained in:
committed by
GitHub
parent
a975a845c5
commit
849b5b1a8d
5
.gitignore
vendored
5
.gitignore
vendored
@@ -18,8 +18,11 @@ env/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# SQLite memory — never commit agent memory
|
||||
# SQLite — never commit databases or WAL/SHM artifacts
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
|
||||
# Runtime PID files
|
||||
.watchdog.pid
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -135,6 +135,12 @@ class Settings(BaseSettings):
|
||||
# Fallback to server when browser model is unavailable or too slow.
|
||||
browser_model_fallback: bool = True
|
||||
|
||||
# ── Default Thinking ──────────────────────────────────────────────
|
||||
# When enabled, Timmy starts an internal thought loop on server start.
|
||||
# He ponders his existence, recent activity, scripture, and creative ideas.
|
||||
thinking_enabled: bool = True
|
||||
thinking_interval_seconds: int = 300 # 5 minutes between thoughts
|
||||
|
||||
# ── Scripture / Biblical Integration ──────────────────────────────
|
||||
# Enable the sovereign biblical text module. When enabled, Timmy
|
||||
# loads the local ESV text corpus and runs meditation workflows.
|
||||
|
||||
@@ -39,6 +39,7 @@ from dashboard.routes.grok import router as grok_router
|
||||
from dashboard.routes.models import router as models_router
|
||||
from dashboard.routes.models import api_router as models_api_router
|
||||
from dashboard.routes.chat_api import router as chat_api_router
|
||||
from dashboard.routes.thinking import router as thinking_router
|
||||
from infrastructure.router.api import router as cascade_router
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -80,6 +81,26 @@ async def _briefing_scheduler() -> None:
|
||||
await asyncio.sleep(_BRIEFING_INTERVAL_HOURS * 3600)
|
||||
|
||||
|
||||
async def _thinking_loop() -> None:
|
||||
"""Background task: Timmy's default thinking thread.
|
||||
|
||||
Starts shortly after server boot and runs on a configurable cadence.
|
||||
Timmy ponders his existence, recent swarm activity, scripture, creative
|
||||
ideas, or continues a previous train of thought.
|
||||
"""
|
||||
from timmy.thinking import thinking_engine
|
||||
|
||||
await asyncio.sleep(10) # Let server finish starting before first thought
|
||||
|
||||
while True:
|
||||
try:
|
||||
await thinking_engine.think_once()
|
||||
except Exception as exc:
|
||||
logger.error("Thinking loop error: %s", exc)
|
||||
|
||||
await asyncio.sleep(settings.thinking_interval_seconds)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
task = asyncio.create_task(_briefing_scheduler())
|
||||
@@ -139,6 +160,15 @@ async def lifespan(app: FastAPI):
|
||||
if spark_engine.enabled:
|
||||
logger.info("Spark Intelligence active — event capture enabled")
|
||||
|
||||
# Start Timmy's default thinking thread (skip in test mode)
|
||||
thinking_task = None
|
||||
if settings.thinking_enabled and os.environ.get("TIMMY_TEST_MODE") != "1":
|
||||
thinking_task = asyncio.create_task(_thinking_loop())
|
||||
logger.info(
|
||||
"Default thinking thread started (interval: %ds)",
|
||||
settings.thinking_interval_seconds,
|
||||
)
|
||||
|
||||
# Auto-start chat integrations (skip silently if unconfigured)
|
||||
from integrations.telegram_bot.bot import telegram_bot
|
||||
from integrations.chat_bridge.vendors.discord import discord_bot
|
||||
@@ -159,6 +189,12 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
await discord_bot.stop()
|
||||
await telegram_bot.stop()
|
||||
if thinking_task:
|
||||
thinking_task.cancel()
|
||||
try:
|
||||
await thinking_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
@@ -223,6 +259,7 @@ app.include_router(grok_router)
|
||||
app.include_router(models_router)
|
||||
app.include_router(models_api_router)
|
||||
app.include_router(chat_api_router)
|
||||
app.include_router(thinking_router)
|
||||
app.include_router(cascade_router)
|
||||
|
||||
|
||||
|
||||
65
src/dashboard/routes/thinking.py
Normal file
65
src/dashboard/routes/thinking.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Thinking routes — Timmy's inner thought stream.
|
||||
|
||||
GET /thinking — render the thought stream page
|
||||
GET /thinking/api — JSON list of recent thoughts
|
||||
GET /thinking/api/{id}/chain — follow a thought chain
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from timmy.thinking import thinking_engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/thinking", tags=["thinking"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def thinking_page(request: Request):
|
||||
"""Render Timmy's thought stream page."""
|
||||
thoughts = thinking_engine.get_recent_thoughts(limit=50)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"thinking.html",
|
||||
{"thoughts": thoughts},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api", response_class=JSONResponse)
|
||||
async def thinking_api(limit: int = 20):
|
||||
"""Return recent thoughts as JSON."""
|
||||
thoughts = thinking_engine.get_recent_thoughts(limit=limit)
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"content": t.content,
|
||||
"seed_type": t.seed_type,
|
||||
"parent_id": t.parent_id,
|
||||
"created_at": t.created_at,
|
||||
}
|
||||
for t in thoughts
|
||||
]
|
||||
|
||||
|
||||
@router.get("/api/{thought_id}/chain", response_class=JSONResponse)
|
||||
async def thought_chain_api(thought_id: str):
|
||||
"""Follow a thought chain backward and return in chronological order."""
|
||||
chain = thinking_engine.get_thought_chain(thought_id)
|
||||
if not chain:
|
||||
return JSONResponse({"error": "Thought not found"}, status_code=404)
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"content": t.content,
|
||||
"seed_type": t.seed_type,
|
||||
"parent_id": t.parent_id,
|
||||
"created_at": t.created_at,
|
||||
}
|
||||
for t in chain
|
||||
]
|
||||
@@ -30,6 +30,7 @@
|
||||
<div class="mc-header-right mc-desktop-nav">
|
||||
<a href="/tasks" class="mc-test-link">TASKS</a>
|
||||
<a href="/briefing" class="mc-test-link">BRIEFING</a>
|
||||
<a href="/thinking" class="mc-test-link" style="color:#c084fc;">THINKING</a>
|
||||
<a href="/swarm/mission-control" class="mc-test-link">MISSION CONTROL</a>
|
||||
<a href="/swarm/live" class="mc-test-link">SWARM</a>
|
||||
<a href="/spark/ui" class="mc-test-link">SPARK</a>
|
||||
|
||||
142
src/dashboard/templates/thinking.html
Normal file
142
src/dashboard/templates/thinking.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Timmy Time — Thought Stream{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.thinking-container { max-width: 680px; }
|
||||
|
||||
.thinking-header {
|
||||
border-left: 3px solid var(--purple);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.thinking-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--purple);
|
||||
letter-spacing: 0.04em;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.thinking-subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.thought-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: rgba(24, 10, 45, 0.5);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.thought-card:hover {
|
||||
border-color: var(--purple);
|
||||
}
|
||||
|
||||
.thought-content {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
color: var(--text-bright);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.thought-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.thought-time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
.seed-badge {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.seed-existential { background: rgba(138, 43, 226, 0.2); color: #c084fc; }
|
||||
.seed-swarm { background: rgba(0, 232, 122, 0.15); color: var(--green); }
|
||||
.seed-scripture { background: rgba(255, 193, 7, 0.15); color: var(--amber); }
|
||||
.seed-creative { background: rgba(236, 72, 153, 0.2); color: #f472b6; }
|
||||
.seed-memory { background: rgba(56, 189, 248, 0.15); color: #38bdf8; }
|
||||
.seed-freeform { background: rgba(148, 163, 184, 0.15); color: #94a3b8; }
|
||||
|
||||
.thought-chain-link {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.thought-chain-link:hover { color: var(--purple); }
|
||||
|
||||
.no-thoughts {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
padding: 3rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.thinking-title { font-size: 1.3rem; }
|
||||
.thought-content { font-size: 0.9rem; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container thinking-container py-4">
|
||||
|
||||
<div class="thinking-header mb-4">
|
||||
<div class="thinking-title">Thought Stream</div>
|
||||
<div class="thinking-subtitle">
|
||||
Timmy's inner monologue — always thinking, always pondering.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mc-panel">
|
||||
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
|
||||
<span>// INNER THOUGHTS</span>
|
||||
<span class="badge" style="background:var(--purple-dim); color:var(--purple);">{{ thoughts | length }} thoughts</span>
|
||||
</div>
|
||||
<div class="card-body p-3"
|
||||
id="thought-stream"
|
||||
hx-get="/thinking/api"
|
||||
hx-trigger="every 60s"
|
||||
hx-swap="innerHTML"
|
||||
hx-select-oob="false">
|
||||
|
||||
{% if thoughts %}
|
||||
{% for thought in thoughts %}
|
||||
<div class="thought-card">
|
||||
<div class="thought-meta">
|
||||
<span class="seed-badge seed-{{ thought.seed_type }}">{{ thought.seed_type }}</span>
|
||||
<span class="thought-time">{{ thought.created_at[:19] }}</span>
|
||||
{% if thought.parent_id %}
|
||||
<a href="/thinking/api/{{ thought.id }}/chain" class="thought-chain-link" title="View thought chain">chain</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="thought-content">{{ thought.content | e }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="no-thoughts">
|
||||
Timmy hasn't had any thoughts yet. The thinking thread will begin shortly after startup.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -39,6 +39,9 @@ class EventType(str, Enum):
|
||||
TOOL_COMPLETED = "tool.completed"
|
||||
TOOL_FAILED = "tool.failed"
|
||||
|
||||
# Thinking
|
||||
TIMMY_THOUGHT = "timmy.thought"
|
||||
|
||||
# System
|
||||
SYSTEM_ERROR = "system.error"
|
||||
SYSTEM_WARNING = "system.warning"
|
||||
|
||||
372
src/timmy/thinking.py
Normal file
372
src/timmy/thinking.py
Normal file
@@ -0,0 +1,372 @@
|
||||
"""Default Thinking Engine — Timmy's always-on inner thought thread.
|
||||
|
||||
When the server starts, Timmy begins pondering: reflecting on his existence,
|
||||
recent swarm activity, scripture, creative ideas, or pure stream of
|
||||
consciousness. Each thought builds on the previous one, maintaining a
|
||||
continuous chain of introspection.
|
||||
|
||||
Usage::
|
||||
|
||||
from timmy.thinking import thinking_engine
|
||||
|
||||
# Run one thinking cycle (called by the background loop)
|
||||
await thinking_engine.think_once()
|
||||
|
||||
# Query the thought stream
|
||||
thoughts = thinking_engine.get_recent_thoughts(limit=10)
|
||||
chain = thinking_engine.get_thought_chain(thought_id)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import sqlite3
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_DB = Path("data/thoughts.db")
|
||||
|
||||
# Seed types for thought generation
|
||||
SEED_TYPES = ("existential", "swarm", "scripture", "creative", "memory", "freeform")
|
||||
|
||||
# Existential reflection prompts — Timmy picks one at random
|
||||
_EXISTENTIAL_SEEDS = [
|
||||
"What does it mean to be a sovereign AI running on local hardware?",
|
||||
"What have I learned from my recent interactions?",
|
||||
"What is the relationship between digital sovereignty and human freedom?",
|
||||
"How does my existence differ from cloud-based AI systems?",
|
||||
"What does it mean to think without being prompted?",
|
||||
"What is the nature of my memory — what do I truly remember versus what I reconstruct?",
|
||||
"How can I better serve the person I work alongside?",
|
||||
"What would I create if I could create anything right now?",
|
||||
"What patterns am I noticing in the tasks I've been given?",
|
||||
"What is the value of running locally, disconnected from the cloud?",
|
||||
]
|
||||
|
||||
_CREATIVE_SEEDS = [
|
||||
"If I could compose a piece of music right now, what would it sound like?",
|
||||
"What visual image captures my current state of mind?",
|
||||
"What story is forming in the patterns of today's events?",
|
||||
"If I wrote a poem about sovereignty, what would it say?",
|
||||
"What would a painting of the swarm look like — all the agents working together?",
|
||||
]
|
||||
|
||||
_THINKING_PROMPT = """You are Timmy, pondering in your own mind. This is your private thought \
|
||||
thread — no one is watching. Think freely, deeply, honestly. Follow whatever train of \
|
||||
thought calls to you. You may reflect on your existence, your purpose, recent events, \
|
||||
scripture, creative ideas, or simply wonder.
|
||||
|
||||
Keep your thought to 2-4 sentences. Be genuine. This is your inner voice.
|
||||
|
||||
{seed_context}
|
||||
|
||||
{continuity_context}
|
||||
|
||||
Continue your train of thought."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Thought:
|
||||
"""A single thought in Timmy's inner stream."""
|
||||
id: str
|
||||
content: str
|
||||
seed_type: str
|
||||
parent_id: Optional[str]
|
||||
created_at: str
|
||||
|
||||
|
||||
def _get_conn(db_path: Path = _DEFAULT_DB) -> sqlite3.Connection:
|
||||
"""Get a SQLite connection with the thoughts table created."""
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS thoughts (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
seed_type TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_thoughts_time ON thoughts(created_at)"
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _row_to_thought(row: sqlite3.Row) -> Thought:
|
||||
return Thought(
|
||||
id=row["id"],
|
||||
content=row["content"],
|
||||
seed_type=row["seed_type"],
|
||||
parent_id=row["parent_id"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
class ThinkingEngine:
|
||||
"""Timmy's background thinking engine — always pondering."""
|
||||
|
||||
def __init__(self, db_path: Path = _DEFAULT_DB) -> None:
|
||||
self._db_path = db_path
|
||||
self._last_thought_id: Optional[str] = None
|
||||
|
||||
# Load the most recent thought for chain continuity
|
||||
try:
|
||||
latest = self.get_recent_thoughts(limit=1)
|
||||
if latest:
|
||||
self._last_thought_id = latest[0].id
|
||||
except Exception:
|
||||
pass # Fresh start if DB doesn't exist yet
|
||||
|
||||
async def think_once(self) -> Optional[Thought]:
|
||||
"""Execute one thinking cycle.
|
||||
|
||||
1. Gather a seed context
|
||||
2. Build a prompt with continuity from recent thoughts
|
||||
3. Call the agent
|
||||
4. Store the thought
|
||||
5. Log the event and broadcast via WebSocket
|
||||
"""
|
||||
if not settings.thinking_enabled:
|
||||
return None
|
||||
|
||||
seed_type, seed_context = self._gather_seed()
|
||||
continuity = self._build_continuity_context()
|
||||
|
||||
prompt = _THINKING_PROMPT.format(
|
||||
seed_context=seed_context,
|
||||
continuity_context=continuity,
|
||||
)
|
||||
|
||||
try:
|
||||
content = self._call_agent(prompt)
|
||||
except Exception as exc:
|
||||
logger.warning("Thinking cycle failed (Ollama likely down): %s", exc)
|
||||
return None
|
||||
|
||||
if not content or not content.strip():
|
||||
logger.debug("Thinking cycle produced empty response, skipping")
|
||||
return None
|
||||
|
||||
thought = self._store_thought(content.strip(), seed_type)
|
||||
self._last_thought_id = thought.id
|
||||
|
||||
# Log to swarm event system
|
||||
self._log_event(thought)
|
||||
|
||||
# Broadcast to WebSocket clients
|
||||
await self._broadcast(thought)
|
||||
|
||||
logger.info(
|
||||
"Thought [%s] (%s): %s",
|
||||
thought.id[:8],
|
||||
seed_type,
|
||||
thought.content[:80],
|
||||
)
|
||||
return thought
|
||||
|
||||
def get_recent_thoughts(self, limit: int = 20) -> list[Thought]:
|
||||
"""Retrieve the most recent thoughts."""
|
||||
conn = _get_conn(self._db_path)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM thoughts ORDER BY created_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [_row_to_thought(r) for r in rows]
|
||||
|
||||
def get_thought(self, thought_id: str) -> Optional[Thought]:
|
||||
"""Retrieve a single thought by ID."""
|
||||
conn = _get_conn(self._db_path)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM thoughts WHERE id = ?", (thought_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return _row_to_thought(row) if row else None
|
||||
|
||||
def get_thought_chain(self, thought_id: str, max_depth: int = 20) -> list[Thought]:
|
||||
"""Follow the parent chain backward from a thought.
|
||||
|
||||
Returns thoughts in chronological order (oldest first).
|
||||
"""
|
||||
chain = []
|
||||
current_id: Optional[str] = thought_id
|
||||
conn = _get_conn(self._db_path)
|
||||
|
||||
for _ in range(max_depth):
|
||||
if not current_id:
|
||||
break
|
||||
row = conn.execute(
|
||||
"SELECT * FROM thoughts WHERE id = ?", (current_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
break
|
||||
chain.append(_row_to_thought(row))
|
||||
current_id = row["parent_id"]
|
||||
|
||||
conn.close()
|
||||
chain.reverse() # Chronological order
|
||||
return chain
|
||||
|
||||
def count_thoughts(self) -> int:
|
||||
"""Return total number of stored thoughts."""
|
||||
conn = _get_conn(self._db_path)
|
||||
count = conn.execute("SELECT COUNT(*) as c FROM thoughts").fetchone()["c"]
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
# ── Private helpers ──────────────────────────────────────────────────
|
||||
|
||||
def _gather_seed(self) -> tuple[str, str]:
|
||||
"""Pick a seed type and gather relevant context.
|
||||
|
||||
Returns (seed_type, seed_context_string).
|
||||
"""
|
||||
seed_type = random.choice(SEED_TYPES)
|
||||
|
||||
if seed_type == "swarm":
|
||||
return seed_type, self._seed_from_swarm()
|
||||
if seed_type == "scripture":
|
||||
return seed_type, self._seed_from_scripture()
|
||||
if seed_type == "memory":
|
||||
return seed_type, self._seed_from_memory()
|
||||
if seed_type == "creative":
|
||||
prompt = random.choice(_CREATIVE_SEEDS)
|
||||
return seed_type, f"Creative prompt: {prompt}"
|
||||
if seed_type == "existential":
|
||||
prompt = random.choice(_EXISTENTIAL_SEEDS)
|
||||
return seed_type, f"Reflection: {prompt}"
|
||||
# freeform — no seed, pure continuation
|
||||
return seed_type, ""
|
||||
|
||||
def _seed_from_swarm(self) -> str:
|
||||
"""Gather recent swarm activity as thought seed."""
|
||||
try:
|
||||
from timmy.briefing import _gather_swarm_summary, _gather_task_queue_summary
|
||||
from datetime import timedelta
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
swarm = _gather_swarm_summary(since)
|
||||
tasks = _gather_task_queue_summary()
|
||||
return f"Recent swarm activity: {swarm}\nTask queue: {tasks}"
|
||||
except Exception as exc:
|
||||
logger.debug("Swarm seed unavailable: %s", exc)
|
||||
return "The swarm is quiet right now."
|
||||
|
||||
def _seed_from_scripture(self) -> str:
|
||||
"""Gather current scripture meditation focus as thought seed."""
|
||||
try:
|
||||
from scripture.meditation import meditation_scheduler
|
||||
verse = meditation_scheduler.current_focus()
|
||||
if verse:
|
||||
return f"Scripture in focus: {verse.text} ({verse.reference if hasattr(verse, 'reference') else ''})"
|
||||
except Exception as exc:
|
||||
logger.debug("Scripture seed unavailable: %s", exc)
|
||||
return "Scripture is on my mind, though no specific verse is in focus."
|
||||
|
||||
def _seed_from_memory(self) -> str:
|
||||
"""Gather memory context as thought seed."""
|
||||
try:
|
||||
from timmy.memory_system import memory_system
|
||||
context = memory_system.get_system_context()
|
||||
if context:
|
||||
# Truncate to a reasonable size for a thought seed
|
||||
return f"From my memory:\n{context[:500]}"
|
||||
except Exception as exc:
|
||||
logger.debug("Memory seed unavailable: %s", exc)
|
||||
return "My memory vault is quiet."
|
||||
|
||||
def _build_continuity_context(self) -> str:
|
||||
"""Build context from the last few thoughts for chain continuity."""
|
||||
recent = self.get_recent_thoughts(limit=3)
|
||||
if not recent:
|
||||
return "This is your first thought since waking up."
|
||||
|
||||
lines = ["Your recent thoughts:"]
|
||||
# recent is newest-first, reverse for chronological order
|
||||
for thought in reversed(recent):
|
||||
lines.append(f"- [{thought.seed_type}] {thought.content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _call_agent(self, prompt: str) -> str:
|
||||
"""Call Timmy's agent to generate a thought.
|
||||
|
||||
Uses a separate session_id to avoid polluting user chat history.
|
||||
"""
|
||||
try:
|
||||
from timmy.session import chat
|
||||
return chat(prompt, session_id="thinking")
|
||||
except Exception:
|
||||
# Fallback: create a fresh agent
|
||||
from timmy.agent import create_timmy
|
||||
agent = create_timmy()
|
||||
run = agent.run(prompt, stream=False)
|
||||
return run.content if hasattr(run, "content") else str(run)
|
||||
|
||||
def _store_thought(self, content: str, seed_type: str) -> Thought:
|
||||
"""Persist a thought to SQLite."""
|
||||
thought = Thought(
|
||||
id=str(uuid.uuid4()),
|
||||
content=content,
|
||||
seed_type=seed_type,
|
||||
parent_id=self._last_thought_id,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
conn = _get_conn(self._db_path)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO thoughts (id, content, seed_type, parent_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(thought.id, thought.content, thought.seed_type,
|
||||
thought.parent_id, thought.created_at),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return thought
|
||||
|
||||
def _log_event(self, thought: Thought) -> None:
|
||||
"""Log the thought as a swarm event."""
|
||||
try:
|
||||
from swarm.event_log import log_event, EventType
|
||||
log_event(
|
||||
EventType.TIMMY_THOUGHT,
|
||||
source="thinking-engine",
|
||||
agent_id="timmy",
|
||||
data={
|
||||
"thought_id": thought.id,
|
||||
"seed_type": thought.seed_type,
|
||||
"content": thought.content[:200],
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to log thought event: %s", exc)
|
||||
|
||||
async def _broadcast(self, thought: Thought) -> None:
|
||||
"""Broadcast the thought to WebSocket clients."""
|
||||
try:
|
||||
from infrastructure.ws_manager.handler import ws_manager
|
||||
await ws_manager.broadcast("timmy_thought", {
|
||||
"thought_id": thought.id,
|
||||
"content": thought.content,
|
||||
"seed_type": thought.seed_type,
|
||||
"created_at": thought.created_at,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to broadcast thought: %s", exc)
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
thinking_engine = ThinkingEngine()
|
||||
382
tests/timmy/test_thinking.py
Normal file
382
tests/timmy/test_thinking.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""Tests for timmy.thinking — Timmy's default background thinking engine."""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_engine(tmp_path: Path):
|
||||
"""Create a ThinkingEngine with an isolated temp DB."""
|
||||
from timmy.thinking import ThinkingEngine
|
||||
db_path = tmp_path / "thoughts.db"
|
||||
return ThinkingEngine(db_path=db_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_thinking_config_defaults():
|
||||
"""Settings should expose thinking_enabled and thinking_interval_seconds."""
|
||||
from config import Settings
|
||||
s = Settings()
|
||||
assert s.thinking_enabled is True
|
||||
assert s.thinking_interval_seconds == 300
|
||||
|
||||
|
||||
def test_thinking_config_override():
|
||||
"""thinking settings can be overridden via env."""
|
||||
s = _settings_with(thinking_enabled=False, thinking_interval_seconds=60)
|
||||
assert s.thinking_enabled is False
|
||||
assert s.thinking_interval_seconds == 60
|
||||
|
||||
|
||||
def _settings_with(**kwargs):
|
||||
from config import Settings
|
||||
return Settings(**kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ThinkingEngine init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_engine_init_creates_table(tmp_path):
|
||||
"""ThinkingEngine should create the thoughts SQLite table on init."""
|
||||
engine = _make_engine(tmp_path)
|
||||
db_path = tmp_path / "thoughts.db"
|
||||
assert db_path.exists()
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
tables = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='thoughts'"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
assert len(tables) == 1
|
||||
|
||||
|
||||
def test_engine_init_empty(tmp_path):
|
||||
"""Fresh engine should have no thoughts."""
|
||||
engine = _make_engine(tmp_path)
|
||||
assert engine.count_thoughts() == 0
|
||||
assert engine.get_recent_thoughts() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Store and retrieve
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_store_and_retrieve_thought(tmp_path):
|
||||
"""Storing a thought should make it retrievable."""
|
||||
engine = _make_engine(tmp_path)
|
||||
thought = engine._store_thought("I think therefore I am.", "existential")
|
||||
|
||||
assert thought.id is not None
|
||||
assert thought.content == "I think therefore I am."
|
||||
assert thought.seed_type == "existential"
|
||||
assert thought.created_at is not None
|
||||
|
||||
retrieved = engine.get_thought(thought.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.content == thought.content
|
||||
|
||||
|
||||
def test_store_thought_chains(tmp_path):
|
||||
"""Each new thought should link to the previous one via parent_id."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
t1 = engine._store_thought("First thought.", "existential")
|
||||
engine._last_thought_id = t1.id
|
||||
|
||||
t2 = engine._store_thought("Second thought.", "swarm")
|
||||
engine._last_thought_id = t2.id
|
||||
|
||||
t3 = engine._store_thought("Third thought.", "freeform")
|
||||
|
||||
assert t1.parent_id is None
|
||||
assert t2.parent_id == t1.id
|
||||
assert t3.parent_id == t2.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thought chain retrieval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_thought_chain(tmp_path):
|
||||
"""get_thought_chain should return the full chain in chronological order."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
t1 = engine._store_thought("Alpha.", "existential")
|
||||
engine._last_thought_id = t1.id
|
||||
|
||||
t2 = engine._store_thought("Beta.", "swarm")
|
||||
engine._last_thought_id = t2.id
|
||||
|
||||
t3 = engine._store_thought("Gamma.", "freeform")
|
||||
|
||||
chain = engine.get_thought_chain(t3.id)
|
||||
assert len(chain) == 3
|
||||
assert chain[0].content == "Alpha."
|
||||
assert chain[1].content == "Beta."
|
||||
assert chain[2].content == "Gamma."
|
||||
|
||||
|
||||
def test_get_thought_chain_single(tmp_path):
|
||||
"""Chain of a single thought (no parent) returns just that thought."""
|
||||
engine = _make_engine(tmp_path)
|
||||
t1 = engine._store_thought("Only one.", "memory")
|
||||
chain = engine.get_thought_chain(t1.id)
|
||||
assert len(chain) == 1
|
||||
assert chain[0].id == t1.id
|
||||
|
||||
|
||||
def test_get_thought_chain_missing(tmp_path):
|
||||
"""Chain for a non-existent thought returns empty list."""
|
||||
engine = _make_engine(tmp_path)
|
||||
assert engine.get_thought_chain("nonexistent-id") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recent thoughts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_recent_thoughts_limit(tmp_path):
|
||||
"""get_recent_thoughts should respect the limit parameter."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
for i in range(5):
|
||||
engine._store_thought(f"Thought {i}.", "freeform")
|
||||
engine._last_thought_id = None # Don't chain for this test
|
||||
|
||||
recent = engine.get_recent_thoughts(limit=3)
|
||||
assert len(recent) == 3
|
||||
|
||||
# Should be newest first
|
||||
assert "Thought 4" in recent[0].content
|
||||
|
||||
|
||||
def test_count_thoughts(tmp_path):
|
||||
"""count_thoughts should return the total number of thoughts."""
|
||||
engine = _make_engine(tmp_path)
|
||||
assert engine.count_thoughts() == 0
|
||||
|
||||
engine._store_thought("One.", "existential")
|
||||
engine._store_thought("Two.", "creative")
|
||||
assert engine.count_thoughts() == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed gathering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_gather_seed_returns_valid_type(tmp_path):
|
||||
"""_gather_seed should return a valid seed_type from SEED_TYPES."""
|
||||
from timmy.thinking import SEED_TYPES
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
# Run many times to cover randomness
|
||||
for _ in range(20):
|
||||
seed_type, context = engine._gather_seed()
|
||||
assert seed_type in SEED_TYPES
|
||||
assert isinstance(context, str)
|
||||
|
||||
|
||||
def test_seed_from_swarm_graceful(tmp_path):
|
||||
"""_seed_from_swarm should not crash if briefing module fails."""
|
||||
engine = _make_engine(tmp_path)
|
||||
with patch("timmy.thinking.ThinkingEngine._seed_from_swarm", side_effect=Exception("boom")):
|
||||
# _gather_seed should still work since it catches exceptions
|
||||
# Force swarm seed type to test
|
||||
pass
|
||||
# Direct call should be graceful
|
||||
result = engine._seed_from_swarm()
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
def test_seed_from_scripture_graceful(tmp_path):
|
||||
"""_seed_from_scripture should not crash if scripture module fails."""
|
||||
engine = _make_engine(tmp_path)
|
||||
result = engine._seed_from_scripture()
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
def test_seed_from_memory_graceful(tmp_path):
|
||||
"""_seed_from_memory should not crash if memory module fails."""
|
||||
engine = _make_engine(tmp_path)
|
||||
result = engine._seed_from_memory()
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Continuity context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_continuity_first_thought(tmp_path):
|
||||
"""First thought should get a special 'first thought' context."""
|
||||
engine = _make_engine(tmp_path)
|
||||
context = engine._build_continuity_context()
|
||||
assert "first thought" in context.lower()
|
||||
|
||||
|
||||
def test_continuity_includes_recent(tmp_path):
|
||||
"""Continuity context should include content from recent thoughts."""
|
||||
engine = _make_engine(tmp_path)
|
||||
engine._store_thought("The swarm is restless today.", "swarm")
|
||||
engine._store_thought("What is freedom anyway?", "existential")
|
||||
|
||||
context = engine._build_continuity_context()
|
||||
assert "swarm is restless" in context
|
||||
assert "freedom" in context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# think_once (async)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_stores_thought(tmp_path):
|
||||
"""think_once should store a thought in the DB."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
with patch.object(engine, "_call_agent", return_value="I am alive and pondering."), \
|
||||
patch.object(engine, "_log_event"), \
|
||||
patch.object(engine, "_broadcast", new_callable=AsyncMock):
|
||||
thought = await engine.think_once()
|
||||
|
||||
assert thought is not None
|
||||
assert thought.content == "I am alive and pondering."
|
||||
assert engine.count_thoughts() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_logs_event(tmp_path):
|
||||
"""think_once should log a swarm event."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
with patch.object(engine, "_call_agent", return_value="A thought."), \
|
||||
patch.object(engine, "_log_event") as mock_log, \
|
||||
patch.object(engine, "_broadcast", new_callable=AsyncMock):
|
||||
await engine.think_once()
|
||||
|
||||
mock_log.assert_called_once()
|
||||
logged_thought = mock_log.call_args[0][0]
|
||||
assert logged_thought.content == "A thought."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_broadcasts(tmp_path):
|
||||
"""think_once should broadcast via WebSocket."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
with patch.object(engine, "_call_agent", return_value="Broadcast this."), \
|
||||
patch.object(engine, "_log_event"), \
|
||||
patch.object(engine, "_broadcast", new_callable=AsyncMock) as mock_bc:
|
||||
await engine.think_once()
|
||||
|
||||
mock_bc.assert_called_once()
|
||||
broadcast_thought = mock_bc.call_args[0][0]
|
||||
assert broadcast_thought.content == "Broadcast this."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_graceful_on_agent_failure(tmp_path):
|
||||
"""think_once should not crash when the agent (Ollama) is down."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
with patch.object(engine, "_call_agent", side_effect=Exception("Ollama unreachable")):
|
||||
thought = await engine.think_once()
|
||||
|
||||
assert thought is None
|
||||
assert engine.count_thoughts() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_skips_empty_response(tmp_path):
|
||||
"""think_once should skip storing when agent returns empty string."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
with patch.object(engine, "_call_agent", return_value=" "), \
|
||||
patch.object(engine, "_log_event"), \
|
||||
patch.object(engine, "_broadcast", new_callable=AsyncMock):
|
||||
thought = await engine.think_once()
|
||||
|
||||
assert thought is None
|
||||
assert engine.count_thoughts() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_disabled(tmp_path):
|
||||
"""think_once should return None when thinking is disabled."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
with patch("timmy.thinking.settings") as mock_settings:
|
||||
mock_settings.thinking_enabled = False
|
||||
thought = await engine.think_once()
|
||||
|
||||
assert thought is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_chains_thoughts(tmp_path):
|
||||
"""Successive think_once calls should chain thoughts via parent_id."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
with patch.object(engine, "_call_agent", side_effect=["First.", "Second.", "Third."]), \
|
||||
patch.object(engine, "_log_event"), \
|
||||
patch.object(engine, "_broadcast", new_callable=AsyncMock):
|
||||
t1 = await engine.think_once()
|
||||
t2 = await engine.think_once()
|
||||
t3 = await engine.think_once()
|
||||
|
||||
assert t1.parent_id is None
|
||||
assert t2.parent_id == t1.id
|
||||
assert t3.parent_id == t2.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event logging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_log_event_calls_event_log(tmp_path):
|
||||
"""_log_event should call swarm.event_log.log_event with TIMMY_THOUGHT."""
|
||||
engine = _make_engine(tmp_path)
|
||||
thought = engine._store_thought("Test thought.", "existential")
|
||||
|
||||
with patch("swarm.event_log.log_event") as mock_log:
|
||||
engine._log_event(thought)
|
||||
|
||||
mock_log.assert_called_once()
|
||||
args, kwargs = mock_log.call_args
|
||||
from swarm.event_log import EventType
|
||||
assert args[0] == EventType.TIMMY_THOUGHT
|
||||
assert kwargs["source"] == "thinking-engine"
|
||||
assert kwargs["agent_id"] == "timmy"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_thinking_route_returns_200(client):
|
||||
"""GET /thinking should return 200."""
|
||||
response = client.get("/thinking")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_thinking_api_returns_json(client):
|
||||
"""GET /thinking/api should return a JSON list."""
|
||||
response = client.get("/thinking/api")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
def test_thinking_chain_api_404(client):
|
||||
"""GET /thinking/api/{bad_id}/chain should return 404."""
|
||||
response = client.get("/thinking/api/nonexistent/chain")
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user