From 849b5b1a8d6602389e3b2a20737758af01747ee9 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone <8633216+AlexanderWhitestone@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:00:11 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20add=20default=20thinking=20thread=20?= =?UTF-8?q?=E2=80=94=20Timmy=20always=20ponders=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- data/scripture.db-shm | Bin 32768 -> 0 bytes data/scripture.db-wal | Bin 90672 -> 0 bytes src/config.py | 6 + src/dashboard/app.py | 37 +++ src/dashboard/routes/thinking.py | 65 +++++ src/dashboard/templates/base.html | 1 + src/dashboard/templates/thinking.html | 142 ++++++++++ src/swarm/event_log.py | 3 + src/timmy/thinking.py | 372 +++++++++++++++++++++++++ tests/timmy/test_thinking.py | 382 ++++++++++++++++++++++++++ 11 files changed, 1012 insertions(+), 1 deletion(-) delete mode 100644 data/scripture.db-shm delete mode 100644 data/scripture.db-wal create mode 100644 src/dashboard/routes/thinking.py create mode 100644 src/dashboard/templates/thinking.html create mode 100644 src/timmy/thinking.py create mode 100644 tests/timmy/test_thinking.py 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 6ae92050fc260b3e1c688bdd62cfb2787f8e258a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI*KS~2Z6bImm8ly34jDHAt21{>ZVQFEP3wQ%Nt6ab<*a}`i&`z+_OITRwJeWW# z2fMKI{UE$aX4&2MrJpzbJWGr8QW~G+FGu-v@$vpWdwRKk>z_Z|UOiqu-@VRnPQUJ_ z|Ga*2@3`;JQkH*@b$)i*Ds{WmVXix6Jt}p#)V*By%X*yaL0L~qJCOz@WRW>5M={AZBU^fi?wVii{9wQy^w0 z34t~RVp5n8Xj33&;|YN_1!5|s5NJ~%W}pgzHU*B`Y+Kg^vOvt+76Jqa{3Y-U)}bt~ diff --git a/data/scripture.db-wal b/data/scripture.db-wal deleted file mode 100644 index fb1319fe99d5f204ecbc78051257a9c373f6b34c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 90672 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 i@FVy{okQ#_2!H?xfB*=900@8p2!H?xfB* 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