forked from Rockachopa/Timmy-time-dashboard
Compare commits
16 Commits
kimi/issue
...
fix/csrf-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59e789e2da | ||
| d2a5866650 | |||
| 2381d0b6d0 | |||
| 03ad2027a4 | |||
| 2bfc44ea1b | |||
| fe1fa78ef1 | |||
| 3c46a1b202 | |||
| 001358c64f | |||
| faad0726a2 | |||
| dd4410fe57 | |||
| ef7f31070b | |||
| 6f66670396 | |||
| 4cdd82818b | |||
| 99ad672e4d | |||
| a3f61c67d3 | |||
| 32dbdc68c8 |
@@ -84,6 +84,7 @@ class Settings(BaseSettings):
|
|||||||
# Only used when explicitly enabled and query complexity warrants it.
|
# Only used when explicitly enabled and query complexity warrants it.
|
||||||
grok_enabled: bool = False
|
grok_enabled: bool = False
|
||||||
xai_api_key: str = ""
|
xai_api_key: str = ""
|
||||||
|
xai_base_url: str = "https://api.x.ai/v1"
|
||||||
grok_default_model: str = "grok-3-fast"
|
grok_default_model: str = "grok-3-fast"
|
||||||
grok_max_sats_per_query: int = 200
|
grok_max_sats_per_query: int = 200
|
||||||
grok_free: bool = False # Skip Lightning invoice when user has own API key
|
grok_free: bool = False # Skip Lightning invoice when user has own API key
|
||||||
|
|||||||
@@ -377,73 +377,78 @@ def _startup_background_tasks() -> list[asyncio.Task]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _try_prune(label: str, prune_fn, days: int) -> None:
|
||||||
|
"""Run a prune function, log results, swallow errors."""
|
||||||
|
try:
|
||||||
|
pruned = prune_fn()
|
||||||
|
if pruned:
|
||||||
|
logger.info(
|
||||||
|
"%s auto-prune: removed %d entries older than %d days",
|
||||||
|
label,
|
||||||
|
pruned,
|
||||||
|
days,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("%s auto-prune skipped: %s", label, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_vault_size() -> None:
|
||||||
|
"""Warn if the memory vault exceeds the configured size limit."""
|
||||||
|
try:
|
||||||
|
vault_path = Path(settings.repo_root) / "memory" / "notes"
|
||||||
|
if vault_path.exists():
|
||||||
|
total_bytes = sum(f.stat().st_size for f in vault_path.rglob("*") if f.is_file())
|
||||||
|
total_mb = total_bytes / (1024 * 1024)
|
||||||
|
if total_mb > settings.memory_vault_max_mb:
|
||||||
|
logger.warning(
|
||||||
|
"Memory vault (%.1f MB) exceeds limit (%d MB) — consider archiving old notes",
|
||||||
|
total_mb,
|
||||||
|
settings.memory_vault_max_mb,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Vault size check skipped: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def _startup_pruning() -> None:
|
def _startup_pruning() -> None:
|
||||||
"""Auto-prune old memories, thoughts, and events on startup."""
|
"""Auto-prune old memories, thoughts, and events on startup."""
|
||||||
if settings.memory_prune_days > 0:
|
if settings.memory_prune_days > 0:
|
||||||
try:
|
from timmy.memory_system import prune_memories
|
||||||
from timmy.memory_system import prune_memories
|
|
||||||
|
|
||||||
pruned = prune_memories(
|
_try_prune(
|
||||||
|
"Memory",
|
||||||
|
lambda: prune_memories(
|
||||||
older_than_days=settings.memory_prune_days,
|
older_than_days=settings.memory_prune_days,
|
||||||
keep_facts=settings.memory_prune_keep_facts,
|
keep_facts=settings.memory_prune_keep_facts,
|
||||||
)
|
),
|
||||||
if pruned:
|
settings.memory_prune_days,
|
||||||
logger.info(
|
)
|
||||||
"Memory auto-prune: removed %d entries older than %d days",
|
|
||||||
pruned,
|
|
||||||
settings.memory_prune_days,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Memory auto-prune skipped: %s", exc)
|
|
||||||
|
|
||||||
if settings.thoughts_prune_days > 0:
|
if settings.thoughts_prune_days > 0:
|
||||||
try:
|
from timmy.thinking import thinking_engine
|
||||||
from timmy.thinking import thinking_engine
|
|
||||||
|
|
||||||
pruned = thinking_engine.prune_old_thoughts(
|
_try_prune(
|
||||||
|
"Thought",
|
||||||
|
lambda: thinking_engine.prune_old_thoughts(
|
||||||
keep_days=settings.thoughts_prune_days,
|
keep_days=settings.thoughts_prune_days,
|
||||||
keep_min=settings.thoughts_prune_keep_min,
|
keep_min=settings.thoughts_prune_keep_min,
|
||||||
)
|
),
|
||||||
if pruned:
|
settings.thoughts_prune_days,
|
||||||
logger.info(
|
)
|
||||||
"Thought auto-prune: removed %d entries older than %d days",
|
|
||||||
pruned,
|
|
||||||
settings.thoughts_prune_days,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Thought auto-prune skipped: %s", exc)
|
|
||||||
|
|
||||||
if settings.events_prune_days > 0:
|
if settings.events_prune_days > 0:
|
||||||
try:
|
from swarm.event_log import prune_old_events
|
||||||
from swarm.event_log import prune_old_events
|
|
||||||
|
|
||||||
pruned = prune_old_events(
|
_try_prune(
|
||||||
|
"Event",
|
||||||
|
lambda: prune_old_events(
|
||||||
keep_days=settings.events_prune_days,
|
keep_days=settings.events_prune_days,
|
||||||
keep_min=settings.events_prune_keep_min,
|
keep_min=settings.events_prune_keep_min,
|
||||||
)
|
),
|
||||||
if pruned:
|
settings.events_prune_days,
|
||||||
logger.info(
|
)
|
||||||
"Event auto-prune: removed %d entries older than %d days",
|
|
||||||
pruned,
|
|
||||||
settings.events_prune_days,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Event auto-prune skipped: %s", exc)
|
|
||||||
|
|
||||||
if settings.memory_vault_max_mb > 0:
|
if settings.memory_vault_max_mb > 0:
|
||||||
try:
|
_check_vault_size()
|
||||||
vault_path = Path(settings.repo_root) / "memory" / "notes"
|
|
||||||
if vault_path.exists():
|
|
||||||
total_bytes = sum(f.stat().st_size for f in vault_path.rglob("*") if f.is_file())
|
|
||||||
total_mb = total_bytes / (1024 * 1024)
|
|
||||||
if total_mb > settings.memory_vault_max_mb:
|
|
||||||
logger.warning(
|
|
||||||
"Memory vault (%.1f MB) exceeds limit (%d MB) — consider archiving old notes",
|
|
||||||
total_mb,
|
|
||||||
settings.memory_vault_max_mb,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Vault size check skipped: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
async def _shutdown_cleanup(
|
async def _shutdown_cleanup(
|
||||||
|
|||||||
@@ -175,18 +175,12 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# Token validation failed and path is not exempt
|
# Token validation failed and path is not exempt
|
||||||
# We still need to call the app to check if the endpoint is decorated
|
# Resolve the endpoint from routes BEFORE executing to avoid side effects
|
||||||
# with @csrf_exempt, so we'll let it through and check after routing
|
endpoint = self._resolve_endpoint(request)
|
||||||
response = await call_next(request)
|
|
||||||
|
|
||||||
# After routing, check if the endpoint is marked as exempt
|
|
||||||
endpoint = request.scope.get("endpoint")
|
|
||||||
if endpoint and is_csrf_exempt(endpoint):
|
if endpoint and is_csrf_exempt(endpoint):
|
||||||
# Endpoint is marked as exempt, allow the response
|
return await call_next(request)
|
||||||
return response
|
|
||||||
|
|
||||||
# Endpoint is not exempt and token validation failed
|
# Endpoint is not exempt and token validation failed — reject without executing
|
||||||
# Return 403 error
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
content={
|
content={
|
||||||
@@ -196,6 +190,42 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _resolve_endpoint(self, request: Request) -> Callable | None:
|
||||||
|
"""Resolve the endpoint for a request without executing it.
|
||||||
|
|
||||||
|
Walks the app chain to find routes, then matches against the request
|
||||||
|
scope. This allows checking @csrf_exempt before the handler runs
|
||||||
|
(avoiding side effects on CSRF rejection).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The endpoint callable if found, None otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from starlette.routing import Match
|
||||||
|
|
||||||
|
# Walk the middleware/app chain to find something with routes
|
||||||
|
routes = None
|
||||||
|
current = self.app
|
||||||
|
for _ in range(10): # Safety limit
|
||||||
|
routes = getattr(current, "routes", None)
|
||||||
|
if routes:
|
||||||
|
break
|
||||||
|
current = getattr(current, "app", None)
|
||||||
|
if current is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not routes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scope = dict(request.scope)
|
||||||
|
for route in routes:
|
||||||
|
match, child_scope = route.matches(scope)
|
||||||
|
if match == Match.FULL:
|
||||||
|
return child_scope.get("endpoint")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to resolve endpoint for CSRF check")
|
||||||
|
return None
|
||||||
|
|
||||||
def _is_likely_exempt(self, path: str) -> bool:
|
def _is_likely_exempt(self, path: str) -> bool:
|
||||||
"""Check if a path is likely to be CSRF exempt.
|
"""Check if a path is likely to be CSRF exempt.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, Column, Date, DateTime, Index, Integer, String
|
from sqlalchemy import JSON, Boolean, Column, Date, DateTime, Index, Integer, String
|
||||||
@@ -40,8 +40,13 @@ class Task(Base):
|
|||||||
deferred_at = Column(DateTime, nullable=True)
|
deferred_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(
|
||||||
|
DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
__table_args__ = (Index("ix_task_state_order", "state", "sort_order"),)
|
__table_args__ = (Index("ix_task_state_order", "state", "sort_order"),)
|
||||||
|
|
||||||
@@ -59,4 +64,4 @@ class JournalEntry(Base):
|
|||||||
gratitude = Column(String(500), nullable=True)
|
gratitude = Column(String(500), nullable=True)
|
||||||
energy_level = Column(Integer, nullable=True) # User-reported, 1-10
|
energy_level = Column(Integer, nullable=True) # User-reported, 1-10
|
||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime
|
from datetime import UTC, date, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
@@ -38,6 +38,56 @@ def get_later_tasks(db: Session) -> list[Task]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_mit_tasks(db: Session, titles: list[str | None]) -> list[int]:
|
||||||
|
"""Create MIT tasks from a list of titles, return their IDs."""
|
||||||
|
task_ids: list[int] = []
|
||||||
|
for title in titles:
|
||||||
|
if title:
|
||||||
|
task = Task(
|
||||||
|
title=title,
|
||||||
|
is_mit=True,
|
||||||
|
state=TaskState.LATER,
|
||||||
|
certainty=TaskCertainty.SOFT,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
task_ids.append(task.id)
|
||||||
|
return task_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _create_other_tasks(db: Session, other_tasks: str):
|
||||||
|
"""Create non-MIT tasks from newline-separated text."""
|
||||||
|
for line in other_tasks.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
task = Task(
|
||||||
|
title=line,
|
||||||
|
state=TaskState.LATER,
|
||||||
|
certainty=TaskCertainty.FUZZY,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_now_next(db: Session):
|
||||||
|
"""Set initial NOW/NEXT states when both slots are empty."""
|
||||||
|
if get_now_task(db) or get_next_task(db):
|
||||||
|
return
|
||||||
|
later_tasks = (
|
||||||
|
db.query(Task)
|
||||||
|
.filter(Task.state == TaskState.LATER)
|
||||||
|
.order_by(Task.is_mit.desc(), Task.sort_order)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if later_tasks:
|
||||||
|
later_tasks[0].state = TaskState.NOW
|
||||||
|
db.add(later_tasks[0])
|
||||||
|
db.flush()
|
||||||
|
if len(later_tasks) > 1:
|
||||||
|
later_tasks[1].state = TaskState.NEXT
|
||||||
|
db.add(later_tasks[1])
|
||||||
|
|
||||||
|
|
||||||
def promote_tasks(db: Session):
|
def promote_tasks(db: Session):
|
||||||
"""Enforce the NOW/NEXT/LATER state machine invariants.
|
"""Enforce the NOW/NEXT/LATER state machine invariants.
|
||||||
|
|
||||||
@@ -114,63 +164,19 @@ async def post_morning_ritual(
|
|||||||
other_tasks: str = Form(""),
|
other_tasks: str = Form(""),
|
||||||
):
|
):
|
||||||
"""Process morning ritual: create MITs, other tasks, and set initial states."""
|
"""Process morning ritual: create MITs, other tasks, and set initial states."""
|
||||||
# Create Journal Entry
|
|
||||||
mit_task_ids = []
|
|
||||||
journal_entry = JournalEntry(entry_date=date.today())
|
journal_entry = JournalEntry(entry_date=date.today())
|
||||||
db.add(journal_entry)
|
db.add(journal_entry)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(journal_entry)
|
db.refresh(journal_entry)
|
||||||
|
|
||||||
# Create MIT tasks
|
journal_entry.mit_task_ids = _create_mit_tasks(db, [mit1_title, mit2_title, mit3_title])
|
||||||
for mit_title in [mit1_title, mit2_title, mit3_title]:
|
|
||||||
if mit_title:
|
|
||||||
task = Task(
|
|
||||||
title=mit_title,
|
|
||||||
is_mit=True,
|
|
||||||
state=TaskState.LATER, # Initially LATER, will be promoted
|
|
||||||
certainty=TaskCertainty.SOFT,
|
|
||||||
)
|
|
||||||
db.add(task)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(task)
|
|
||||||
mit_task_ids.append(task.id)
|
|
||||||
|
|
||||||
journal_entry.mit_task_ids = mit_task_ids
|
|
||||||
db.add(journal_entry)
|
db.add(journal_entry)
|
||||||
|
|
||||||
# Create other tasks
|
_create_other_tasks(db, other_tasks)
|
||||||
for task_title in other_tasks.split("\n"):
|
|
||||||
task_title = task_title.strip()
|
|
||||||
if task_title:
|
|
||||||
task = Task(
|
|
||||||
title=task_title,
|
|
||||||
state=TaskState.LATER,
|
|
||||||
certainty=TaskCertainty.FUZZY,
|
|
||||||
)
|
|
||||||
db.add(task)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Set initial NOW/NEXT states
|
_seed_now_next(db)
|
||||||
# Set initial NOW/NEXT states after all tasks are created
|
db.commit()
|
||||||
if not get_now_task(db) and not get_next_task(db):
|
|
||||||
later_tasks = (
|
|
||||||
db.query(Task)
|
|
||||||
.filter(Task.state == TaskState.LATER)
|
|
||||||
.order_by(Task.is_mit.desc(), Task.sort_order)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
if later_tasks:
|
|
||||||
# Set the highest priority LATER task to NOW
|
|
||||||
later_tasks[0].state = TaskState.NOW
|
|
||||||
db.add(later_tasks[0])
|
|
||||||
db.flush() # Flush to make the change visible for the next query
|
|
||||||
|
|
||||||
# Set the next highest priority LATER task to NEXT
|
|
||||||
if len(later_tasks) > 1:
|
|
||||||
later_tasks[1].state = TaskState.NEXT
|
|
||||||
db.add(later_tasks[1])
|
|
||||||
db.commit() # Commit changes after initial NOW/NEXT setup
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -220,7 +226,7 @@ async def post_evening_ritual(
|
|||||||
)
|
)
|
||||||
for task in active_tasks:
|
for task in active_tasks:
|
||||||
task.state = TaskState.DEFERRED # Or DONE, depending on desired archiving logic
|
task.state = TaskState.DEFERRED # Or DONE, depending on desired archiving logic
|
||||||
task.deferred_at = datetime.utcnow()
|
task.deferred_at = datetime.now(UTC)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -273,7 +279,7 @@ async def start_task(
|
|||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
task.state = TaskState.NOW
|
task.state = TaskState.NOW
|
||||||
task.started_at = datetime.utcnow()
|
task.started_at = datetime.now(UTC)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -303,7 +309,7 @@ async def complete_task(
|
|||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
task.state = TaskState.DONE
|
task.state = TaskState.DONE
|
||||||
task.completed_at = datetime.utcnow()
|
task.completed_at = datetime.now(UTC)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -332,7 +338,7 @@ async def defer_task(
|
|||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
task.state = TaskState.DEFERRED
|
task.state = TaskState.DEFERRED
|
||||||
task.deferred_at = datetime.utcnow()
|
task.deferred_at = datetime.now(UTC)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import sqlite3
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from contextlib import closing, contextmanager
|
from contextlib import closing, contextmanager
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
@@ -219,7 +219,7 @@ async def create_task_form(
|
|||||||
raise HTTPException(status_code=400, detail="Task title cannot be empty")
|
raise HTTPException(status_code=400, detail="Task title cannot be empty")
|
||||||
|
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
priority = priority if priority in VALID_PRIORITIES else "normal"
|
priority = priority if priority in VALID_PRIORITIES else "normal"
|
||||||
|
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
@@ -287,7 +287,7 @@ async def modify_task(
|
|||||||
async def _set_status(request: Request, task_id: str, new_status: str):
|
async def _set_status(request: Request, task_id: str, new_status: str):
|
||||||
"""Helper to update status and return refreshed task card."""
|
"""Helper to update status and return refreshed task card."""
|
||||||
completed_at = (
|
completed_at = (
|
||||||
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
datetime.now(UTC).isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
||||||
)
|
)
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -316,7 +316,7 @@ async def api_create_task(request: Request):
|
|||||||
raise HTTPException(422, "title is required")
|
raise HTTPException(422, "title is required")
|
||||||
|
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
priority = body.get("priority", "normal")
|
priority = body.get("priority", "normal")
|
||||||
if priority not in VALID_PRIORITIES:
|
if priority not in VALID_PRIORITIES:
|
||||||
priority = "normal"
|
priority = "normal"
|
||||||
@@ -358,7 +358,7 @@ async def api_update_status(task_id: str, request: Request):
|
|||||||
raise HTTPException(422, f"Invalid status. Must be one of: {VALID_STATUSES}")
|
raise HTTPException(422, f"Invalid status. Must be one of: {VALID_STATUSES}")
|
||||||
|
|
||||||
completed_at = (
|
completed_at = (
|
||||||
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
datetime.now(UTC).isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
||||||
)
|
)
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import sqlite3
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from contextlib import closing, contextmanager
|
from contextlib import closing, contextmanager
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
@@ -144,7 +144,7 @@ async def submit_work_order(
|
|||||||
related_files: str = Form(""),
|
related_files: str = Form(""),
|
||||||
):
|
):
|
||||||
wo_id = str(uuid.uuid4())
|
wo_id = str(uuid.uuid4())
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
priority = priority if priority in PRIORITIES else "medium"
|
priority = priority if priority in PRIORITIES else "medium"
|
||||||
category = category if category in CATEGORIES else "suggestion"
|
category = category if category in CATEGORIES else "suggestion"
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ async def active_partial(request: Request):
|
|||||||
|
|
||||||
async def _update_status(request: Request, wo_id: str, new_status: str, **extra):
|
async def _update_status(request: Request, wo_id: str, new_status: str, **extra):
|
||||||
completed_at = (
|
completed_at = (
|
||||||
datetime.utcnow().isoformat() if new_status in ("completed", "rejected") else None
|
datetime.now(UTC).isoformat() if new_status in ("completed", "rejected") else None
|
||||||
)
|
)
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
sets = ["status=?", "completed_at=COALESCE(?, completed_at)"]
|
sets = ["status=?", "completed_at=COALESCE(?, completed_at)"]
|
||||||
|
|||||||
@@ -149,6 +149,52 @@ def _log_error_event(
|
|||||||
logger.debug("Failed to log error event: %s", log_exc)
|
logger.debug("Failed to log error event: %s", log_exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_report_description(
|
||||||
|
exc: Exception,
|
||||||
|
source: str,
|
||||||
|
context: dict | None,
|
||||||
|
error_hash: str,
|
||||||
|
tb_str: str,
|
||||||
|
affected_file: str,
|
||||||
|
affected_line: int,
|
||||||
|
git_ctx: dict,
|
||||||
|
) -> str:
|
||||||
|
"""Build the markdown description for a bug report task."""
|
||||||
|
parts = [
|
||||||
|
f"**Error:** {type(exc).__name__}: {str(exc)}",
|
||||||
|
f"**Source:** {source}",
|
||||||
|
f"**File:** {affected_file}:{affected_line}",
|
||||||
|
f"**Git:** {git_ctx.get('branch', '?')} @ {git_ctx.get('commit', '?')}",
|
||||||
|
f"**Time:** {datetime.now(UTC).isoformat()}",
|
||||||
|
f"**Hash:** {error_hash}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if context:
|
||||||
|
ctx_str = ", ".join(f"{k}={v}" for k, v in context.items())
|
||||||
|
parts.append(f"**Context:** {ctx_str}")
|
||||||
|
|
||||||
|
parts.append(f"\n**Stack Trace:**\n```\n{tb_str[:2000]}\n```")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_bug_report_created(source: str, task_id: str, error_hash: str, title: str) -> None:
|
||||||
|
"""Log a BUG_REPORT_CREATED event (best-effort)."""
|
||||||
|
try:
|
||||||
|
from swarm.event_log import EventType, log_event
|
||||||
|
|
||||||
|
log_event(
|
||||||
|
EventType.BUG_REPORT_CREATED,
|
||||||
|
source=source,
|
||||||
|
task_id=task_id,
|
||||||
|
data={
|
||||||
|
"error_hash": error_hash,
|
||||||
|
"title": title[:100],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Bug report event log error: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def _create_bug_report(
|
def _create_bug_report(
|
||||||
exc: Exception,
|
exc: Exception,
|
||||||
source: str,
|
source: str,
|
||||||
@@ -164,25 +210,20 @@ def _create_bug_report(
|
|||||||
from swarm.task_queue.models import create_task
|
from swarm.task_queue.models import create_task
|
||||||
|
|
||||||
title = f"[BUG] {type(exc).__name__}: {str(exc)[:80]}"
|
title = f"[BUG] {type(exc).__name__}: {str(exc)[:80]}"
|
||||||
|
description = _build_report_description(
|
||||||
description_parts = [
|
exc,
|
||||||
f"**Error:** {type(exc).__name__}: {str(exc)}",
|
source,
|
||||||
f"**Source:** {source}",
|
context,
|
||||||
f"**File:** {affected_file}:{affected_line}",
|
error_hash,
|
||||||
f"**Git:** {git_ctx.get('branch', '?')} @ {git_ctx.get('commit', '?')}",
|
tb_str,
|
||||||
f"**Time:** {datetime.now(UTC).isoformat()}",
|
affected_file,
|
||||||
f"**Hash:** {error_hash}",
|
affected_line,
|
||||||
]
|
git_ctx,
|
||||||
|
)
|
||||||
if context:
|
|
||||||
ctx_str = ", ".join(f"{k}={v}" for k, v in context.items())
|
|
||||||
description_parts.append(f"**Context:** {ctx_str}")
|
|
||||||
|
|
||||||
description_parts.append(f"\n**Stack Trace:**\n```\n{tb_str[:2000]}\n```")
|
|
||||||
|
|
||||||
task = create_task(
|
task = create_task(
|
||||||
title=title,
|
title=title,
|
||||||
description="\n".join(description_parts),
|
description=description,
|
||||||
assigned_to="default",
|
assigned_to="default",
|
||||||
created_by="system",
|
created_by="system",
|
||||||
priority="normal",
|
priority="normal",
|
||||||
@@ -190,24 +231,9 @@ def _create_bug_report(
|
|||||||
auto_approve=True,
|
auto_approve=True,
|
||||||
task_type="bug_report",
|
task_type="bug_report",
|
||||||
)
|
)
|
||||||
task_id = task.id
|
|
||||||
|
|
||||||
try:
|
_log_bug_report_created(source, task.id, error_hash, title)
|
||||||
from swarm.event_log import EventType, log_event
|
return task.id
|
||||||
|
|
||||||
log_event(
|
|
||||||
EventType.BUG_REPORT_CREATED,
|
|
||||||
source=source,
|
|
||||||
task_id=task_id,
|
|
||||||
data={
|
|
||||||
"error_hash": error_hash,
|
|
||||||
"title": title[:100],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Bug report screenshot error: %s", exc)
|
|
||||||
|
|
||||||
return task_id
|
|
||||||
|
|
||||||
except Exception as task_exc:
|
except Exception as task_exc:
|
||||||
logger.debug("Failed to create bug report task: %s", task_exc)
|
logger.debug("Failed to create bug report task: %s", task_exc)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class EventBus:
|
|||||||
|
|
||||||
@bus.subscribe("agent.task.*")
|
@bus.subscribe("agent.task.*")
|
||||||
async def handle_task(event: Event):
|
async def handle_task(event: Event):
|
||||||
logger.debug(f"Task event: {event.data}")
|
logger.debug("Task event: %s", event.data)
|
||||||
|
|
||||||
await bus.publish(Event(
|
await bus.publish(Event(
|
||||||
type="agent.task.assigned",
|
type="agent.task.assigned",
|
||||||
|
|||||||
@@ -221,65 +221,56 @@ class CascadeRouter:
|
|||||||
raise RuntimeError("PyYAML not installed")
|
raise RuntimeError("PyYAML not installed")
|
||||||
|
|
||||||
content = self.config_path.read_text()
|
content = self.config_path.read_text()
|
||||||
# Expand environment variables
|
|
||||||
content = self._expand_env_vars(content)
|
content = self._expand_env_vars(content)
|
||||||
data = yaml.safe_load(content)
|
data = yaml.safe_load(content)
|
||||||
|
|
||||||
# Load cascade settings
|
self.config = self._parse_router_config(data)
|
||||||
cascade = data.get("cascade", {})
|
self._load_providers(data)
|
||||||
|
|
||||||
# Load fallback chains
|
|
||||||
fallback_chains = data.get("fallback_chains", {})
|
|
||||||
|
|
||||||
# Load multi-modal settings
|
|
||||||
multimodal = data.get("multimodal", {})
|
|
||||||
|
|
||||||
self.config = RouterConfig(
|
|
||||||
timeout_seconds=cascade.get("timeout_seconds", 30),
|
|
||||||
max_retries_per_provider=cascade.get("max_retries_per_provider", 2),
|
|
||||||
retry_delay_seconds=cascade.get("retry_delay_seconds", 1),
|
|
||||||
circuit_breaker_failure_threshold=cascade.get("circuit_breaker", {}).get(
|
|
||||||
"failure_threshold", 5
|
|
||||||
),
|
|
||||||
circuit_breaker_recovery_timeout=cascade.get("circuit_breaker", {}).get(
|
|
||||||
"recovery_timeout", 60
|
|
||||||
),
|
|
||||||
circuit_breaker_half_open_max_calls=cascade.get("circuit_breaker", {}).get(
|
|
||||||
"half_open_max_calls", 2
|
|
||||||
),
|
|
||||||
auto_pull_models=multimodal.get("auto_pull", True),
|
|
||||||
fallback_chains=fallback_chains,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load providers
|
|
||||||
for p_data in data.get("providers", []):
|
|
||||||
# Skip disabled providers
|
|
||||||
if not p_data.get("enabled", False):
|
|
||||||
continue
|
|
||||||
|
|
||||||
provider = Provider(
|
|
||||||
name=p_data["name"],
|
|
||||||
type=p_data["type"],
|
|
||||||
enabled=p_data.get("enabled", True),
|
|
||||||
priority=p_data.get("priority", 99),
|
|
||||||
url=p_data.get("url"),
|
|
||||||
api_key=p_data.get("api_key"),
|
|
||||||
base_url=p_data.get("base_url"),
|
|
||||||
models=p_data.get("models", []),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if provider is actually available
|
|
||||||
if self._check_provider_available(provider):
|
|
||||||
self.providers.append(provider)
|
|
||||||
else:
|
|
||||||
logger.warning("Provider %s not available, skipping", provider.name)
|
|
||||||
|
|
||||||
# Sort by priority
|
|
||||||
self.providers.sort(key=lambda p: p.priority)
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to load config: %s", exc)
|
logger.error("Failed to load config: %s", exc)
|
||||||
|
|
||||||
|
def _parse_router_config(self, data: dict) -> RouterConfig:
|
||||||
|
"""Build a RouterConfig from parsed YAML data."""
|
||||||
|
cascade = data.get("cascade", {})
|
||||||
|
cb = cascade.get("circuit_breaker", {})
|
||||||
|
multimodal = data.get("multimodal", {})
|
||||||
|
|
||||||
|
return RouterConfig(
|
||||||
|
timeout_seconds=cascade.get("timeout_seconds", 30),
|
||||||
|
max_retries_per_provider=cascade.get("max_retries_per_provider", 2),
|
||||||
|
retry_delay_seconds=cascade.get("retry_delay_seconds", 1),
|
||||||
|
circuit_breaker_failure_threshold=cb.get("failure_threshold", 5),
|
||||||
|
circuit_breaker_recovery_timeout=cb.get("recovery_timeout", 60),
|
||||||
|
circuit_breaker_half_open_max_calls=cb.get("half_open_max_calls", 2),
|
||||||
|
auto_pull_models=multimodal.get("auto_pull", True),
|
||||||
|
fallback_chains=data.get("fallback_chains", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_providers(self, data: dict) -> None:
|
||||||
|
"""Load, filter, and sort providers from parsed YAML data."""
|
||||||
|
for p_data in data.get("providers", []):
|
||||||
|
if not p_data.get("enabled", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
provider = Provider(
|
||||||
|
name=p_data["name"],
|
||||||
|
type=p_data["type"],
|
||||||
|
enabled=p_data.get("enabled", True),
|
||||||
|
priority=p_data.get("priority", 99),
|
||||||
|
url=p_data.get("url"),
|
||||||
|
api_key=p_data.get("api_key"),
|
||||||
|
base_url=p_data.get("base_url"),
|
||||||
|
models=p_data.get("models", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._check_provider_available(provider):
|
||||||
|
self.providers.append(provider)
|
||||||
|
else:
|
||||||
|
logger.warning("Provider %s not available, skipping", provider.name)
|
||||||
|
|
||||||
|
self.providers.sort(key=lambda p: p.priority)
|
||||||
|
|
||||||
def _expand_env_vars(self, content: str) -> str:
|
def _expand_env_vars(self, content: str) -> str:
|
||||||
"""Expand ${VAR} syntax in YAML content.
|
"""Expand ${VAR} syntax in YAML content.
|
||||||
|
|
||||||
@@ -768,7 +759,7 @@ class CascadeRouter:
|
|||||||
|
|
||||||
client = openai.AsyncOpenAI(
|
client = openai.AsyncOpenAI(
|
||||||
api_key=provider.api_key,
|
api_key=provider.api_key,
|
||||||
base_url=provider.base_url or "https://api.x.ai/v1",
|
base_url=provider.base_url or settings.xai_base_url,
|
||||||
timeout=httpx.Timeout(300.0),
|
timeout=httpx.Timeout(300.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -99,23 +99,27 @@ class GrokBackend:
|
|||||||
|
|
||||||
def _get_client(self):
|
def _get_client(self):
|
||||||
"""Create OpenAI client configured for xAI endpoint."""
|
"""Create OpenAI client configured for xAI endpoint."""
|
||||||
|
from config import settings
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
|
|
||||||
return OpenAI(
|
return OpenAI(
|
||||||
api_key=self._api_key,
|
api_key=self._api_key,
|
||||||
base_url="https://api.x.ai/v1",
|
base_url=settings.xai_base_url,
|
||||||
timeout=httpx.Timeout(300.0),
|
timeout=httpx.Timeout(300.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _get_async_client(self):
|
async def _get_async_client(self):
|
||||||
"""Create async OpenAI client configured for xAI endpoint."""
|
"""Create async OpenAI client configured for xAI endpoint."""
|
||||||
|
from config import settings
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
return AsyncOpenAI(
|
return AsyncOpenAI(
|
||||||
api_key=self._api_key,
|
api_key=self._api_key,
|
||||||
base_url="https://api.x.ai/v1",
|
base_url=settings.xai_base_url,
|
||||||
timeout=httpx.Timeout(300.0),
|
timeout=httpx.Timeout(300.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -174,15 +174,8 @@ class ConversationManager:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def should_use_tools(self, message: str, context: ConversationContext) -> bool:
|
_TOOL_KEYWORDS = frozenset(
|
||||||
"""Determine if this message likely requires tools.
|
{
|
||||||
|
|
||||||
Returns True if tools are likely needed, False for simple chat.
|
|
||||||
"""
|
|
||||||
message_lower = message.lower().strip()
|
|
||||||
|
|
||||||
# Tool keywords that suggest tool usage is needed
|
|
||||||
tool_keywords = [
|
|
||||||
"search",
|
"search",
|
||||||
"look up",
|
"look up",
|
||||||
"find",
|
"find",
|
||||||
@@ -203,10 +196,11 @@ class ConversationManager:
|
|||||||
"shell",
|
"shell",
|
||||||
"command",
|
"command",
|
||||||
"install",
|
"install",
|
||||||
]
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Chat-only keywords that definitely don't need tools
|
_CHAT_ONLY_KEYWORDS = frozenset(
|
||||||
chat_only = [
|
{
|
||||||
"hello",
|
"hello",
|
||||||
"hi ",
|
"hi ",
|
||||||
"hey",
|
"hey",
|
||||||
@@ -221,30 +215,47 @@ class ConversationManager:
|
|||||||
"goodbye",
|
"goodbye",
|
||||||
"tell me about yourself",
|
"tell me about yourself",
|
||||||
"what can you do",
|
"what can you do",
|
||||||
]
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Check for chat-only patterns first
|
_SIMPLE_QUESTION_PREFIXES = ("what is", "who is", "how does", "why is", "when did", "where is")
|
||||||
for pattern in chat_only:
|
_TIME_WORDS = ("today", "now", "current", "latest", "this week", "this month")
|
||||||
if pattern in message_lower:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check for tool keywords
|
def _is_chat_only(self, message_lower: str) -> bool:
|
||||||
for keyword in tool_keywords:
|
"""Return True if the message matches a chat-only pattern."""
|
||||||
if keyword in message_lower:
|
return any(kw in message_lower for kw in self._CHAT_ONLY_KEYWORDS)
|
||||||
return True
|
|
||||||
|
|
||||||
# Simple questions (starting with what, who, how, why, when, where)
|
def _has_tool_keyword(self, message_lower: str) -> bool:
|
||||||
# usually don't need tools unless about current/real-time info
|
"""Return True if the message contains a tool-related keyword."""
|
||||||
simple_question_words = ["what is", "who is", "how does", "why is", "when did", "where is"]
|
return any(kw in message_lower for kw in self._TOOL_KEYWORDS)
|
||||||
for word in simple_question_words:
|
|
||||||
if message_lower.startswith(word):
|
def _is_simple_question(self, message_lower: str) -> bool | None:
|
||||||
# Check if it's asking about current/real-time info
|
"""Check if message is a simple question.
|
||||||
time_words = ["today", "now", "current", "latest", "this week", "this month"]
|
|
||||||
if any(t in message_lower for t in time_words):
|
Returns True if it needs tools (real-time info), False if it
|
||||||
return True
|
doesn't, or None if the message isn't a simple question.
|
||||||
return False
|
"""
|
||||||
|
for prefix in self._SIMPLE_QUESTION_PREFIXES:
|
||||||
|
if message_lower.startswith(prefix):
|
||||||
|
return any(t in message_lower for t in self._TIME_WORDS)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def should_use_tools(self, message: str, context: ConversationContext) -> bool:
|
||||||
|
"""Determine if this message likely requires tools.
|
||||||
|
|
||||||
|
Returns True if tools are likely needed, False for simple chat.
|
||||||
|
"""
|
||||||
|
message_lower = message.lower().strip()
|
||||||
|
|
||||||
|
if self._is_chat_only(message_lower):
|
||||||
|
return False
|
||||||
|
if self._has_tool_keyword(message_lower):
|
||||||
|
return True
|
||||||
|
|
||||||
|
simple = self._is_simple_question(message_lower)
|
||||||
|
if simple is not None:
|
||||||
|
return simple
|
||||||
|
|
||||||
# Default: don't use tools for unclear cases
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import shutil
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -196,7 +196,7 @@ def _bridge_to_work_order(title: str, body: str, category: str) -> None:
|
|||||||
body,
|
body,
|
||||||
category,
|
category,
|
||||||
"timmy-thinking",
|
"timmy-thinking",
|
||||||
datetime.utcnow().isoformat(),
|
datetime.now(UTC).isoformat(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -204,15 +204,61 @@ def _bridge_to_work_order(title: str, body: str, category: str) -> None:
|
|||||||
logger.debug("Work order bridge failed: %s", exc)
|
logger.debug("Work order bridge failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_issue_session():
|
||||||
|
"""Get or create the cached MCP session, connecting if needed.
|
||||||
|
|
||||||
|
Returns the connected ``MCPTools`` instance.
|
||||||
|
"""
|
||||||
|
from agno.tools.mcp import MCPTools
|
||||||
|
|
||||||
|
global _issue_session
|
||||||
|
|
||||||
|
if _issue_session is None:
|
||||||
|
_issue_session = MCPTools(
|
||||||
|
server_params=_gitea_server_params(),
|
||||||
|
timeout_seconds=settings.mcp_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not getattr(_issue_session, "_connected", False):
|
||||||
|
await _issue_session.connect()
|
||||||
|
_issue_session._connected = True
|
||||||
|
|
||||||
|
return _issue_session
|
||||||
|
|
||||||
|
|
||||||
|
def _build_issue_body(body: str) -> str:
|
||||||
|
"""Append the auto-filing signature to the issue body."""
|
||||||
|
full_body = body
|
||||||
|
if full_body:
|
||||||
|
full_body += "\n\n"
|
||||||
|
full_body += "---\n*Auto-filed by Timmy's thinking engine*"
|
||||||
|
return full_body
|
||||||
|
|
||||||
|
|
||||||
|
def _build_issue_args(title: str, full_body: str) -> dict:
|
||||||
|
"""Build MCP tool arguments for ``issue_write`` with method=create."""
|
||||||
|
owner, repo = settings.gitea_repo.split("/", 1)
|
||||||
|
return {
|
||||||
|
"method": "create",
|
||||||
|
"owner": owner,
|
||||||
|
"repo": repo,
|
||||||
|
"title": title,
|
||||||
|
"body": full_body,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _category_from_labels(labels: str) -> str:
|
||||||
|
"""Derive a work-order category from comma-separated label names."""
|
||||||
|
label_list = [tag.strip() for tag in labels.split(",") if tag.strip()] if labels else []
|
||||||
|
return "bug" if "bug" in label_list else "suggestion"
|
||||||
|
|
||||||
|
|
||||||
async def create_gitea_issue_via_mcp(title: str, body: str = "", labels: str = "") -> str:
|
async def create_gitea_issue_via_mcp(title: str, body: str = "", labels: str = "") -> str:
|
||||||
"""File a Gitea issue via the MCP server (standalone, no LLM loop).
|
"""File a Gitea issue via the MCP server (standalone, no LLM loop).
|
||||||
|
|
||||||
Used by the thinking engine's ``_maybe_file_issues()`` post-hook.
|
Used by the thinking engine's ``_maybe_file_issues()`` post-hook.
|
||||||
Manages its own MCPTools session with lazy connect + graceful failure.
|
Manages its own MCPTools session with lazy connect + graceful failure.
|
||||||
|
|
||||||
Uses ``tools.session.call_tool()`` for direct MCP invocation — the
|
|
||||||
``MCPTools`` wrapper itself does not expose ``call_tool()``.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
title: Issue title.
|
title: Issue title.
|
||||||
body: Issue body (markdown).
|
body: Issue body (markdown).
|
||||||
@@ -225,46 +271,13 @@ async def create_gitea_issue_via_mcp(title: str, body: str = "", labels: str = "
|
|||||||
return "Gitea integration is not configured."
|
return "Gitea integration is not configured."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from agno.tools.mcp import MCPTools
|
session = await _ensure_issue_session()
|
||||||
|
full_body = _build_issue_body(body)
|
||||||
|
args = _build_issue_args(title, full_body)
|
||||||
|
|
||||||
global _issue_session
|
result = await session.session.call_tool("issue_write", arguments=args)
|
||||||
|
|
||||||
if _issue_session is None:
|
_bridge_to_work_order(title, body, _category_from_labels(labels))
|
||||||
_issue_session = MCPTools(
|
|
||||||
server_params=_gitea_server_params(),
|
|
||||||
timeout_seconds=settings.mcp_timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure connected
|
|
||||||
if not getattr(_issue_session, "_connected", False):
|
|
||||||
await _issue_session.connect()
|
|
||||||
_issue_session._connected = True
|
|
||||||
|
|
||||||
# Append auto-filing signature
|
|
||||||
full_body = body
|
|
||||||
if full_body:
|
|
||||||
full_body += "\n\n"
|
|
||||||
full_body += "---\n*Auto-filed by Timmy's thinking engine*"
|
|
||||||
|
|
||||||
# Parse owner/repo from settings
|
|
||||||
owner, repo = settings.gitea_repo.split("/", 1)
|
|
||||||
|
|
||||||
# Build tool arguments — gitea-mcp uses issue_write with method="create"
|
|
||||||
args = {
|
|
||||||
"method": "create",
|
|
||||||
"owner": owner,
|
|
||||||
"repo": repo,
|
|
||||||
"title": title,
|
|
||||||
"body": full_body,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Call via the underlying MCP session (MCPTools doesn't expose call_tool)
|
|
||||||
result = await _issue_session.session.call_tool("issue_write", arguments=args)
|
|
||||||
|
|
||||||
# Bridge to local work order
|
|
||||||
label_list = [tag.strip() for tag in labels.split(",") if tag.strip()] if labels else []
|
|
||||||
category = "bug" if "bug" in label_list else "suggestion"
|
|
||||||
_bridge_to_work_order(title, body, category)
|
|
||||||
|
|
||||||
logger.info("Created Gitea issue via MCP: %s", title[:60])
|
logger.info("Created Gitea issue via MCP: %s", title[:60])
|
||||||
return f"Created issue: {title}\n{result}"
|
return f"Created issue: {title}\n{result}"
|
||||||
|
|||||||
@@ -46,6 +46,64 @@ DB_PATH = PROJECT_ROOT / "data" / "memory.db"
|
|||||||
# ───────────────────────────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_HOT_MEMORY_TEMPLATE = """\
|
||||||
|
# Timmy Hot Memory
|
||||||
|
|
||||||
|
> Working RAM — always loaded, ~300 lines max, pruned monthly
|
||||||
|
> Last updated: {date}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
**Agent State:** Operational
|
||||||
|
**Mode:** Development
|
||||||
|
**Active Tasks:** 0
|
||||||
|
**Pending Decisions:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standing Rules
|
||||||
|
|
||||||
|
1. **Sovereignty First** — No cloud dependencies
|
||||||
|
2. **Local-Only Inference** — Ollama on localhost
|
||||||
|
3. **Privacy by Design** — Telemetry disabled
|
||||||
|
4. **Tool Minimalism** — Use tools only when necessary
|
||||||
|
5. **Memory Discipline** — Write handoffs at session end
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Roster
|
||||||
|
|
||||||
|
| Agent | Role | Status |
|
||||||
|
|-------|------|--------|
|
||||||
|
| Timmy | Core | Active |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Profile
|
||||||
|
|
||||||
|
**Name:** (not set)
|
||||||
|
**Interests:** (to be learned)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
(none yet)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pending Actions
|
||||||
|
|
||||||
|
- [ ] Learn user's name
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Prune date: {prune_date}*
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_connection() -> Generator[sqlite3.Connection, None, None]:
|
def get_connection() -> Generator[sqlite3.Connection, None, None]:
|
||||||
"""Get database connection to unified memory database."""
|
"""Get database connection to unified memory database."""
|
||||||
@@ -732,66 +790,12 @@ class HotMemory:
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
"HotMemory._create_default() - creating default MEMORY.md for backward compatibility"
|
"HotMemory._create_default() - creating default MEMORY.md for backward compatibility"
|
||||||
)
|
)
|
||||||
default_content = """# Timmy Hot Memory
|
now = datetime.now(UTC)
|
||||||
|
content = _DEFAULT_HOT_MEMORY_TEMPLATE.format(
|
||||||
> Working RAM — always loaded, ~300 lines max, pruned monthly
|
date=now.strftime("%Y-%m-%d"),
|
||||||
> Last updated: {date}
|
prune_date=now.replace(day=25).strftime("%Y-%m-%d"),
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
**Agent State:** Operational
|
|
||||||
**Mode:** Development
|
|
||||||
**Active Tasks:** 0
|
|
||||||
**Pending Decisions:** None
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Standing Rules
|
|
||||||
|
|
||||||
1. **Sovereignty First** — No cloud dependencies
|
|
||||||
2. **Local-Only Inference** — Ollama on localhost
|
|
||||||
3. **Privacy by Design** — Telemetry disabled
|
|
||||||
4. **Tool Minimalism** — Use tools only when necessary
|
|
||||||
5. **Memory Discipline** — Write handoffs at session end
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent Roster
|
|
||||||
|
|
||||||
| Agent | Role | Status |
|
|
||||||
|-------|------|--------|
|
|
||||||
| Timmy | Core | Active |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Profile
|
|
||||||
|
|
||||||
**Name:** (not set)
|
|
||||||
**Interests:** (to be learned)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Decisions
|
|
||||||
|
|
||||||
(none yet)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pending Actions
|
|
||||||
|
|
||||||
- [ ] Learn user's name
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Prune date: {prune_date}*
|
|
||||||
""".format(
|
|
||||||
date=datetime.now(UTC).strftime("%Y-%m-%d"),
|
|
||||||
prune_date=(datetime.now(UTC).replace(day=25)).strftime("%Y-%m-%d"),
|
|
||||||
)
|
)
|
||||||
|
self.path.write_text(content)
|
||||||
self.path.write_text(default_content)
|
|
||||||
logger.info("HotMemory: Created default MEMORY.md")
|
logger.info("HotMemory: Created default MEMORY.md")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -392,31 +392,26 @@ def _build_insights(
|
|||||||
return insights or ["Conversations look healthy. Keep up the good work."]
|
return insights or ["Conversations look healthy. Keep up the good work."]
|
||||||
|
|
||||||
|
|
||||||
def self_reflect(limit: int = 30) -> str:
|
def _format_recurring_topics(repeated: list[tuple[str, int]]) -> list[str]:
|
||||||
"""Review recent conversations and reflect on Timmy's own behavior.
|
"""Format the recurring-topics section of a reflection report."""
|
||||||
|
if repeated:
|
||||||
|
lines = ["### Recurring Topics"]
|
||||||
|
for word, count in repeated:
|
||||||
|
lines.append(f'- "{word}" ({count} mentions)')
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
return ["### Recurring Topics\nNo strong patterns detected.\n"]
|
||||||
|
|
||||||
Scans past session entries for patterns: low-confidence responses,
|
|
||||||
errors, repeated topics, and conversation quality signals. Returns
|
|
||||||
a structured reflection that Timmy can use to improve.
|
|
||||||
|
|
||||||
Args:
|
def _assemble_report(
|
||||||
limit: How many recent entries to review (default 30).
|
entries: list[dict],
|
||||||
|
errors: list[dict],
|
||||||
Returns:
|
timmy_msgs: list[dict],
|
||||||
A formatted self-reflection report.
|
user_msgs: list[dict],
|
||||||
"""
|
low_conf: list[dict],
|
||||||
sl = get_session_logger()
|
repeated: list[tuple[str, int]],
|
||||||
sl.flush()
|
) -> str:
|
||||||
entries = sl.get_recent_entries(limit=limit)
|
"""Assemble the full self-reflection report from analyzed data."""
|
||||||
|
|
||||||
if not entries:
|
|
||||||
return "No conversation history to reflect on yet."
|
|
||||||
|
|
||||||
_messages, errors, timmy_msgs, user_msgs = _categorize_entries(entries)
|
|
||||||
low_conf = _find_low_confidence(timmy_msgs)
|
|
||||||
repeated = _find_repeated_topics(user_msgs)
|
|
||||||
|
|
||||||
# Build reflection report
|
|
||||||
sections: list[str] = ["## Self-Reflection Report\n"]
|
sections: list[str] = ["## Self-Reflection Report\n"]
|
||||||
sections.append(
|
sections.append(
|
||||||
f"Reviewed {len(entries)} recent entries: "
|
f"Reviewed {len(entries)} recent entries: "
|
||||||
@@ -446,16 +441,37 @@ def self_reflect(limit: int = 30) -> str:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if repeated:
|
sections.extend(_format_recurring_topics(repeated))
|
||||||
sections.append("### Recurring Topics")
|
|
||||||
for word, count in repeated:
|
|
||||||
sections.append(f'- "{word}" ({count} mentions)')
|
|
||||||
sections.append("")
|
|
||||||
else:
|
|
||||||
sections.append("### Recurring Topics\nNo strong patterns detected.\n")
|
|
||||||
|
|
||||||
sections.append("### Insights")
|
sections.append("### Insights")
|
||||||
for insight in _build_insights(low_conf, errors, repeated):
|
for insight in _build_insights(low_conf, errors, repeated):
|
||||||
sections.append(f"- {insight}")
|
sections.append(f"- {insight}")
|
||||||
|
|
||||||
return "\n".join(sections)
|
return "\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def self_reflect(limit: int = 30) -> str:
|
||||||
|
"""Review recent conversations and reflect on Timmy's own behavior.
|
||||||
|
|
||||||
|
Scans past session entries for patterns: low-confidence responses,
|
||||||
|
errors, repeated topics, and conversation quality signals. Returns
|
||||||
|
a structured reflection that Timmy can use to improve.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: How many recent entries to review (default 30).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A formatted self-reflection report.
|
||||||
|
"""
|
||||||
|
sl = get_session_logger()
|
||||||
|
sl.flush()
|
||||||
|
entries = sl.get_recent_entries(limit=limit)
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
return "No conversation history to reflect on yet."
|
||||||
|
|
||||||
|
_messages, errors, timmy_msgs, user_msgs = _categorize_entries(entries)
|
||||||
|
low_conf = _find_low_confidence(timmy_msgs)
|
||||||
|
repeated = _find_repeated_topics(user_msgs)
|
||||||
|
|
||||||
|
return _assemble_report(entries, errors, timmy_msgs, user_msgs, low_conf, repeated)
|
||||||
|
|||||||
@@ -39,21 +39,19 @@ _DEFAULT_DB = Path("data/thoughts.db")
|
|||||||
# qwen3 and other reasoning models wrap chain-of-thought in <think> tags
|
# qwen3 and other reasoning models wrap chain-of-thought in <think> tags
|
||||||
_THINK_TAG_RE = re.compile(r"<think>.*?</think>\s*", re.DOTALL)
|
_THINK_TAG_RE = re.compile(r"<think>.*?</think>\s*", re.DOTALL)
|
||||||
|
|
||||||
# Sensitive patterns that must never be stored as facts.
|
# Sensitive patterns that must never be stored as facts
|
||||||
# Uses compiled regexes with word boundaries so that compound technical
|
_SENSITIVE_PATTERNS = [
|
||||||
# terms like "max_tokens" or "num_tokens" are NOT falsely flagged.
|
"token",
|
||||||
_SENSITIVE_RE = [
|
"password",
|
||||||
re.compile(r"(?<![a-z_])token(?![a-z_])", re.IGNORECASE), # "token" but not "max_tokens"
|
"secret",
|
||||||
re.compile(r"password", re.IGNORECASE),
|
"api_key",
|
||||||
re.compile(r"secret", re.IGNORECASE),
|
"apikey",
|
||||||
re.compile(r"api_key", re.IGNORECASE),
|
"credential",
|
||||||
re.compile(r"apikey", re.IGNORECASE),
|
".config/",
|
||||||
re.compile(r"credential", re.IGNORECASE),
|
"/token",
|
||||||
re.compile(r"\.config/"),
|
"access_token",
|
||||||
re.compile(r"/token\b"),
|
"private_key",
|
||||||
re.compile(r"access_token", re.IGNORECASE),
|
"ssh_key",
|
||||||
re.compile(r"private_key", re.IGNORECASE),
|
|
||||||
re.compile(r"ssh_key", re.IGNORECASE),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Meta-observation phrases to filter out from distilled facts
|
# Meta-observation phrases to filter out from distilled facts
|
||||||
@@ -550,7 +548,7 @@ class ThinkingEngine:
|
|||||||
fact_lower = fact.lower()
|
fact_lower = fact.lower()
|
||||||
|
|
||||||
# Block sensitive information
|
# Block sensitive information
|
||||||
if any(pat.search(fact) for pat in _SENSITIVE_RE):
|
if any(pat in fact_lower for pat in _SENSITIVE_PATTERNS):
|
||||||
logger.warning("Distill: blocked sensitive fact: %s", fact[:60])
|
logger.warning("Distill: blocked sensitive fact: %s", fact[:60])
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -89,45 +89,31 @@ def list_swarm_agents() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]:
|
def _find_kimi_cli() -> str | None:
|
||||||
"""Delegate a coding task to Kimi, the external coding agent.
|
"""Return the path to the kimi CLI binary, or None if not installed."""
|
||||||
|
|
||||||
Kimi has 262K context and is optimized for code tasks: writing,
|
|
||||||
debugging, refactoring, test writing. Timmy thinks and plans,
|
|
||||||
Kimi executes bulk code changes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task: Clear, specific coding task description. Include file paths
|
|
||||||
and expected behavior. Good: "Fix the bug in src/timmy/session.py
|
|
||||||
where sessions don't persist." Bad: "Fix all bugs."
|
|
||||||
working_directory: Directory for Kimi to work in. Defaults to repo root.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with success status and Kimi's output or error.
|
|
||||||
"""
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
|
return shutil.which("kimi")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_workdir(working_directory: str) -> str | dict[str, Any]:
|
||||||
|
"""Return a validated working directory path, or an error dict."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
kimi_path = shutil.which("kimi")
|
|
||||||
if not kimi_path:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": "kimi CLI not found on PATH. Install with: pip install kimi-cli",
|
|
||||||
}
|
|
||||||
|
|
||||||
workdir = working_directory or settings.repo_root
|
workdir = working_directory or settings.repo_root
|
||||||
if not Path(workdir).is_dir():
|
if not Path(workdir).is_dir():
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": f"Working directory does not exist: {workdir}",
|
"error": f"Working directory does not exist: {workdir}",
|
||||||
}
|
}
|
||||||
|
return workdir
|
||||||
|
|
||||||
cmd = [kimi_path, "--print", "-p", task]
|
|
||||||
|
|
||||||
logger.info("Delegating to Kimi: %s (cwd=%s)", task[:80], workdir)
|
def _run_kimi(cmd: list[str], workdir: str) -> dict[str, Any]:
|
||||||
|
"""Execute the kimi subprocess and return a result dict."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -157,3 +143,34 @@ def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"error": f"Failed to run Kimi: {exc}",
|
"error": f"Failed to run Kimi: {exc}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]:
|
||||||
|
"""Delegate a coding task to Kimi, the external coding agent.
|
||||||
|
|
||||||
|
Kimi has 262K context and is optimized for code tasks: writing,
|
||||||
|
debugging, refactoring, test writing. Timmy thinks and plans,
|
||||||
|
Kimi executes bulk code changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Clear, specific coding task description. Include file paths
|
||||||
|
and expected behavior. Good: "Fix the bug in src/timmy/session.py
|
||||||
|
where sessions don't persist." Bad: "Fix all bugs."
|
||||||
|
working_directory: Directory for Kimi to work in. Defaults to repo root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status and Kimi's output or error.
|
||||||
|
"""
|
||||||
|
kimi_path = _find_kimi_cli()
|
||||||
|
if not kimi_path:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "kimi CLI not found on PATH. Install with: pip install kimi-cli",
|
||||||
|
}
|
||||||
|
|
||||||
|
workdir = _resolve_workdir(working_directory)
|
||||||
|
if isinstance(workdir, dict):
|
||||||
|
return workdir
|
||||||
|
|
||||||
|
logger.info("Delegating to Kimi: %s (cwd=%s)", task[:80], workdir)
|
||||||
|
return _run_kimi([kimi_path, "--print", "-p", task], workdir)
|
||||||
|
|||||||
@@ -86,6 +86,40 @@ def _pip_snapshot(mood: str, confidence: float) -> dict:
|
|||||||
return pip_familiar.snapshot().to_dict()
|
return pip_familiar.snapshot().to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_mood(state) -> str:
|
||||||
|
"""Map cognitive mood/engagement to a presence mood string."""
|
||||||
|
if state.engagement == "idle" and state.mood == "settled":
|
||||||
|
return "calm"
|
||||||
|
return _MOOD_MAP.get(state.mood, "calm")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_confidence(state) -> float:
|
||||||
|
"""Compute normalised confidence from cognitive tracker state."""
|
||||||
|
if state._confidence_count > 0:
|
||||||
|
raw = state._confidence_sum / state._confidence_count
|
||||||
|
else:
|
||||||
|
raw = 0.7
|
||||||
|
return round(max(0.0, min(1.0, raw)), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_active_threads(state) -> list[dict]:
|
||||||
|
"""Convert active commitments into presence thread dicts."""
|
||||||
|
return [
|
||||||
|
{"type": "thinking", "ref": c[:80], "status": "active"}
|
||||||
|
for c in state.active_commitments[:10]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_environment() -> dict:
|
||||||
|
"""Return the environment section using local wall-clock time."""
|
||||||
|
local_now = datetime.now()
|
||||||
|
return {
|
||||||
|
"time_of_day": _time_of_day(local_now.hour),
|
||||||
|
"local_time": local_now.strftime("%-I:%M %p"),
|
||||||
|
"day_of_week": local_now.strftime("%A"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_state_dict() -> dict:
|
def get_state_dict() -> dict:
|
||||||
"""Build presence state dict from current cognitive state.
|
"""Build presence state dict from current cognitive state.
|
||||||
|
|
||||||
@@ -98,37 +132,19 @@ def get_state_dict() -> dict:
|
|||||||
state = cognitive_tracker.get_state()
|
state = cognitive_tracker.get_state()
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
# Map cognitive mood to presence mood
|
mood = _resolve_mood(state)
|
||||||
mood = _MOOD_MAP.get(state.mood, "calm")
|
confidence = _resolve_confidence(state)
|
||||||
if state.engagement == "idle" and state.mood == "settled":
|
|
||||||
mood = "calm"
|
|
||||||
|
|
||||||
# Confidence from cognitive tracker
|
|
||||||
if state._confidence_count > 0:
|
|
||||||
confidence = state._confidence_sum / state._confidence_count
|
|
||||||
else:
|
|
||||||
confidence = 0.7
|
|
||||||
|
|
||||||
# Build active threads from commitments
|
|
||||||
threads = []
|
|
||||||
for commitment in state.active_commitments[:10]:
|
|
||||||
threads.append({"type": "thinking", "ref": commitment[:80], "status": "active"})
|
|
||||||
|
|
||||||
# Activity
|
|
||||||
activity = _ACTIVITY_MAP.get(state.engagement, "idle")
|
activity = _ACTIVITY_MAP.get(state.engagement, "idle")
|
||||||
|
|
||||||
# Environment
|
|
||||||
local_now = datetime.now()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"liveness": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
"liveness": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
"current_focus": state.focus_topic or "",
|
"current_focus": state.focus_topic or "",
|
||||||
"active_threads": threads,
|
"active_threads": _build_active_threads(state),
|
||||||
"recent_events": [],
|
"recent_events": [],
|
||||||
"concerns": [],
|
"concerns": [],
|
||||||
"mood": mood,
|
"mood": mood,
|
||||||
"confidence": round(max(0.0, min(1.0, confidence)), 2),
|
"confidence": confidence,
|
||||||
"energy": round(_current_energy(), 2),
|
"energy": round(_current_energy(), 2),
|
||||||
"identity": {
|
"identity": {
|
||||||
"name": "Timmy",
|
"name": "Timmy",
|
||||||
@@ -143,11 +159,7 @@ def get_state_dict() -> dict:
|
|||||||
"visitor_present": False,
|
"visitor_present": False,
|
||||||
"conversation_turns": state.conversation_depth,
|
"conversation_turns": state.conversation_depth,
|
||||||
},
|
},
|
||||||
"environment": {
|
"environment": _build_environment(),
|
||||||
"time_of_day": _time_of_day(local_now.hour),
|
|
||||||
"local_time": local_now.strftime("%-I:%M %p"),
|
|
||||||
"day_of_week": local_now.strftime("%A"),
|
|
||||||
},
|
|
||||||
"familiar": _pip_snapshot(mood, confidence),
|
"familiar": _pip_snapshot(mood, confidence),
|
||||||
"meta": {
|
"meta": {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
|
|||||||
100
tests/dashboard/middleware/test_csrf_no_side_effects.py
Normal file
100
tests/dashboard/middleware/test_csrf_no_side_effects.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Tests that CSRF rejection does NOT execute the endpoint handler.
|
||||||
|
|
||||||
|
Regression test for #626: the middleware was calling call_next() before
|
||||||
|
checking @csrf_exempt, causing side effects even on CSRF-rejected requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from dashboard.middleware.csrf import CSRFMiddleware, csrf_exempt
|
||||||
|
|
||||||
|
|
||||||
|
class TestCSRFNoSideEffects:
|
||||||
|
"""Verify endpoints are NOT executed when CSRF validation fails."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def enable_csrf(self):
|
||||||
|
"""Re-enable CSRF for these tests."""
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
original = settings.timmy_disable_csrf
|
||||||
|
settings.timmy_disable_csrf = False
|
||||||
|
yield
|
||||||
|
settings.timmy_disable_csrf = original
|
||||||
|
|
||||||
|
def test_protected_endpoint_not_executed_on_csrf_failure(self):
|
||||||
|
"""A protected endpoint must NOT run when CSRF token is missing.
|
||||||
|
|
||||||
|
Before the fix, the middleware called call_next() to resolve the
|
||||||
|
endpoint, executing its side effects before returning 403.
|
||||||
|
"""
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(CSRFMiddleware)
|
||||||
|
|
||||||
|
side_effect_log = []
|
||||||
|
|
||||||
|
@app.post("/transfer")
|
||||||
|
def transfer_money():
|
||||||
|
side_effect_log.append("money_transferred")
|
||||||
|
return {"message": "transferred"}
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post("/transfer")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert side_effect_log == [], (
|
||||||
|
"Endpoint was executed despite CSRF failure — side effects occurred!"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_csrf_exempt_endpoint_still_executes(self):
|
||||||
|
"""A @csrf_exempt endpoint should still execute without a CSRF token."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(CSRFMiddleware)
|
||||||
|
|
||||||
|
side_effect_log = []
|
||||||
|
|
||||||
|
@app.post("/webhook-handler")
|
||||||
|
@csrf_exempt
|
||||||
|
def webhook_handler():
|
||||||
|
side_effect_log.append("webhook_processed")
|
||||||
|
return {"message": "processed"}
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post("/webhook-handler")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert side_effect_log == ["webhook_processed"]
|
||||||
|
|
||||||
|
def test_exempt_and_protected_no_cross_contamination(self):
|
||||||
|
"""Mixed exempt/protected: only exempt endpoints execute without tokens."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(CSRFMiddleware)
|
||||||
|
|
||||||
|
execution_log = []
|
||||||
|
|
||||||
|
@app.post("/safe-webhook")
|
||||||
|
@csrf_exempt
|
||||||
|
def safe_webhook():
|
||||||
|
execution_log.append("safe")
|
||||||
|
return {"message": "safe"}
|
||||||
|
|
||||||
|
@app.post("/dangerous-action")
|
||||||
|
def dangerous_action():
|
||||||
|
execution_log.append("dangerous")
|
||||||
|
return {"message": "danger"}
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Exempt endpoint runs
|
||||||
|
resp1 = client.post("/safe-webhook")
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
|
||||||
|
# Protected endpoint blocked WITHOUT executing
|
||||||
|
resp2 = client.post("/dangerous-action")
|
||||||
|
assert resp2.status_code == 403
|
||||||
|
|
||||||
|
assert execution_log == ["safe"], (
|
||||||
|
f"Expected only 'safe' execution, got: {execution_log}"
|
||||||
|
)
|
||||||
@@ -5,11 +5,13 @@ from datetime import UTC, datetime, timedelta
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from infrastructure.error_capture import (
|
from infrastructure.error_capture import (
|
||||||
|
_build_report_description,
|
||||||
_create_bug_report,
|
_create_bug_report,
|
||||||
_dedup_cache,
|
_dedup_cache,
|
||||||
_extract_traceback_info,
|
_extract_traceback_info,
|
||||||
_get_git_context,
|
_get_git_context,
|
||||||
_is_duplicate,
|
_is_duplicate,
|
||||||
|
_log_bug_report_created,
|
||||||
_log_error_event,
|
_log_error_event,
|
||||||
_notify_bug_report,
|
_notify_bug_report,
|
||||||
_record_to_session,
|
_record_to_session,
|
||||||
@@ -231,6 +233,68 @@ class TestLogErrorEvent:
|
|||||||
_log_error_event(e, "test", "abc123", "file.py", 42, {"branch": "main"})
|
_log_error_event(e, "test", "abc123", "file.py", 42, {"branch": "main"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildReportDescription:
|
||||||
|
"""Test _build_report_description helper."""
|
||||||
|
|
||||||
|
def test_includes_error_info(self):
|
||||||
|
try:
|
||||||
|
raise RuntimeError("desc test")
|
||||||
|
except RuntimeError as e:
|
||||||
|
desc = _build_report_description(
|
||||||
|
e,
|
||||||
|
"test_src",
|
||||||
|
None,
|
||||||
|
"hash1",
|
||||||
|
"tb...",
|
||||||
|
"file.py",
|
||||||
|
10,
|
||||||
|
{"branch": "main"},
|
||||||
|
)
|
||||||
|
assert "RuntimeError" in desc
|
||||||
|
assert "test_src" in desc
|
||||||
|
assert "file.py:10" in desc
|
||||||
|
assert "hash1" in desc
|
||||||
|
|
||||||
|
def test_includes_context_when_provided(self):
|
||||||
|
try:
|
||||||
|
raise RuntimeError("ctx desc")
|
||||||
|
except RuntimeError as e:
|
||||||
|
desc = _build_report_description(
|
||||||
|
e,
|
||||||
|
"src",
|
||||||
|
{"path": "/api"},
|
||||||
|
"h",
|
||||||
|
"tb",
|
||||||
|
"f.py",
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert "path=/api" in desc
|
||||||
|
|
||||||
|
def test_omits_context_when_none(self):
|
||||||
|
try:
|
||||||
|
raise RuntimeError("no ctx")
|
||||||
|
except RuntimeError as e:
|
||||||
|
desc = _build_report_description(
|
||||||
|
e,
|
||||||
|
"src",
|
||||||
|
None,
|
||||||
|
"h",
|
||||||
|
"tb",
|
||||||
|
"f.py",
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert "**Context:**" not in desc
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogBugReportCreated:
|
||||||
|
"""Test _log_bug_report_created helper."""
|
||||||
|
|
||||||
|
def test_does_not_crash_on_missing_deps(self):
|
||||||
|
_log_bug_report_created("test", "task-1", "hash1", "title")
|
||||||
|
|
||||||
|
|
||||||
class TestCreateBugReport:
|
class TestCreateBugReport:
|
||||||
"""Test _create_bug_report helper."""
|
"""Test _create_bug_report helper."""
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import pytest
|
|||||||
|
|
||||||
from timmy.mcp_tools import (
|
from timmy.mcp_tools import (
|
||||||
_bridge_to_work_order,
|
_bridge_to_work_order,
|
||||||
|
_build_issue_args,
|
||||||
|
_build_issue_body,
|
||||||
|
_category_from_labels,
|
||||||
_generate_avatar_image,
|
_generate_avatar_image,
|
||||||
_parse_command,
|
_parse_command,
|
||||||
close_mcp_sessions,
|
close_mcp_sessions,
|
||||||
@@ -132,6 +135,49 @@ def test_filesystem_mcp_returns_tools():
|
|||||||
assert "/home/user/project" in params_kwargs["args"]
|
assert "/home/user/project" in params_kwargs["args"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _build_issue_body / _build_issue_args / _category_from_labels
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_issue_body_appends_signature():
|
||||||
|
"""_build_issue_body appends the auto-filing signature."""
|
||||||
|
result = _build_issue_body("Some description")
|
||||||
|
assert result.startswith("Some description\n\n")
|
||||||
|
assert "Auto-filed by Timmy" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_issue_body_empty():
|
||||||
|
"""_build_issue_body handles empty body."""
|
||||||
|
result = _build_issue_body("")
|
||||||
|
assert result.startswith("---\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_issue_args():
|
||||||
|
"""_build_issue_args returns correct MCP arguments."""
|
||||||
|
with patch("timmy.mcp_tools.settings") as mock_settings:
|
||||||
|
mock_settings.gitea_repo = "owner/repo"
|
||||||
|
result = _build_issue_args("Title", "Body")
|
||||||
|
assert result == {
|
||||||
|
"method": "create",
|
||||||
|
"owner": "owner",
|
||||||
|
"repo": "repo",
|
||||||
|
"title": "Title",
|
||||||
|
"body": "Body",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_from_labels_bug():
|
||||||
|
"""_category_from_labels returns 'bug' when labels contain bug."""
|
||||||
|
assert _category_from_labels("bug, enhancement") == "bug"
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_from_labels_default():
|
||||||
|
"""_category_from_labels returns 'suggestion' by default."""
|
||||||
|
assert _category_from_labels("enhancement") == "suggestion"
|
||||||
|
assert _category_from_labels("") == "suggestion"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# create_gitea_issue_via_mcp
|
# create_gitea_issue_via_mcp
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1188,42 +1188,3 @@ def test_references_real_files_blocks_mixed(tmp_path):
|
|||||||
# Mix of real and fake files — should fail because of the fake one
|
# Mix of real and fake files — should fail because of the fake one
|
||||||
text = "Fix src/timmy/thinking.py and also src/timmy/nonexistent_module.py for the memory leak."
|
text = "Fix src/timmy/thinking.py and also src/timmy/nonexistent_module.py for the memory leak."
|
||||||
assert ThinkingEngine._references_real_files(text) is False
|
assert ThinkingEngine._references_real_files(text) is False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sensitive-pattern regression: max_tokens must NOT be flagged (#625)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_sensitive_patterns_allow_max_tokens():
|
|
||||||
"""_SENSITIVE_RE should not flag 'max_tokens' as sensitive (#625)."""
|
|
||||||
from timmy.thinking import _SENSITIVE_RE
|
|
||||||
|
|
||||||
safe_facts = [
|
|
||||||
"The cascade router passes max_tokens to Ollama provider.",
|
|
||||||
"max_tokens=request.max_tokens in the completion call.",
|
|
||||||
"num_tokens defaults to 2048.",
|
|
||||||
"total_prompt_tokens is tracked in stats.",
|
|
||||||
]
|
|
||||||
for fact in safe_facts:
|
|
||||||
assert not any(pat.search(fact) for pat in _SENSITIVE_RE), (
|
|
||||||
f"False positive: {fact!r} was flagged as sensitive"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sensitive_patterns_still_block_real_secrets():
|
|
||||||
"""_SENSITIVE_RE should still block actual secrets."""
|
|
||||||
from timmy.thinking import _SENSITIVE_RE
|
|
||||||
|
|
||||||
dangerous_facts = [
|
|
||||||
"The token is abc123def456.",
|
|
||||||
"Set password to hunter2.",
|
|
||||||
"api_key = sk-live-xyz",
|
|
||||||
"Found credential in .env file.",
|
|
||||||
"access_token expired yesterday.",
|
|
||||||
"private_key stored in vault.",
|
|
||||||
]
|
|
||||||
for fact in dangerous_facts:
|
|
||||||
assert any(pat.search(fact) for pat in _SENSITIVE_RE), (
|
|
||||||
f"Missed secret: {fact!r} was NOT flagged as sensitive"
|
|
||||||
)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user