diff --git a/.gitignore b/.gitignore index 0814e2b8..9d4ae250 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/data/scripture.db-shm b/data/scripture.db-shm deleted file mode 100644 index 6ae92050..00000000 Binary files a/data/scripture.db-shm and /dev/null differ diff --git a/data/scripture.db-wal b/data/scripture.db-wal deleted file mode 100644 index fb1319fe..00000000 Binary files a/data/scripture.db-wal and /dev/null differ diff --git a/src/config.py b/src/config.py index cbf60b19..d1aff094 100644 --- a/src/config.py +++ b/src/config.py @@ -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. diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 67e65207..d055adc2 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -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) diff --git a/src/dashboard/routes/thinking.py b/src/dashboard/routes/thinking.py new file mode 100644 index 00000000..791abedf --- /dev/null +++ b/src/dashboard/routes/thinking.py @@ -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 + ] diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index a0973599..455dbc50 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -30,6 +30,7 @@
TASKS BRIEFING + THINKING MISSION CONTROL SWARM SPARK diff --git a/src/dashboard/templates/thinking.html b/src/dashboard/templates/thinking.html new file mode 100644 index 00000000..b9167cf3 --- /dev/null +++ b/src/dashboard/templates/thinking.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} + +{% block title %}Timmy Time — Thought Stream{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+ +
+
Thought Stream
+
+ Timmy's inner monologue — always thinking, always pondering. +
+
+ +
+
+ // INNER THOUGHTS + {{ thoughts | length }} thoughts +
+
+ + {% if thoughts %} + {% for thought in thoughts %} +
+
+ {{ thought.seed_type }} + {{ thought.created_at[:19] }} + {% if thought.parent_id %} + chain + {% endif %} +
+
{{ thought.content | e }}
+
+ {% endfor %} + {% else %} +
+ Timmy hasn't had any thoughts yet. The thinking thread will begin shortly after startup. +
+ {% endif %} + +
+
+ +
+{% endblock %} diff --git a/src/swarm/event_log.py b/src/swarm/event_log.py index 7b6ec0a4..6fda588c 100644 --- a/src/swarm/event_log.py +++ b/src/swarm/event_log.py @@ -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" diff --git a/src/timmy/thinking.py b/src/timmy/thinking.py new file mode 100644 index 00000000..1d3b0259 --- /dev/null +++ b/src/timmy/thinking.py @@ -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() diff --git a/tests/timmy/test_thinking.py b/tests/timmy/test_thinking.py new file mode 100644 index 00000000..d510b99f --- /dev/null +++ b/tests/timmy/test_thinking.py @@ -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