diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 2e3b074a..1f2084a7 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -38,6 +38,8 @@ from dashboard.routes.chat_api import router as chat_api_router from dashboard.routes.thinking import router as thinking_router from dashboard.routes.calm import router as calm_router from dashboard.routes.swarm import router as swarm_router +from dashboard.routes.tasks import router as tasks_router +from dashboard.routes.work_orders import router as work_orders_router from dashboard.routes.system import router as system_router from dashboard.routes.paperclip import router as paperclip_router from infrastructure.router.api import router as cascade_router @@ -333,6 +335,8 @@ app.include_router(chat_api_router) app.include_router(thinking_router) app.include_router(calm_router) app.include_router(swarm_router) +app.include_router(tasks_router) +app.include_router(work_orders_router) app.include_router(system_router) app.include_router(paperclip_router) app.include_router(cascade_router) diff --git a/src/dashboard/models/database.py b/src/dashboard/models/database.py index 10877b05..0994996f 100644 --- a/src/dashboard/models/database.py +++ b/src/dashboard/models/database.py @@ -12,6 +12,11 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() +def create_tables(): + """Create all tables defined by models that have imported Base.""" + Base.metadata.create_all(bind=engine) + + def get_db(): db = SessionLocal() try: diff --git a/src/dashboard/routes/calm.py b/src/dashboard/routes/calm.py index ff842e5c..46ebe077 100644 --- a/src/dashboard/routes/calm.py +++ b/src/dashboard/routes/calm.py @@ -8,9 +8,12 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from dashboard.models.calm import JournalEntry, Task, TaskCertainty, TaskState -from dashboard.models.database import SessionLocal, engine, get_db +from dashboard.models.database import SessionLocal, engine, get_db, create_tables from dashboard.templating import templates +# Ensure CALM tables exist (safe to call multiple times) +create_tables() + logger = logging.getLogger(__name__) router = APIRouter(tags=["calm"]) diff --git a/src/dashboard/routes/system.py b/src/dashboard/routes/system.py index 3651bc5b..b4a3cdca 100644 --- a/src/dashboard/routes/system.py +++ b/src/dashboard/routes/system.py @@ -77,11 +77,6 @@ async def self_modify_queue(request: Request): ) -@router.get("/tasks", response_class=HTMLResponse) -async def tasks_page(request: Request): - return templates.TemplateResponse(request, "tasks.html", {"tasks": []}) - - @router.get("/swarm/mission-control", response_class=HTMLResponse) async def mission_control(request: Request): return templates.TemplateResponse(request, "mission_control.html", {}) @@ -104,11 +99,6 @@ async def hands_page(request: Request): return templates.TemplateResponse(request, "hands.html", {"executions": []}) -@router.get("/work-orders/queue", response_class=HTMLResponse) -async def work_orders(request: Request): - return templates.TemplateResponse(request, "work_orders.html", {"orders": []}) - - @router.get("/creative/ui", response_class=HTMLResponse) async def creative_ui(request: Request): return templates.TemplateResponse(request, "creative.html", {}) diff --git a/src/dashboard/routes/tasks.py b/src/dashboard/routes/tasks.py new file mode 100644 index 00000000..0b1f9403 --- /dev/null +++ b/src/dashboard/routes/tasks.py @@ -0,0 +1,375 @@ +"""Task Queue routes — SQLite-backed CRUD for the task management dashboard.""" + +import logging +import sqlite3 +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request, Form +from fastapi.responses import HTMLResponse, JSONResponse + +from dashboard.templating import templates + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["tasks"]) + +# --------------------------------------------------------------------------- +# Database helpers +# --------------------------------------------------------------------------- + +DB_PATH = Path("data/tasks.db") + +VALID_STATUSES = { + "pending_approval", "approved", "running", "paused", + "completed", "vetoed", "failed", "backlogged", +} +VALID_PRIORITIES = {"low", "normal", "high", "urgent"} + + +def _get_db() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute(""" + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT DEFAULT '', + status TEXT DEFAULT 'pending_approval', + priority TEXT DEFAULT 'normal', + assigned_to TEXT DEFAULT '', + created_by TEXT DEFAULT 'operator', + result TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + completed_at TEXT + ) + """) + conn.commit() + return conn + + +def _row_to_dict(row: sqlite3.Row) -> dict: + return dict(row) + + +class _EnumLike: + """Thin wrapper so Jinja templates can use task.status.value.""" + + def __init__(self, v: str): + self.value = v + + def __str__(self): + return self.value + + def __eq__(self, other): + if isinstance(other, str): + return self.value == other + return NotImplemented + + +class _TaskView: + """Lightweight view object for Jinja template rendering.""" + + def __init__(self, row: dict): + self.id = row["id"] + self.title = row.get("title", "") + self.description = row.get("description", "") + self.status = _EnumLike(row.get("status", "pending_approval")) + self.priority = _EnumLike(row.get("priority", "normal")) + self.assigned_to = row.get("assigned_to", "") + self.created_by = row.get("created_by", "operator") + self.result = row.get("result", "") + self.created_at = row.get("created_at", "") + self.completed_at = row.get("completed_at") + self.steps = [] # reserved for future use + + +# --------------------------------------------------------------------------- +# Page routes +# --------------------------------------------------------------------------- + +@router.get("/tasks", response_class=HTMLResponse) +async def tasks_page(request: Request): + """Render the main task queue page with 3-column layout.""" + db = _get_db() + try: + pending = [_TaskView(_row_to_dict(r)) for r in db.execute( + "SELECT * FROM tasks WHERE status IN ('pending_approval') ORDER BY created_at DESC" + ).fetchall()] + active = [_TaskView(_row_to_dict(r)) for r in db.execute( + "SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC" + ).fetchall()] + completed = [_TaskView(_row_to_dict(r)) for r in db.execute( + "SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50" + ).fetchall()] + finally: + db.close() + + return templates.TemplateResponse(request, "tasks.html", { + "pending_count": len(pending), + "pending": pending, + "active": active, + "completed": completed, + "agents": [], # no agent roster wired yet + "pre_assign": "", + }) + + +# --------------------------------------------------------------------------- +# HTMX partials (polled by the template) +# --------------------------------------------------------------------------- + +@router.get("/tasks/pending", response_class=HTMLResponse) +async def tasks_pending(request: Request): + db = _get_db() + try: + rows = db.execute( + "SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC" + ).fetchall() + finally: + db.close() + tasks = [_TaskView(_row_to_dict(r)) for r in rows] + parts = [] + for task in tasks: + parts.append(templates.TemplateResponse( + request, "partials/task_card.html", {"task": task} + ).body.decode()) + if not parts: + return HTMLResponse('
No pending tasks
') + return HTMLResponse("".join(parts)) + + +@router.get("/tasks/active", response_class=HTMLResponse) +async def tasks_active(request: Request): + db = _get_db() + try: + rows = db.execute( + "SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC" + ).fetchall() + finally: + db.close() + tasks = [_TaskView(_row_to_dict(r)) for r in rows] + parts = [] + for task in tasks: + parts.append(templates.TemplateResponse( + request, "partials/task_card.html", {"task": task} + ).body.decode()) + if not parts: + return HTMLResponse('
No active tasks
') + return HTMLResponse("".join(parts)) + + +@router.get("/tasks/completed", response_class=HTMLResponse) +async def tasks_completed(request: Request): + db = _get_db() + try: + rows = db.execute( + "SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50" + ).fetchall() + finally: + db.close() + tasks = [_TaskView(_row_to_dict(r)) for r in rows] + parts = [] + for task in tasks: + parts.append(templates.TemplateResponse( + request, "partials/task_card.html", {"task": task} + ).body.decode()) + if not parts: + return HTMLResponse('
No completed tasks yet
') + return HTMLResponse("".join(parts)) + + +# --------------------------------------------------------------------------- +# Form-based create (used by the modal in tasks.html) +# --------------------------------------------------------------------------- + +@router.post("/tasks/create", response_class=HTMLResponse) +async def create_task_form( + request: Request, + title: str = Form(...), + description: str = Form(""), + priority: str = Form("normal"), + assigned_to: str = Form(""), +): + """Create a task from the modal form and return a task card partial.""" + task_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + priority = priority if priority in VALID_PRIORITIES else "normal" + + db = _get_db() + try: + db.execute( + "INSERT INTO tasks (id, title, description, priority, assigned_to, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (task_id, title, description, priority, assigned_to, now), + ) + db.commit() + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + finally: + db.close() + + task = _TaskView(_row_to_dict(row)) + return templates.TemplateResponse(request, "partials/task_card.html", {"task": task}) + + +# --------------------------------------------------------------------------- +# Task action endpoints (approve, veto, modify, pause, cancel, retry) +# --------------------------------------------------------------------------- + +@router.post("/tasks/{task_id}/approve", response_class=HTMLResponse) +async def approve_task(request: Request, task_id: str): + return await _set_status(request, task_id, "approved") + + +@router.post("/tasks/{task_id}/veto", response_class=HTMLResponse) +async def veto_task(request: Request, task_id: str): + return await _set_status(request, task_id, "vetoed") + + +@router.post("/tasks/{task_id}/pause", response_class=HTMLResponse) +async def pause_task(request: Request, task_id: str): + return await _set_status(request, task_id, "paused") + + +@router.post("/tasks/{task_id}/cancel", response_class=HTMLResponse) +async def cancel_task(request: Request, task_id: str): + return await _set_status(request, task_id, "vetoed") + + +@router.post("/tasks/{task_id}/retry", response_class=HTMLResponse) +async def retry_task(request: Request, task_id: str): + return await _set_status(request, task_id, "approved") + + +@router.post("/tasks/{task_id}/modify", response_class=HTMLResponse) +async def modify_task( + request: Request, + task_id: str, + title: str = Form(...), + description: str = Form(""), +): + db = _get_db() + try: + db.execute( + "UPDATE tasks SET title=?, description=? WHERE id=?", + (title, description, task_id), + ) + db.commit() + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + finally: + db.close() + if not row: + raise HTTPException(404, "Task not found") + task = _TaskView(_row_to_dict(row)) + return templates.TemplateResponse(request, "partials/task_card.html", {"task": task}) + + +async def _set_status(request: Request, task_id: str, new_status: str): + """Helper to update status and return refreshed task card.""" + completed_at = datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None + db = _get_db() + try: + db.execute( + "UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?", + (new_status, completed_at, task_id), + ) + db.commit() + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + finally: + db.close() + if not row: + raise HTTPException(404, "Task not found") + task = _TaskView(_row_to_dict(row)) + return templates.TemplateResponse(request, "partials/task_card.html", {"task": task}) + + +# --------------------------------------------------------------------------- +# JSON API (for programmatic access / Timmy's tool calls) +# --------------------------------------------------------------------------- + +@router.post("/api/tasks", response_class=JSONResponse, status_code=201) +async def api_create_task(request: Request): + """Create a task via JSON API.""" + body = await request.json() + title = body.get("title") + if not title: + raise HTTPException(422, "title is required") + + task_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + priority = body.get("priority", "normal") + if priority not in VALID_PRIORITIES: + priority = "normal" + + db = _get_db() + try: + db.execute( + "INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + task_id, + title, + body.get("description", ""), + priority, + body.get("assigned_to", ""), + body.get("created_by", "operator"), + now, + ), + ) + db.commit() + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + finally: + db.close() + + return JSONResponse(_row_to_dict(row), status_code=201) + + +@router.get("/api/tasks", response_class=JSONResponse) +async def api_list_tasks(): + """List all tasks as JSON.""" + db = _get_db() + try: + rows = db.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall() + finally: + db.close() + return JSONResponse([_row_to_dict(r) for r in rows]) + + +@router.patch("/api/tasks/{task_id}/status", response_class=JSONResponse) +async def api_update_status(task_id: str, request: Request): + """Update task status via JSON API.""" + body = await request.json() + new_status = body.get("status") + if not new_status or new_status not in VALID_STATUSES: + raise HTTPException(422, f"Invalid status. Must be one of: {VALID_STATUSES}") + + completed_at = datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None + db = _get_db() + try: + db.execute( + "UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?", + (new_status, completed_at, task_id), + ) + db.commit() + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + finally: + db.close() + if not row: + raise HTTPException(404, "Task not found") + return JSONResponse(_row_to_dict(row)) + + +@router.delete("/api/tasks/{task_id}", response_class=JSONResponse) +async def api_delete_task(task_id: str): + """Delete a task.""" + db = _get_db() + try: + cursor = db.execute("DELETE FROM tasks WHERE id=?", (task_id,)) + db.commit() + finally: + db.close() + if cursor.rowcount == 0: + raise HTTPException(404, "Task not found") + return JSONResponse({"success": True, "id": task_id}) diff --git a/src/dashboard/routes/work_orders.py b/src/dashboard/routes/work_orders.py new file mode 100644 index 00000000..1365f3e4 --- /dev/null +++ b/src/dashboard/routes/work_orders.py @@ -0,0 +1,239 @@ +"""Work Orders routes — SQLite-backed submit/review/execute pipeline.""" + +import logging +import sqlite3 +import uuid +from datetime import datetime +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request, Form +from fastapi.responses import HTMLResponse, JSONResponse + +from dashboard.templating import templates + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["work-orders"]) + +DB_PATH = Path("data/work_orders.db") + +PRIORITIES = ["low", "medium", "high", "critical"] +CATEGORIES = ["bug", "feature", "suggestion", "maintenance", "security"] +VALID_STATUSES = {"submitted", "triaged", "approved", "in_progress", "completed", "rejected"} + + +def _get_db() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute(""" + CREATE TABLE IF NOT EXISTS work_orders ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT DEFAULT '', + priority TEXT DEFAULT 'medium', + category TEXT DEFAULT 'suggestion', + submitter TEXT DEFAULT 'dashboard', + related_files TEXT DEFAULT '', + status TEXT DEFAULT 'submitted', + result TEXT DEFAULT '', + rejection_reason TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + completed_at TEXT + ) + """) + conn.commit() + return conn + + +class _EnumLike: + def __init__(self, v: str): + self.value = v + + def __str__(self): + return self.value + + def __eq__(self, other): + if isinstance(other, str): + return self.value == other + return NotImplemented + + +class _WOView: + """View object for Jinja template rendering.""" + + def __init__(self, row: dict): + self.id = row["id"] + self.title = row.get("title", "") + self.description = row.get("description", "") + self.priority = _EnumLike(row.get("priority", "medium")) + self.category = _EnumLike(row.get("category", "suggestion")) + self.submitter = row.get("submitter", "dashboard") + self.status = _EnumLike(row.get("status", "submitted")) + raw_files = row.get("related_files", "") + self.related_files = [f.strip() for f in raw_files.split(",") if f.strip()] if raw_files else [] + self.result = row.get("result", "") + self.rejection_reason = row.get("rejection_reason", "") + self.created_at = row.get("created_at", "") + self.completed_at = row.get("completed_at") + self.execution_mode = None + + +def _row_to_dict(row: sqlite3.Row) -> dict: + return dict(row) + + +def _query_wos(db, statuses): + placeholders = ",".join("?" for _ in statuses) + return [ + _WOView(_row_to_dict(r)) + for r in db.execute( + f"SELECT * FROM work_orders WHERE status IN ({placeholders}) ORDER BY created_at DESC", + statuses, + ).fetchall() + ] + + +# --------------------------------------------------------------------------- +# Page route +# --------------------------------------------------------------------------- + +@router.get("/work-orders/queue", response_class=HTMLResponse) +async def work_orders_page(request: Request): + db = _get_db() + try: + pending = _query_wos(db, ["submitted", "triaged"]) + active = _query_wos(db, ["approved", "in_progress"]) + completed = _query_wos(db, ["completed"]) + rejected = _query_wos(db, ["rejected"]) + finally: + db.close() + + return templates.TemplateResponse(request, "work_orders.html", { + "pending_count": len(pending), + "pending": pending, + "active": active, + "completed": completed, + "rejected": rejected, + "priorities": PRIORITIES, + "categories": CATEGORIES, + }) + + +# --------------------------------------------------------------------------- +# Form submit +# --------------------------------------------------------------------------- + +@router.post("/work-orders/submit", response_class=HTMLResponse) +async def submit_work_order( + request: Request, + title: str = Form(...), + description: str = Form(""), + priority: str = Form("medium"), + category: str = Form("suggestion"), + submitter: str = Form("dashboard"), + related_files: str = Form(""), +): + wo_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + priority = priority if priority in PRIORITIES else "medium" + category = category if category in CATEGORIES else "suggestion" + + db = _get_db() + try: + db.execute( + "INSERT INTO work_orders (id, title, description, priority, category, submitter, related_files, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (wo_id, title, description, priority, category, submitter, related_files, now), + ) + db.commit() + row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone() + finally: + db.close() + + wo = _WOView(_row_to_dict(row)) + return templates.TemplateResponse(request, "partials/work_order_card.html", {"wo": wo}) + + +# --------------------------------------------------------------------------- +# HTMX partials +# --------------------------------------------------------------------------- + +@router.get("/work-orders/queue/pending", response_class=HTMLResponse) +async def pending_partial(request: Request): + db = _get_db() + try: + wos = _query_wos(db, ["submitted", "triaged"]) + finally: + db.close() + if not wos: + return HTMLResponse( + '
' + "No pending work orders.
" + ) + parts = [] + for wo in wos: + parts.append( + templates.TemplateResponse(request, "partials/work_order_card.html", {"wo": wo}).body.decode() + ) + return HTMLResponse("".join(parts)) + + +@router.get("/work-orders/queue/active", response_class=HTMLResponse) +async def active_partial(request: Request): + db = _get_db() + try: + wos = _query_wos(db, ["approved", "in_progress"]) + finally: + db.close() + if not wos: + return HTMLResponse( + '
' + "No work orders currently in progress.
" + ) + parts = [] + for wo in wos: + parts.append( + templates.TemplateResponse(request, "partials/work_order_card.html", {"wo": wo}).body.decode() + ) + return HTMLResponse("".join(parts)) + + +# --------------------------------------------------------------------------- +# Action endpoints +# --------------------------------------------------------------------------- + +async def _update_status(request: Request, wo_id: str, new_status: str, **extra): + completed_at = datetime.utcnow().isoformat() if new_status in ("completed", "rejected") else None + db = _get_db() + try: + sets = ["status=?", "completed_at=COALESCE(?, completed_at)"] + vals = [new_status, completed_at] + for col, val in extra.items(): + sets.append(f"{col}=?") + vals.append(val) + vals.append(wo_id) + db.execute(f"UPDATE work_orders SET {', '.join(sets)} WHERE id=?", vals) + db.commit() + row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone() + finally: + db.close() + if not row: + raise HTTPException(404, "Work order not found") + wo = _WOView(_row_to_dict(row)) + return templates.TemplateResponse(request, "partials/work_order_card.html", {"wo": wo}) + + +@router.post("/work-orders/{wo_id}/approve", response_class=HTMLResponse) +async def approve_wo(request: Request, wo_id: str): + return await _update_status(request, wo_id, "approved") + + +@router.post("/work-orders/{wo_id}/reject", response_class=HTMLResponse) +async def reject_wo(request: Request, wo_id: str): + return await _update_status(request, wo_id, "rejected") + + +@router.post("/work-orders/{wo_id}/execute", response_class=HTMLResponse) +async def execute_wo(request: Request, wo_id: str): + return await _update_status(request, wo_id, "in_progress") diff --git a/src/dashboard/templates/partials/memory_facts.html b/src/dashboard/templates/partials/memory_facts.html new file mode 100644 index 00000000..9b659131 --- /dev/null +++ b/src/dashboard/templates/partials/memory_facts.html @@ -0,0 +1,13 @@ +{% if facts %} + +{% else %} +

No personal facts stored yet.

+{% endif %} diff --git a/src/dashboard/templates/partials/memory_results.html b/src/dashboard/templates/partials/memory_results.html new file mode 100644 index 00000000..a18cdbf5 --- /dev/null +++ b/src/dashboard/templates/partials/memory_results.html @@ -0,0 +1,34 @@ +
+

Search Results

+ + {% if results %} +
+ {% for mem in results %} +
+
+ {{ mem.source }} + {{ mem.context_type }} + {% if mem.relevance_score %} + {{ "%.2f"|format(mem.relevance_score) }} + {% endif %} +
+
{{ mem.content }}
+
+ {{ mem.timestamp[11:16] }} + {% if mem.agent_id %} + Agent: {{ mem.agent_id[:8] }}... + {% endif %} + {% if mem.task_id %} + Task: {{ mem.task_id[:8] }}... + {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+

No results found for "{{ query }}"

+

Try different keywords or check spelling.

+
+ {% endif %} +
diff --git a/src/timmy/agent.py b/src/timmy/agent.py index a1cd8620..0adf8928 100644 --- a/src/timmy/agent.py +++ b/src/timmy/agent.py @@ -275,7 +275,7 @@ def create_timmy( return Agent( name="Agent", - model=Ollama(id=model_name, host=settings.ollama_url), + model=Ollama(id=model_name, host=settings.ollama_url, request_timeout=300), db=SqliteDb(db_file=db_file), description=full_prompt, add_history_to_context=True, diff --git a/src/timmy/agents/base.py b/src/timmy/agents/base.py index 9bb0205b..24a2fcf9 100644 --- a/src/timmy/agents/base.py +++ b/src/timmy/agents/base.py @@ -63,7 +63,7 @@ class BaseAgent(ABC): return Agent( name=self.name, - model=Ollama(id=settings.ollama_model, host=settings.ollama_url), + model=Ollama(id=settings.ollama_model, host=settings.ollama_url, request_timeout=300), description=system_prompt, tools=tool_instances if tool_instances else None, add_history_to_context=True, diff --git a/src/timmy/semantic_memory.py b/src/timmy/semantic_memory.py index b69052f3..d36d5fa3 100644 --- a/src/timmy/semantic_memory.py +++ b/src/timmy/semantic_memory.py @@ -365,6 +365,49 @@ def memory_search(query: str, top_k: int = 5) -> str: return "\n\n".join(parts) +def memory_read(query: str = "", top_k: int = 5) -> str: + """Read from persistent memory — search facts, notes, and past conversations. + + This is the primary tool for recalling stored information. If no query + is given, returns the most recent personal facts. With a query, it + searches semantically across all stored memories. + + Args: + query: Optional search term. Leave empty to list recent facts. + top_k: Maximum results to return (default 5). + + Returns: + Formatted string of memory contents. + """ + if top_k is None: + top_k = 5 + + parts: list[str] = [] + + # Always include personal facts first + try: + from timmy.memory.vector_store import search_memories + facts = search_memories(query or "", limit=top_k, min_relevance=0.0) + fact_entries = [e for e in facts if (e.context_type or "") == "fact"] + if fact_entries: + parts.append("## Personal Facts") + for entry in fact_entries[:top_k]: + parts.append(f"- {entry.content[:300]}") + except Exception as exc: + logger.debug("Vector store unavailable for memory_read: %s", exc) + + # If a query was provided, also do semantic search + if query: + search_result = memory_search(query, top_k) + if search_result and search_result != "No relevant memories found.": + parts.append("\n## Search Results") + parts.append(search_result) + + if not parts: + return "No memories stored yet. Use memory_write to store information." + return "\n".join(parts) + + def memory_write(content: str, context_type: str = "fact") -> str: """Store a piece of information in persistent memory. diff --git a/src/timmy/tools.py b/src/timmy/tools.py index c26733ef..16e22da1 100644 --- a/src/timmy/tools.py +++ b/src/timmy/tools.py @@ -447,10 +447,11 @@ def create_full_toolkit(base_dir: str | Path | None = None): # Memory search and write — persistent recall across all channels try: - from timmy.semantic_memory import memory_search, memory_write + from timmy.semantic_memory import memory_search, memory_write, memory_read toolkit.register(memory_search, name="memory_search") toolkit.register(memory_write, name="memory_write") + toolkit.register(memory_read, name="memory_read") except Exception: logger.debug("Memory tools not available") diff --git a/tests/conftest.py b/tests/conftest.py index 0a4ba423..6fa38505 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -61,6 +61,8 @@ def clean_database(tmp_path): tmp_swarm_db = tmp_path / "swarm.db" tmp_spark_db = tmp_path / "spark.db" tmp_self_coding_db = tmp_path / "self_coding.db" + tmp_tasks_db = tmp_path / "tasks.db" + tmp_work_orders_db = tmp_path / "work_orders.db" _swarm_db_modules = [ "timmy.memory.vector_store", @@ -98,6 +100,18 @@ def clean_database(tmp_path): except Exception: pass + # Redirect task queue and work orders DBs to temp dir + for mod_name, tmp_db in [ + ("dashboard.routes.tasks", tmp_tasks_db), + ("dashboard.routes.work_orders", tmp_work_orders_db), + ]: + try: + mod = __import__(mod_name, fromlist=["DB_PATH"]) + originals[(mod_name, "DB_PATH")] = getattr(mod, "DB_PATH") + setattr(mod, "DB_PATH", tmp_db) + except Exception: + pass + yield for (mod_name, attr), original in originals.items(): diff --git a/tests/dashboard/test_memory_api.py b/tests/dashboard/test_memory_api.py new file mode 100644 index 00000000..e2eca4e8 --- /dev/null +++ b/tests/dashboard/test_memory_api.py @@ -0,0 +1,69 @@ +"""Tests for the Memory API endpoints. + +Verifies that facts can be created, searched, edited, and deleted +through the dashboard memory routes. +""" + + +def test_memory_page_returns_200(client): + response = client.get("/memory") + assert response.status_code == 200 + assert "Memory Browser" in response.text + + +def test_add_fact_returns_html(client): + """POST /memory/fact should return HTML partial with the new fact.""" + response = client.post("/memory/fact", data={"fact": "Alexander is the operator"}) + assert response.status_code == 200 + assert "Alexander is the operator" in response.text + + +def test_add_fact_persists(client): + """After adding a fact, it should appear on the main memory page.""" + client.post("/memory/fact", data={"fact": "Timmy runs on Qwen"}) + response = client.get("/memory") + assert response.status_code == 200 + assert "Timmy runs on Qwen" in response.text + + +def test_memory_search_returns_html(client): + """POST /memory/search should return HTML partial.""" + response = client.post("/memory/search", data={"query": "test query"}) + assert response.status_code == 200 + + +def test_edit_fact(client): + """PUT /memory/fact/{id} should update the fact content.""" + # First create a fact + client.post("/memory/fact", data={"fact": "Original fact"}) + + # Get the fact ID from the memory page + page = client.get("/memory") + assert "Original fact" in page.text + + # Extract a fact ID from the page (look for fact- pattern) + import re + match = re.search(r'id="fact-([^"]+)"', page.text) + if match: + fact_id = match.group(1) + response = client.put( + f"/memory/fact/{fact_id}", + json={"content": "Updated fact"}, + ) + assert response.status_code == 200 + assert response.json()["success"] is True + + +def test_delete_fact(client): + """DELETE /memory/fact/{id} should remove the fact.""" + # Create a fact + client.post("/memory/fact", data={"fact": "Fact to delete"}) + + page = client.get("/memory") + import re + match = re.search(r'id="fact-([^"]+)"', page.text) + if match: + fact_id = match.group(1) + response = client.delete(f"/memory/fact/{fact_id}") + assert response.status_code == 200 + assert response.json()["success"] is True diff --git a/tests/dashboard/test_tasks_api.py b/tests/dashboard/test_tasks_api.py new file mode 100644 index 00000000..314073f9 --- /dev/null +++ b/tests/dashboard/test_tasks_api.py @@ -0,0 +1,91 @@ +"""Tests for the Task Queue API endpoints. + +Verifies task CRUD operations and the dashboard page rendering. +""" + + +def test_tasks_page_returns_200(client): + response = client.get("/tasks") + assert response.status_code == 200 + assert "TASK QUEUE" in response.text + + +def test_create_task(client): + """POST /api/tasks returns 201 with task JSON.""" + response = client.post("/api/tasks", json={ + "title": "Fix the memory bug", + "priority": "high", + }) + assert response.status_code == 201 + data = response.json() + assert data["title"] == "Fix the memory bug" + assert data["priority"] == "high" + assert data["status"] == "pending_approval" + assert "id" in data + + +def test_list_tasks(client): + """GET /api/tasks returns JSON array.""" + response = client.get("/api/tasks") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +def test_create_and_list_roundtrip(client): + """Creating a task makes it appear in the list.""" + client.post("/api/tasks", json={"title": "Roundtrip test"}) + response = client.get("/api/tasks") + tasks = response.json() + assert any(t["title"] == "Roundtrip test" for t in tasks) + + +def test_update_task_status(client): + """PATCH /api/tasks/{id}/status updates the task.""" + create = client.post("/api/tasks", json={"title": "To approve"}) + task_id = create.json()["id"] + + response = client.patch( + f"/api/tasks/{task_id}/status", + json={"status": "approved"}, + ) + assert response.status_code == 200 + assert response.json()["status"] == "approved" + + +def test_delete_task(client): + """DELETE /api/tasks/{id} removes the task.""" + create = client.post("/api/tasks", json={"title": "To delete"}) + task_id = create.json()["id"] + + response = client.delete(f"/api/tasks/{task_id}") + assert response.status_code == 200 + + # Verify it's gone + tasks = client.get("/api/tasks").json() + assert not any(t["id"] == task_id for t in tasks) + + +def test_create_task_missing_title_422(client): + """POST /api/tasks without title returns 422.""" + response = client.post("/api/tasks", json={"priority": "high"}) + assert response.status_code == 422 + + +def test_create_task_via_form(client): + """POST /tasks/create via form creates and returns task card HTML.""" + response = client.post("/tasks/create", data={ + "title": "Form task", + "description": "Created via form", + "priority": "normal", + "assigned_to": "", + }) + assert response.status_code == 200 + assert "Form task" in response.text + + +def test_pending_partial(client): + """GET /tasks/pending returns HTML partial.""" + client.post("/api/tasks", json={"title": "Pending task"}) + response = client.get("/tasks/pending") + assert response.status_code == 200 + assert "Pending task" in response.text diff --git a/tests/dashboard/test_work_orders_api.py b/tests/dashboard/test_work_orders_api.py new file mode 100644 index 00000000..bde64412 --- /dev/null +++ b/tests/dashboard/test_work_orders_api.py @@ -0,0 +1,64 @@ +"""Tests for the Work Orders API endpoints.""" + + +def test_work_orders_page_returns_200(client): + response = client.get("/work-orders/queue") + assert response.status_code == 200 + assert "WORK ORDERS" in response.text + + +def test_submit_work_order(client): + """POST /work-orders/submit creates a work order.""" + response = client.post("/work-orders/submit", data={ + "title": "Fix the dashboard", + "description": "Details here", + "priority": "high", + "category": "bug", + "submitter": "dashboard", + "related_files": "src/app.py", + }) + assert response.status_code == 200 + + +def test_pending_partial_returns_200(client): + """GET /work-orders/queue/pending returns HTML.""" + response = client.get("/work-orders/queue/pending") + assert response.status_code == 200 + + +def test_active_partial_returns_200(client): + """GET /work-orders/queue/active returns HTML.""" + response = client.get("/work-orders/queue/active") + assert response.status_code == 200 + + +def test_submit_and_list_roundtrip(client): + """Submitting a work order makes it appear in the pending section.""" + client.post("/work-orders/submit", data={ + "title": "Roundtrip WO", + "priority": "medium", + "category": "suggestion", + "submitter": "test", + }) + response = client.get("/work-orders/queue/pending") + assert "Roundtrip WO" in response.text + + +def test_approve_work_order(client): + """POST /work-orders/{id}/approve changes status.""" + # Submit one first + client.post("/work-orders/submit", data={ + "title": "To approve", + "priority": "medium", + "category": "suggestion", + "submitter": "test", + }) + # Get ID from pending + pending = client.get("/work-orders/queue/pending") + import re + match = re.search(r'id="wo-([^"]+)"', pending.text) + if match: + wo_id = match.group(1) + response = client.post(f"/work-orders/{wo_id}/approve") + assert response.status_code == 200 + assert "APPROVED" in response.text.upper() or "EXECUTE" in response.text.upper() diff --git a/tests/timmy/test_ollama_timeout.py b/tests/timmy/test_ollama_timeout.py new file mode 100644 index 00000000..2ef1d5b4 --- /dev/null +++ b/tests/timmy/test_ollama_timeout.py @@ -0,0 +1,60 @@ +"""Test that Ollama model is created with a generous request timeout. + +The default httpx timeout is too short for complex prompts (30-60s generation). +This caused socket read errors in production. +""" + +from unittest.mock import patch, MagicMock + + +def test_base_agent_sets_request_timeout(): + """BaseAgent creates Ollama with request_timeout=300.""" + with patch("timmy.agents.base.Ollama") as mock_ollama, \ + patch("timmy.agents.base.Agent"): + mock_ollama.return_value = MagicMock() + + # Import after patching to get the patched version + from timmy.agents.base import BaseAgent + + class ConcreteAgent(BaseAgent): + async def handle_message(self, message: str) -> str: + return "" + + # Trigger Ollama construction + try: + ConcreteAgent( + agent_id="test", + name="Test", + role="tester", + system_prompt="You are a test agent.", + tools=[], + ) + except Exception: + pass # MCP registry may not be available + + # Verify Ollama was called with request_timeout + if mock_ollama.called: + _, kwargs = mock_ollama.call_args + assert kwargs.get("request_timeout") == 300, ( + f"Expected request_timeout=300, got {kwargs.get('request_timeout')}" + ) + + +def test_main_agent_sets_request_timeout(): + """create_timmy() creates Ollama with request_timeout=300.""" + with patch("timmy.agent.Ollama") as mock_ollama, \ + patch("timmy.agent.SqliteDb"), \ + patch("timmy.agent.Agent"): + mock_ollama.return_value = MagicMock() + + from timmy.agent import create_timmy + try: + create_timmy() + except Exception: + pass + + if mock_ollama.called: + _, kwargs = mock_ollama.call_args + assert kwargs.get("request_timeout") == 300, ( + f"Expected request_timeout=300, got {kwargs.get('request_timeout')}" + ) diff --git a/tests/timmy/test_semantic_memory.py b/tests/timmy/test_semantic_memory.py index e810b450..5518c47e 100644 --- a/tests/timmy/test_semantic_memory.py +++ b/tests/timmy/test_semantic_memory.py @@ -12,6 +12,7 @@ from timmy.semantic_memory import ( MemorySearcher, MemoryChunk, memory_search, + memory_read, _get_embedding_model, ) @@ -232,6 +233,22 @@ class TestMemorySearch: assert isinstance(result, str) +class TestMemoryRead: + """Test module-level memory_read function.""" + + def test_memory_read_returns_string(self): + result = memory_read() + assert isinstance(result, str) + + def test_memory_read_with_query(self): + result = memory_read("some query") + assert isinstance(result, str) + + def test_memory_read_none_top_k(self): + result = memory_read("test", top_k=None) + assert isinstance(result, str) + + class TestMemoryChunk: """Test MemoryChunk dataclass."""