Compare commits
14 Commits
kimi/issue
...
kimi/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f32b5a9e4d | ||
| 6214ad3225 | |||
| 5f5da2163f | |||
| 0029c34bb1 | |||
| 2577b71207 | |||
| 1a8b8ecaed | |||
| d821e76589 | |||
| bc010ecfba | |||
| faf6c1a5f1 | |||
| 48103bb076 | |||
| 9f244ffc70 | |||
| 0162a604be | |||
| 2326771c5a | |||
| 8f6cf2681b |
@@ -19,14 +19,17 @@ router = APIRouter(tags=["calm"])
|
|||||||
|
|
||||||
# Helper functions for state machine logic
|
# Helper functions for state machine logic
|
||||||
def get_now_task(db: Session) -> Task | None:
|
def get_now_task(db: Session) -> Task | None:
|
||||||
|
"""Return the single active NOW task, or None."""
|
||||||
return db.query(Task).filter(Task.state == TaskState.NOW).first()
|
return db.query(Task).filter(Task.state == TaskState.NOW).first()
|
||||||
|
|
||||||
|
|
||||||
def get_next_task(db: Session) -> Task | None:
|
def get_next_task(db: Session) -> Task | None:
|
||||||
|
"""Return the single queued NEXT task, or None."""
|
||||||
return db.query(Task).filter(Task.state == TaskState.NEXT).first()
|
return db.query(Task).filter(Task.state == TaskState.NEXT).first()
|
||||||
|
|
||||||
|
|
||||||
def get_later_tasks(db: Session) -> list[Task]:
|
def get_later_tasks(db: Session) -> list[Task]:
|
||||||
|
"""Return all LATER tasks ordered by MIT flag then sort_order."""
|
||||||
return (
|
return (
|
||||||
db.query(Task)
|
db.query(Task)
|
||||||
.filter(Task.state == TaskState.LATER)
|
.filter(Task.state == TaskState.LATER)
|
||||||
@@ -36,6 +39,12 @@ def get_later_tasks(db: Session) -> list[Task]:
|
|||||||
|
|
||||||
|
|
||||||
def promote_tasks(db: Session):
|
def promote_tasks(db: Session):
|
||||||
|
"""Enforce the NOW/NEXT/LATER state machine invariants.
|
||||||
|
|
||||||
|
- At most one NOW task (extras demoted to NEXT).
|
||||||
|
- If no NOW, promote NEXT -> NOW.
|
||||||
|
- If no NEXT, promote highest-priority LATER -> NEXT.
|
||||||
|
"""
|
||||||
# Ensure only one NOW task exists. If multiple, demote extras to NEXT.
|
# Ensure only one NOW task exists. If multiple, demote extras to NEXT.
|
||||||
now_tasks = db.query(Task).filter(Task.state == TaskState.NOW).all()
|
now_tasks = db.query(Task).filter(Task.state == TaskState.NOW).all()
|
||||||
if len(now_tasks) > 1:
|
if len(now_tasks) > 1:
|
||||||
@@ -74,6 +83,7 @@ def promote_tasks(db: Session):
|
|||||||
# Endpoints
|
# Endpoints
|
||||||
@router.get("/calm", response_class=HTMLResponse)
|
@router.get("/calm", response_class=HTMLResponse)
|
||||||
async def get_calm_view(request: Request, db: Session = Depends(get_db)):
|
async def get_calm_view(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Render the main CALM dashboard with NOW/NEXT/LATER counts."""
|
||||||
now_task = get_now_task(db)
|
now_task = get_now_task(db)
|
||||||
next_task = get_next_task(db)
|
next_task = get_next_task(db)
|
||||||
later_tasks_count = len(get_later_tasks(db))
|
later_tasks_count = len(get_later_tasks(db))
|
||||||
@@ -90,6 +100,7 @@ async def get_calm_view(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/calm/ritual/morning", response_class=HTMLResponse)
|
@router.get("/calm/ritual/morning", response_class=HTMLResponse)
|
||||||
async def get_morning_ritual_form(request: Request):
|
async def get_morning_ritual_form(request: Request):
|
||||||
|
"""Render the morning ritual intake form."""
|
||||||
return templates.TemplateResponse(request, "calm/morning_ritual_form.html", {})
|
return templates.TemplateResponse(request, "calm/morning_ritual_form.html", {})
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +113,7 @@ async def post_morning_ritual(
|
|||||||
mit3_title: str = Form(None),
|
mit3_title: str = Form(None),
|
||||||
other_tasks: str = Form(""),
|
other_tasks: str = Form(""),
|
||||||
):
|
):
|
||||||
|
"""Process morning ritual: create MITs, other tasks, and set initial states."""
|
||||||
# Create Journal Entry
|
# Create Journal Entry
|
||||||
mit_task_ids = []
|
mit_task_ids = []
|
||||||
journal_entry = JournalEntry(entry_date=date.today())
|
journal_entry = JournalEntry(entry_date=date.today())
|
||||||
@@ -173,6 +185,7 @@ async def post_morning_ritual(
|
|||||||
|
|
||||||
@router.get("/calm/ritual/evening", response_class=HTMLResponse)
|
@router.get("/calm/ritual/evening", response_class=HTMLResponse)
|
||||||
async def get_evening_ritual_form(request: Request, db: Session = Depends(get_db)):
|
async def get_evening_ritual_form(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Render the evening ritual form for today's journal entry."""
|
||||||
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
|
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
|
||||||
if not journal_entry:
|
if not journal_entry:
|
||||||
raise HTTPException(status_code=404, detail="No journal entry for today")
|
raise HTTPException(status_code=404, detail="No journal entry for today")
|
||||||
@@ -189,6 +202,7 @@ async def post_evening_ritual(
|
|||||||
gratitude: str = Form(None),
|
gratitude: str = Form(None),
|
||||||
energy_level: int = Form(None),
|
energy_level: int = Form(None),
|
||||||
):
|
):
|
||||||
|
"""Process evening ritual: save reflection/gratitude, archive active tasks."""
|
||||||
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
|
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
|
||||||
if not journal_entry:
|
if not journal_entry:
|
||||||
raise HTTPException(status_code=404, detail="No journal entry for today")
|
raise HTTPException(status_code=404, detail="No journal entry for today")
|
||||||
@@ -223,6 +237,7 @@ async def create_new_task(
|
|||||||
is_mit: bool = Form(False),
|
is_mit: bool = Form(False),
|
||||||
certainty: TaskCertainty = Form(TaskCertainty.SOFT),
|
certainty: TaskCertainty = Form(TaskCertainty.SOFT),
|
||||||
):
|
):
|
||||||
|
"""Create a new task in LATER state and return updated count."""
|
||||||
task = Task(
|
task = Task(
|
||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -247,6 +262,7 @@ async def start_task(
|
|||||||
task_id: int,
|
task_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""Move a task to NOW state, demoting the current NOW to NEXT."""
|
||||||
current_now_task = get_now_task(db)
|
current_now_task = get_now_task(db)
|
||||||
if current_now_task and current_now_task.id != task_id:
|
if current_now_task and current_now_task.id != task_id:
|
||||||
current_now_task.state = TaskState.NEXT # Demote current NOW to NEXT
|
current_now_task.state = TaskState.NEXT # Demote current NOW to NEXT
|
||||||
@@ -281,6 +297,7 @@ async def complete_task(
|
|||||||
task_id: int,
|
task_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""Mark a task as DONE and trigger state promotion."""
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
@@ -309,6 +326,7 @@ async def defer_task(
|
|||||||
task_id: int,
|
task_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""Defer a task and trigger state promotion."""
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
@@ -333,6 +351,7 @@ async def defer_task(
|
|||||||
|
|
||||||
@router.get("/calm/partials/later_tasks_list", response_class=HTMLResponse)
|
@router.get("/calm/partials/later_tasks_list", response_class=HTMLResponse)
|
||||||
async def get_later_tasks_list(request: Request, db: Session = Depends(get_db)):
|
async def get_later_tasks_list(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Render the expandable list of LATER tasks."""
|
||||||
later_tasks = get_later_tasks(db)
|
later_tasks = get_later_tasks(db)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"calm/partials/later_tasks_list.html",
|
"calm/partials/later_tasks_list.html",
|
||||||
@@ -348,6 +367,7 @@ async def reorder_tasks(
|
|||||||
later_task_ids: str = Form(""),
|
later_task_ids: str = Form(""),
|
||||||
next_task_id: int | None = Form(None),
|
next_task_id: int | None = Form(None),
|
||||||
):
|
):
|
||||||
|
"""Reorder LATER tasks and optionally promote one to NEXT."""
|
||||||
# Reorder LATER tasks
|
# Reorder LATER tasks
|
||||||
if later_task_ids:
|
if later_task_ids:
|
||||||
ids_in_order = [int(x.strip()) for x in later_task_ids.split(",") if x.strip()]
|
ids_in_order = [int(x.strip()) for x in later_task_ids.split(",") if x.strip()]
|
||||||
|
|||||||
117
src/integrations/chat_bridge/vendors/discord.py
vendored
117
src/integrations/chat_bridge/vendors/discord.py
vendored
@@ -515,25 +515,36 @@ class DiscordVendor(ChatPlatform):
|
|||||||
|
|
||||||
async def _handle_message(self, message) -> None:
|
async def _handle_message(self, message) -> None:
|
||||||
"""Process an incoming message and respond via a thread."""
|
"""Process an incoming message and respond via a thread."""
|
||||||
# Strip the bot mention from the message content
|
content = self._extract_content(message)
|
||||||
content = message.content
|
|
||||||
if self._client.user:
|
|
||||||
content = content.replace(f"<@{self._client.user.id}>", "").strip()
|
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create or reuse a thread for this conversation
|
|
||||||
thread = await self._get_or_create_thread(message)
|
thread = await self._get_or_create_thread(message)
|
||||||
target = thread or message.channel
|
target = thread or message.channel
|
||||||
|
session_id = f"discord_{thread.id}" if thread else f"discord_{message.channel.id}"
|
||||||
|
|
||||||
# Derive session_id for per-conversation history via Agno's SQLite
|
run_output, response = await self._invoke_agent(content, session_id, target)
|
||||||
if thread:
|
|
||||||
session_id = f"discord_{thread.id}"
|
|
||||||
else:
|
|
||||||
session_id = f"discord_{message.channel.id}"
|
|
||||||
|
|
||||||
# Run Timmy agent with typing indicator and timeout
|
if run_output is not None:
|
||||||
|
await self._handle_paused_run(run_output, target, session_id)
|
||||||
|
raw_content = run_output.content if hasattr(run_output, "content") else ""
|
||||||
|
response = _clean_response(raw_content or "")
|
||||||
|
|
||||||
|
await self._send_response(response, target)
|
||||||
|
|
||||||
|
def _extract_content(self, message) -> str:
|
||||||
|
"""Strip the bot mention and return clean message text."""
|
||||||
|
content = message.content
|
||||||
|
if self._client.user:
|
||||||
|
content = content.replace(f"<@{self._client.user.id}>", "").strip()
|
||||||
|
return content
|
||||||
|
|
||||||
|
async def _invoke_agent(self, content: str, session_id: str, target):
|
||||||
|
"""Run chat_with_tools with a typing indicator and timeout.
|
||||||
|
|
||||||
|
Returns a (run_output, error_response) tuple. On success the
|
||||||
|
error_response is ``None``; on failure run_output is ``None``.
|
||||||
|
"""
|
||||||
run_output = None
|
run_output = None
|
||||||
response = None
|
response = None
|
||||||
try:
|
try:
|
||||||
@@ -548,51 +559,57 @@ class DiscordVendor(ChatPlatform):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Discord: chat_with_tools() failed: %s", exc)
|
logger.error("Discord: chat_with_tools() failed: %s", exc)
|
||||||
response = "I'm having trouble reaching my inference backend right now. Please try again shortly."
|
response = "I'm having trouble reaching my inference backend right now. Please try again shortly."
|
||||||
|
return run_output, response
|
||||||
|
|
||||||
# Check if Agno paused the run for tool confirmation
|
async def _handle_paused_run(self, run_output, target, session_id: str) -> None:
|
||||||
if run_output is not None:
|
"""If Agno paused the run for tool confirmation, enqueue approvals."""
|
||||||
status = getattr(run_output, "status", None)
|
status = getattr(run_output, "status", None)
|
||||||
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
|
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
|
||||||
|
|
||||||
if is_paused and getattr(run_output, "active_requirements", None):
|
if not (is_paused and getattr(run_output, "active_requirements", None)):
|
||||||
from config import settings
|
return
|
||||||
|
|
||||||
if settings.discord_confirm_actions:
|
from config import settings
|
||||||
for req in run_output.active_requirements:
|
|
||||||
if getattr(req, "needs_confirmation", False):
|
|
||||||
te = req.tool_execution
|
|
||||||
tool_name = getattr(te, "tool_name", "unknown")
|
|
||||||
tool_args = getattr(te, "tool_args", {}) or {}
|
|
||||||
|
|
||||||
from timmy.approvals import create_item
|
if not settings.discord_confirm_actions:
|
||||||
|
return
|
||||||
|
|
||||||
item = create_item(
|
for req in run_output.active_requirements:
|
||||||
title=f"Discord: {tool_name}",
|
if not getattr(req, "needs_confirmation", False):
|
||||||
description=_format_action_description(tool_name, tool_args),
|
continue
|
||||||
proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
|
te = req.tool_execution
|
||||||
impact=_get_impact_level(tool_name),
|
tool_name = getattr(te, "tool_name", "unknown")
|
||||||
)
|
tool_args = getattr(te, "tool_args", {}) or {}
|
||||||
self._pending_actions[item.id] = {
|
|
||||||
"run_output": run_output,
|
|
||||||
"requirement": req,
|
|
||||||
"tool_name": tool_name,
|
|
||||||
"tool_args": tool_args,
|
|
||||||
"target": target,
|
|
||||||
"session_id": session_id,
|
|
||||||
}
|
|
||||||
await self._send_confirmation(target, tool_name, tool_args, item.id)
|
|
||||||
|
|
||||||
raw_content = run_output.content if hasattr(run_output, "content") else ""
|
from timmy.approvals import create_item
|
||||||
response = _clean_response(raw_content or "")
|
|
||||||
|
|
||||||
# Discord has a 2000 character limit — send with error handling
|
item = create_item(
|
||||||
if response and response.strip():
|
title=f"Discord: {tool_name}",
|
||||||
for chunk in _chunk_message(response, 2000):
|
description=_format_action_description(tool_name, tool_args),
|
||||||
try:
|
proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
|
||||||
await target.send(chunk)
|
impact=_get_impact_level(tool_name),
|
||||||
except Exception as exc:
|
)
|
||||||
logger.error("Discord: failed to send message chunk: %s", exc)
|
self._pending_actions[item.id] = {
|
||||||
break
|
"run_output": run_output,
|
||||||
|
"requirement": req,
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"tool_args": tool_args,
|
||||||
|
"target": target,
|
||||||
|
"session_id": session_id,
|
||||||
|
}
|
||||||
|
await self._send_confirmation(target, tool_name, tool_args, item.id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _send_response(response: str | None, target) -> None:
|
||||||
|
"""Send a response to Discord, chunked to the 2000-char limit."""
|
||||||
|
if not response or not response.strip():
|
||||||
|
return
|
||||||
|
for chunk in _chunk_message(response, 2000):
|
||||||
|
try:
|
||||||
|
await target.send(chunk)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Discord: failed to send message chunk: %s", exc)
|
||||||
|
break
|
||||||
|
|
||||||
async def _get_or_create_thread(self, message):
|
async def _get_or_create_thread(self, message):
|
||||||
"""Get the active thread for a channel, or create one.
|
"""Get the active thread for a channel, or create one.
|
||||||
|
|||||||
@@ -119,75 +119,84 @@ class BaseAgent(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def run(self, message: str) -> str:
|
# Transient errors that indicate Ollama contention or temporary
|
||||||
"""Run the agent with a message.
|
# unavailability — these deserve a retry with backoff.
|
||||||
|
_TRANSIENT = (
|
||||||
|
httpx.ConnectError,
|
||||||
|
httpx.ReadError,
|
||||||
|
httpx.ReadTimeout,
|
||||||
|
httpx.ConnectTimeout,
|
||||||
|
ConnectionError,
|
||||||
|
TimeoutError,
|
||||||
|
)
|
||||||
|
|
||||||
Retries on transient failures (connection errors, timeouts) with
|
async def run(self, message: str, *, max_retries: int = 3) -> str:
|
||||||
exponential backoff. GPU contention from concurrent Ollama
|
"""Run the agent with a message, retrying on transient failures.
|
||||||
requests causes ReadError / ReadTimeout — these are transient
|
|
||||||
and should be retried, not raised immediately (#70).
|
|
||||||
|
|
||||||
Returns:
|
GPU contention from concurrent Ollama requests causes ReadError /
|
||||||
Agent response
|
ReadTimeout — these are transient and retried with exponential
|
||||||
|
backoff (#70).
|
||||||
"""
|
"""
|
||||||
max_retries = 3
|
response = await self._run_with_retries(message, max_retries)
|
||||||
last_exception = None
|
await self._emit_response_event(message, response)
|
||||||
# Transient errors that indicate Ollama contention or temporary
|
return response
|
||||||
# unavailability — these deserve a retry with backoff.
|
|
||||||
_transient = (
|
|
||||||
httpx.ConnectError,
|
|
||||||
httpx.ReadError,
|
|
||||||
httpx.ReadTimeout,
|
|
||||||
httpx.ConnectTimeout,
|
|
||||||
ConnectionError,
|
|
||||||
TimeoutError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
async def _run_with_retries(self, message: str, max_retries: int) -> str:
|
||||||
|
"""Execute agent.run() with retry logic for transient errors."""
|
||||||
for attempt in range(1, max_retries + 1):
|
for attempt in range(1, max_retries + 1):
|
||||||
try:
|
try:
|
||||||
result = self.agent.run(message, stream=False)
|
result = self.agent.run(message, stream=False)
|
||||||
response = result.content if hasattr(result, "content") else str(result)
|
return result.content if hasattr(result, "content") else str(result)
|
||||||
break # Success, exit the retry loop
|
except self._TRANSIENT as exc:
|
||||||
except _transient as exc:
|
self._handle_retry_or_raise(
|
||||||
last_exception = exc
|
exc,
|
||||||
if attempt < max_retries:
|
attempt,
|
||||||
# Contention backoff — longer waits because the GPU
|
max_retries,
|
||||||
# needs time to finish the other request.
|
transient=True,
|
||||||
wait = min(2**attempt, 16)
|
)
|
||||||
logger.warning(
|
await asyncio.sleep(min(2**attempt, 16))
|
||||||
"Ollama contention on attempt %d/%d: %s. Waiting %ds before retry...",
|
|
||||||
attempt,
|
|
||||||
max_retries,
|
|
||||||
type(exc).__name__,
|
|
||||||
wait,
|
|
||||||
)
|
|
||||||
await asyncio.sleep(wait)
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
"Ollama unreachable after %d attempts: %s",
|
|
||||||
max_retries,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
raise last_exception from exc
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_exception = exc
|
self._handle_retry_or_raise(
|
||||||
if attempt < max_retries:
|
exc,
|
||||||
logger.warning(
|
attempt,
|
||||||
"Agent run failed on attempt %d/%d: %s. Retrying...",
|
max_retries,
|
||||||
attempt,
|
transient=False,
|
||||||
max_retries,
|
)
|
||||||
exc,
|
await asyncio.sleep(min(2 ** (attempt - 1), 8))
|
||||||
)
|
# Unreachable — _handle_retry_or_raise raises on last attempt.
|
||||||
await asyncio.sleep(min(2 ** (attempt - 1), 8))
|
raise RuntimeError("retry loop exited unexpectedly") # pragma: no cover
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
"Agent run failed after %d attempts: %s",
|
|
||||||
max_retries,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
raise last_exception from exc
|
|
||||||
|
|
||||||
# Emit completion event
|
@staticmethod
|
||||||
|
def _handle_retry_or_raise(
|
||||||
|
exc: Exception,
|
||||||
|
attempt: int,
|
||||||
|
max_retries: int,
|
||||||
|
*,
|
||||||
|
transient: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Log a retry warning or raise after exhausting attempts."""
|
||||||
|
if attempt < max_retries:
|
||||||
|
if transient:
|
||||||
|
logger.warning(
|
||||||
|
"Ollama contention on attempt %d/%d: %s. Waiting before retry...",
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Agent run failed on attempt %d/%d: %s. Retrying...",
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
label = "Ollama unreachable" if transient else "Agent run failed"
|
||||||
|
logger.error("%s after %d attempts: %s", label, max_retries, exc)
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
async def _emit_response_event(self, message: str, response: str) -> None:
|
||||||
|
"""Publish a completion event to the event bus if connected."""
|
||||||
if self.event_bus:
|
if self.event_bus:
|
||||||
await self.event_bus.publish(
|
await self.event_bus.publish(
|
||||||
Event(
|
Event(
|
||||||
@@ -197,8 +206,6 @@ class BaseAgent(ABC):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def get_capabilities(self) -> list[str]:
|
def get_capabilities(self) -> list[str]:
|
||||||
"""Get list of capabilities this agent provides."""
|
"""Get list of capabilities this agent provides."""
|
||||||
return self.tools
|
return self.tools
|
||||||
|
|||||||
@@ -37,6 +37,35 @@ def _is_interactive() -> bool:
|
|||||||
return hasattr(sys.stdin, "isatty") and sys.stdin.isatty()
|
return hasattr(sys.stdin, "isatty") and sys.stdin.isatty()
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_interactive(req, tool_name: str, tool_args: dict) -> None:
|
||||||
|
"""Display tool details and prompt the human for approval."""
|
||||||
|
description = format_action_description(tool_name, tool_args)
|
||||||
|
impact = get_impact_level(tool_name)
|
||||||
|
|
||||||
|
typer.echo()
|
||||||
|
typer.echo(typer.style("Tool confirmation required", bold=True))
|
||||||
|
typer.echo(f" Impact: {impact.upper()}")
|
||||||
|
typer.echo(f" {description}")
|
||||||
|
typer.echo()
|
||||||
|
|
||||||
|
if typer.confirm("Allow this action?", default=False):
|
||||||
|
req.confirm()
|
||||||
|
logger.info("CLI: approved %s", tool_name)
|
||||||
|
else:
|
||||||
|
req.reject(note="User rejected from CLI")
|
||||||
|
logger.info("CLI: rejected %s", tool_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _decide_autonomous(req, tool_name: str, tool_args: dict) -> None:
|
||||||
|
"""Auto-approve allowlisted tools; reject everything else."""
|
||||||
|
if is_allowlisted(tool_name, tool_args):
|
||||||
|
req.confirm()
|
||||||
|
logger.info("AUTO-APPROVED (allowlist): %s", tool_name)
|
||||||
|
else:
|
||||||
|
req.reject(note="Auto-rejected: not in allowlist")
|
||||||
|
logger.info("AUTO-REJECTED (not allowlisted): %s %s", tool_name, str(tool_args)[:100])
|
||||||
|
|
||||||
|
|
||||||
def _handle_tool_confirmation(agent, run_output, session_id: str, *, autonomous: bool = False):
|
def _handle_tool_confirmation(agent, run_output, session_id: str, *, autonomous: bool = False):
|
||||||
"""Prompt user to approve/reject dangerous tool calls.
|
"""Prompt user to approve/reject dangerous tool calls.
|
||||||
|
|
||||||
@@ -51,6 +80,7 @@ def _handle_tool_confirmation(agent, run_output, session_id: str, *, autonomous:
|
|||||||
Returns the final RunOutput after all confirmations are resolved.
|
Returns the final RunOutput after all confirmations are resolved.
|
||||||
"""
|
"""
|
||||||
interactive = _is_interactive() and not autonomous
|
interactive = _is_interactive() and not autonomous
|
||||||
|
decide = _prompt_interactive if interactive else _decide_autonomous
|
||||||
|
|
||||||
max_rounds = 10 # safety limit
|
max_rounds = 10 # safety limit
|
||||||
for _ in range(max_rounds):
|
for _ in range(max_rounds):
|
||||||
@@ -66,39 +96,10 @@ def _handle_tool_confirmation(agent, run_output, session_id: str, *, autonomous:
|
|||||||
for req in reqs:
|
for req in reqs:
|
||||||
if not getattr(req, "needs_confirmation", False):
|
if not getattr(req, "needs_confirmation", False):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
te = req.tool_execution
|
te = req.tool_execution
|
||||||
tool_name = getattr(te, "tool_name", "unknown")
|
tool_name = getattr(te, "tool_name", "unknown")
|
||||||
tool_args = getattr(te, "tool_args", {}) or {}
|
tool_args = getattr(te, "tool_args", {}) or {}
|
||||||
|
decide(req, tool_name, tool_args)
|
||||||
if interactive:
|
|
||||||
# Human present — prompt for approval
|
|
||||||
description = format_action_description(tool_name, tool_args)
|
|
||||||
impact = get_impact_level(tool_name)
|
|
||||||
|
|
||||||
typer.echo()
|
|
||||||
typer.echo(typer.style("Tool confirmation required", bold=True))
|
|
||||||
typer.echo(f" Impact: {impact.upper()}")
|
|
||||||
typer.echo(f" {description}")
|
|
||||||
typer.echo()
|
|
||||||
|
|
||||||
approved = typer.confirm("Allow this action?", default=False)
|
|
||||||
if approved:
|
|
||||||
req.confirm()
|
|
||||||
logger.info("CLI: approved %s", tool_name)
|
|
||||||
else:
|
|
||||||
req.reject(note="User rejected from CLI")
|
|
||||||
logger.info("CLI: rejected %s", tool_name)
|
|
||||||
else:
|
|
||||||
# Autonomous mode — check allowlist
|
|
||||||
if is_allowlisted(tool_name, tool_args):
|
|
||||||
req.confirm()
|
|
||||||
logger.info("AUTO-APPROVED (allowlist): %s", tool_name)
|
|
||||||
else:
|
|
||||||
req.reject(note="Auto-rejected: not in allowlist")
|
|
||||||
logger.info(
|
|
||||||
"AUTO-REJECTED (not allowlisted): %s %s", tool_name, str(tool_args)[:100]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resume the run so the agent sees the confirmation result
|
# Resume the run so the agent sees the confirmation result
|
||||||
try:
|
try:
|
||||||
@@ -138,7 +139,7 @@ def think(
|
|||||||
model_size: str | None = _MODEL_SIZE_OPTION,
|
model_size: str | None = _MODEL_SIZE_OPTION,
|
||||||
):
|
):
|
||||||
"""Ask Timmy to think carefully about a topic."""
|
"""Ask Timmy to think carefully about a topic."""
|
||||||
timmy = create_timmy(backend=backend, model_size=model_size, session_id=_CLI_SESSION_ID)
|
timmy = create_timmy(backend=backend, session_id=_CLI_SESSION_ID)
|
||||||
timmy.print_response(f"Think carefully about: {topic}", stream=True, session_id=_CLI_SESSION_ID)
|
timmy.print_response(f"Think carefully about: {topic}", stream=True, session_id=_CLI_SESSION_ID)
|
||||||
|
|
||||||
|
|
||||||
@@ -201,7 +202,7 @@ def chat(
|
|||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
else:
|
else:
|
||||||
session_id = _CLI_SESSION_ID
|
session_id = _CLI_SESSION_ID
|
||||||
timmy = create_timmy(backend=backend, model_size=model_size, session_id=session_id)
|
timmy = create_timmy(backend=backend, session_id=session_id)
|
||||||
|
|
||||||
# Use agent.run() so we can intercept paused runs for tool confirmation.
|
# Use agent.run() so we can intercept paused runs for tool confirmation.
|
||||||
run_output = timmy.run(message_str, stream=False, session_id=session_id)
|
run_output = timmy.run(message_str, stream=False, session_id=session_id)
|
||||||
@@ -278,7 +279,7 @@ def status(
|
|||||||
model_size: str | None = _MODEL_SIZE_OPTION,
|
model_size: str | None = _MODEL_SIZE_OPTION,
|
||||||
):
|
):
|
||||||
"""Print Timmy's operational status."""
|
"""Print Timmy's operational status."""
|
||||||
timmy = create_timmy(backend=backend, model_size=model_size, session_id=_CLI_SESSION_ID)
|
timmy = create_timmy(backend=backend, session_id=_CLI_SESSION_ID)
|
||||||
timmy.print_response(STATUS_PROMPT, stream=False, session_id=_CLI_SESSION_ID)
|
timmy.print_response(STATUS_PROMPT, stream=False, session_id=_CLI_SESSION_ID)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ Usage::
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from PIL import ImageDraw
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -270,20 +274,8 @@ async def create_gitea_issue_via_mcp(title: str, body: str = "", labels: str = "
|
|||||||
return f"Failed to create issue via MCP: {exc}"
|
return f"Failed to create issue via MCP: {exc}"
|
||||||
|
|
||||||
|
|
||||||
def _generate_avatar_image() -> bytes:
|
def _draw_background(draw: ImageDraw.ImageDraw, size: int) -> None:
|
||||||
"""Generate a Timmy-themed avatar image using Pillow.
|
"""Draw radial gradient background with concentric circles."""
|
||||||
|
|
||||||
Creates a 512x512 wizard-themed avatar with emerald/purple/gold palette.
|
|
||||||
Returns raw PNG bytes. Falls back to a minimal solid-color image if
|
|
||||||
Pillow drawing primitives fail.
|
|
||||||
"""
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
size = 512
|
|
||||||
img = Image.new("RGB", (size, size), (15, 25, 20))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
# Background gradient effect — concentric circles
|
|
||||||
for i in range(size // 2, 0, -4):
|
for i in range(size // 2, 0, -4):
|
||||||
g = int(25 + (i / (size // 2)) * 30)
|
g = int(25 + (i / (size // 2)) * 30)
|
||||||
draw.ellipse(
|
draw.ellipse(
|
||||||
@@ -291,33 +283,45 @@ def _generate_avatar_image() -> bytes:
|
|||||||
fill=(10, g, 20),
|
fill=(10, g, 20),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wizard hat (triangle)
|
|
||||||
|
def _draw_wizard(draw: ImageDraw.ImageDraw) -> None:
|
||||||
|
"""Draw wizard hat, face, eyes, smile, monogram, and robe."""
|
||||||
hat_color = (100, 50, 160) # purple
|
hat_color = (100, 50, 160) # purple
|
||||||
draw.polygon(
|
hat_outline = (180, 130, 255)
|
||||||
[(256, 40), (160, 220), (352, 220)],
|
gold = (220, 190, 50)
|
||||||
fill=hat_color,
|
pupil = (30, 30, 60)
|
||||||
outline=(180, 130, 255),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hat brim
|
# Hat + brim
|
||||||
draw.ellipse([140, 200, 372, 250], fill=hat_color, outline=(180, 130, 255))
|
draw.polygon([(256, 40), (160, 220), (352, 220)], fill=hat_color, outline=hat_outline)
|
||||||
|
draw.ellipse([140, 200, 372, 250], fill=hat_color, outline=hat_outline)
|
||||||
|
|
||||||
# Face circle
|
# Face
|
||||||
draw.ellipse([190, 220, 322, 370], fill=(60, 180, 100), outline=(80, 220, 120))
|
draw.ellipse([190, 220, 322, 370], fill=(60, 180, 100), outline=(80, 220, 120))
|
||||||
|
|
||||||
# Eyes
|
# Eyes (whites + pupils)
|
||||||
draw.ellipse([220, 275, 248, 310], fill=(255, 255, 255))
|
draw.ellipse([220, 275, 248, 310], fill=(255, 255, 255))
|
||||||
draw.ellipse([264, 275, 292, 310], fill=(255, 255, 255))
|
draw.ellipse([264, 275, 292, 310], fill=(255, 255, 255))
|
||||||
draw.ellipse([228, 285, 242, 300], fill=(30, 30, 60))
|
draw.ellipse([228, 285, 242, 300], fill=pupil)
|
||||||
draw.ellipse([272, 285, 286, 300], fill=(30, 30, 60))
|
draw.ellipse([272, 285, 286, 300], fill=pupil)
|
||||||
|
|
||||||
# Smile
|
# Smile
|
||||||
draw.arc([225, 300, 287, 355], start=10, end=170, fill=(30, 30, 60), width=3)
|
draw.arc([225, 300, 287, 355], start=10, end=170, fill=pupil, width=3)
|
||||||
|
|
||||||
# Stars around the hat
|
# "T" monogram on hat
|
||||||
|
draw.text((243, 100), "T", fill=gold)
|
||||||
|
|
||||||
|
# Robe
|
||||||
|
draw.polygon(
|
||||||
|
[(180, 370), (140, 500), (372, 500), (332, 370)],
|
||||||
|
fill=(40, 100, 70),
|
||||||
|
outline=(60, 160, 100),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_stars(draw: ImageDraw.ImageDraw) -> None:
|
||||||
|
"""Draw decorative gold stars around the wizard hat."""
|
||||||
gold = (220, 190, 50)
|
gold = (220, 190, 50)
|
||||||
star_positions = [(120, 100), (380, 120), (100, 300), (400, 280), (256, 10)]
|
for sx, sy in [(120, 100), (380, 120), (100, 300), (400, 280), (256, 10)]:
|
||||||
for sx, sy in star_positions:
|
|
||||||
r = 8
|
r = 8
|
||||||
draw.polygon(
|
draw.polygon(
|
||||||
[
|
[
|
||||||
@@ -333,18 +337,26 @@ def _generate_avatar_image() -> bytes:
|
|||||||
fill=gold,
|
fill=gold,
|
||||||
)
|
)
|
||||||
|
|
||||||
# "T" monogram on the hat
|
|
||||||
draw.text((243, 100), "T", fill=gold)
|
|
||||||
|
|
||||||
# Robe / body
|
def _generate_avatar_image() -> bytes:
|
||||||
draw.polygon(
|
"""Generate a Timmy-themed avatar image using Pillow.
|
||||||
[(180, 370), (140, 500), (372, 500), (332, 370)],
|
|
||||||
fill=(40, 100, 70),
|
|
||||||
outline=(60, 160, 100),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
Creates a 512x512 wizard-themed avatar with emerald/purple/gold palette.
|
||||||
|
Returns raw PNG bytes. Falls back to a minimal solid-color image if
|
||||||
|
Pillow drawing primitives fail.
|
||||||
|
"""
|
||||||
import io
|
import io
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
size = 512
|
||||||
|
img = Image.new("RGB", (size, size), (15, 25, 20))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
_draw_background(draw, size)
|
||||||
|
_draw_wizard(draw)
|
||||||
|
_draw_stars(draw)
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
img.save(buf, format="PNG")
|
img.save(buf, format="PNG")
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|||||||
@@ -78,83 +78,88 @@ def _migrate_schema(conn: sqlite3.Connection) -> None:
|
|||||||
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
tables = {row[0] for row in cursor.fetchall()}
|
tables = {row[0] for row in cursor.fetchall()}
|
||||||
|
|
||||||
has_memories = "memories" in tables
|
if "memories" not in tables:
|
||||||
has_episodes = "episodes" in tables
|
|
||||||
has_chunks = "chunks" in tables
|
|
||||||
has_facts = "facts" in tables
|
|
||||||
|
|
||||||
# Check if we need to migrate (old schema exists but new one doesn't fully)
|
|
||||||
if not has_memories:
|
|
||||||
logger.info("Migration: Creating unified memories table")
|
logger.info("Migration: Creating unified memories table")
|
||||||
# Schema will be created above
|
# Schema will be created by _ensure_schema above
|
||||||
|
conn.commit()
|
||||||
# Migrate episodes -> memories
|
return
|
||||||
if has_episodes and has_memories:
|
|
||||||
logger.info("Migration: Converting episodes table to memories")
|
|
||||||
try:
|
|
||||||
cols = _get_table_columns(conn, "episodes")
|
|
||||||
context_type_col = "context_type" if "context_type" in cols else "'conversation'"
|
|
||||||
|
|
||||||
conn.execute(f"""
|
|
||||||
INSERT INTO memories (
|
|
||||||
id, content, memory_type, source, embedding,
|
|
||||||
metadata, agent_id, task_id, session_id,
|
|
||||||
created_at, access_count, last_accessed
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
id, content,
|
|
||||||
COALESCE({context_type_col}, 'conversation'),
|
|
||||||
COALESCE(source, 'agent'),
|
|
||||||
embedding,
|
|
||||||
metadata, agent_id, task_id, session_id,
|
|
||||||
COALESCE(timestamp, datetime('now')), 0, NULL
|
|
||||||
FROM episodes
|
|
||||||
""")
|
|
||||||
conn.execute("DROP TABLE episodes")
|
|
||||||
logger.info("Migration: Migrated episodes to memories")
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
logger.warning("Migration: Failed to migrate episodes: %s", exc)
|
|
||||||
|
|
||||||
# Migrate chunks -> memories as vault_chunk
|
|
||||||
if has_chunks and has_memories:
|
|
||||||
logger.info("Migration: Converting chunks table to memories")
|
|
||||||
try:
|
|
||||||
cols = _get_table_columns(conn, "chunks")
|
|
||||||
|
|
||||||
id_col = "id" if "id" in cols else "CAST(rowid AS TEXT)"
|
|
||||||
content_col = "content" if "content" in cols else "text"
|
|
||||||
source_col = (
|
|
||||||
"filepath" if "filepath" in cols else ("source" if "source" in cols else "'vault'")
|
|
||||||
)
|
|
||||||
embedding_col = "embedding" if "embedding" in cols else "NULL"
|
|
||||||
created_col = "created_at" if "created_at" in cols else "datetime('now')"
|
|
||||||
|
|
||||||
conn.execute(f"""
|
|
||||||
INSERT INTO memories (
|
|
||||||
id, content, memory_type, source, embedding,
|
|
||||||
created_at, access_count
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
{id_col}, {content_col}, 'vault_chunk', {source_col},
|
|
||||||
{embedding_col}, {created_col}, 0
|
|
||||||
FROM chunks
|
|
||||||
""")
|
|
||||||
conn.execute("DROP TABLE chunks")
|
|
||||||
logger.info("Migration: Migrated chunks to memories")
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
logger.warning("Migration: Failed to migrate chunks: %s", exc)
|
|
||||||
|
|
||||||
# Drop old facts table
|
|
||||||
if has_facts:
|
|
||||||
try:
|
|
||||||
conn.execute("DROP TABLE facts")
|
|
||||||
logger.info("Migration: Dropped old facts table")
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
logger.warning("Migration: Failed to drop facts: %s", exc)
|
|
||||||
|
|
||||||
|
_migrate_episodes(conn, tables)
|
||||||
|
_migrate_chunks(conn, tables)
|
||||||
|
_drop_legacy_tables(conn, tables)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_episodes(conn: sqlite3.Connection, tables: set[str]) -> None:
|
||||||
|
"""Migrate episodes table rows into the unified memories table."""
|
||||||
|
if "episodes" not in tables:
|
||||||
|
return
|
||||||
|
logger.info("Migration: Converting episodes table to memories")
|
||||||
|
try:
|
||||||
|
cols = _get_table_columns(conn, "episodes")
|
||||||
|
context_type_col = "context_type" if "context_type" in cols else "'conversation'"
|
||||||
|
conn.execute(f"""
|
||||||
|
INSERT INTO memories (
|
||||||
|
id, content, memory_type, source, embedding,
|
||||||
|
metadata, agent_id, task_id, session_id,
|
||||||
|
created_at, access_count, last_accessed
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, content,
|
||||||
|
COALESCE({context_type_col}, 'conversation'),
|
||||||
|
COALESCE(source, 'agent'),
|
||||||
|
embedding,
|
||||||
|
metadata, agent_id, task_id, session_id,
|
||||||
|
COALESCE(timestamp, datetime('now')), 0, NULL
|
||||||
|
FROM episodes
|
||||||
|
""")
|
||||||
|
conn.execute("DROP TABLE episodes")
|
||||||
|
logger.info("Migration: Migrated episodes to memories")
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.warning("Migration: Failed to migrate episodes: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_chunks(conn: sqlite3.Connection, tables: set[str]) -> None:
|
||||||
|
"""Migrate chunks table rows into the unified memories table as vault_chunk."""
|
||||||
|
if "chunks" not in tables:
|
||||||
|
return
|
||||||
|
logger.info("Migration: Converting chunks table to memories")
|
||||||
|
try:
|
||||||
|
cols = _get_table_columns(conn, "chunks")
|
||||||
|
id_col = "id" if "id" in cols else "CAST(rowid AS TEXT)"
|
||||||
|
content_col = "content" if "content" in cols else "text"
|
||||||
|
source_col = (
|
||||||
|
"filepath" if "filepath" in cols else ("source" if "source" in cols else "'vault'")
|
||||||
|
)
|
||||||
|
embedding_col = "embedding" if "embedding" in cols else "NULL"
|
||||||
|
created_col = "created_at" if "created_at" in cols else "datetime('now')"
|
||||||
|
conn.execute(f"""
|
||||||
|
INSERT INTO memories (
|
||||||
|
id, content, memory_type, source, embedding,
|
||||||
|
created_at, access_count
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
{id_col}, {content_col}, 'vault_chunk', {source_col},
|
||||||
|
{embedding_col}, {created_col}, 0
|
||||||
|
FROM chunks
|
||||||
|
""")
|
||||||
|
conn.execute("DROP TABLE chunks")
|
||||||
|
logger.info("Migration: Migrated chunks to memories")
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.warning("Migration: Failed to migrate chunks: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_legacy_tables(conn: sqlite3.Connection, tables: set[str]) -> None:
|
||||||
|
"""Drop old facts table if it exists."""
|
||||||
|
if "facts" not in tables:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
conn.execute("DROP TABLE facts")
|
||||||
|
logger.info("Migration: Dropped old facts table")
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.warning("Migration: Failed to drop facts: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def _get_table_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
|
def _get_table_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
|
||||||
"""Get the column names for a table."""
|
"""Get the column names for a table."""
|
||||||
cursor = conn.execute(f"PRAGMA table_info({table_name})")
|
cursor = conn.execute(f"PRAGMA table_info({table_name})")
|
||||||
|
|||||||
@@ -303,12 +303,12 @@ def store_memory(
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def _build_memory_filter(
|
def _build_search_filters(
|
||||||
context_type: str | None,
|
context_type: str | None,
|
||||||
agent_id: str | None,
|
agent_id: str | None,
|
||||||
session_id: str | None,
|
session_id: str | None,
|
||||||
) -> tuple[str, list]:
|
) -> tuple[str, list]:
|
||||||
"""Build WHERE clause and params for memory queries."""
|
"""Build SQL WHERE clause and params from search filters."""
|
||||||
conditions: list[str] = []
|
conditions: list[str] = []
|
||||||
params: list = []
|
params: list = []
|
||||||
|
|
||||||
@@ -358,14 +358,13 @@ def _row_to_entry(row: sqlite3.Row) -> MemoryEntry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _score_and_rank(
|
def _score_and_filter(
|
||||||
rows: list[sqlite3.Row],
|
rows: list[sqlite3.Row],
|
||||||
query: str,
|
query: str,
|
||||||
query_embedding: list[float],
|
query_embedding: list[float],
|
||||||
min_relevance: float,
|
min_relevance: float,
|
||||||
limit: int,
|
|
||||||
) -> list[MemoryEntry]:
|
) -> list[MemoryEntry]:
|
||||||
"""Score candidates by similarity and return top results."""
|
"""Score candidate rows by similarity and filter by min_relevance."""
|
||||||
results = []
|
results = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
entry = _row_to_entry(row)
|
entry = _row_to_entry(row)
|
||||||
@@ -380,7 +379,7 @@ def _score_and_rank(
|
|||||||
results.append(entry)
|
results.append(entry)
|
||||||
|
|
||||||
results.sort(key=lambda x: x.relevance_score or 0, reverse=True)
|
results.sort(key=lambda x: x.relevance_score or 0, reverse=True)
|
||||||
return results[:limit]
|
return results
|
||||||
|
|
||||||
|
|
||||||
def search_memories(
|
def search_memories(
|
||||||
@@ -405,9 +404,10 @@ def search_memories(
|
|||||||
List of MemoryEntry objects sorted by relevance
|
List of MemoryEntry objects sorted by relevance
|
||||||
"""
|
"""
|
||||||
query_embedding = embed_text(query)
|
query_embedding = embed_text(query)
|
||||||
where_clause, params = _build_memory_filter(context_type, agent_id, session_id)
|
where_clause, params = _build_search_filters(context_type, agent_id, session_id)
|
||||||
rows = _fetch_memory_candidates(where_clause, params, limit * 3)
|
rows = _fetch_memory_candidates(where_clause, params, limit * 3)
|
||||||
return _score_and_rank(rows, query, query_embedding, min_relevance, limit)
|
results = _score_and_filter(rows, query, query_embedding, min_relevance)
|
||||||
|
return results[:limit]
|
||||||
|
|
||||||
|
|
||||||
def delete_memory(memory_id: str) -> bool:
|
def delete_memory(memory_id: str) -> bool:
|
||||||
|
|||||||
@@ -341,6 +341,11 @@ class ThinkingEngine:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Capture arrival time *before* the LLM call so the thought
|
||||||
|
# timestamp reflects when the cycle started, not when the
|
||||||
|
# (potentially slow) generation finished. Fixes #582.
|
||||||
|
arrived_at = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
memory_context, system_context, recent_thoughts = self._build_thinking_context()
|
memory_context, system_context, recent_thoughts = self._build_thinking_context()
|
||||||
|
|
||||||
content, seed_type = await self._generate_novel_thought(
|
content, seed_type = await self._generate_novel_thought(
|
||||||
@@ -352,7 +357,7 @@ class ThinkingEngine:
|
|||||||
if not content:
|
if not content:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
thought = self._store_thought(content, seed_type)
|
thought = self._store_thought(content, seed_type, arrived_at=arrived_at)
|
||||||
self._last_thought_id = thought.id
|
self._last_thought_id = thought.id
|
||||||
|
|
||||||
await self._process_thinking_result(thought)
|
await self._process_thinking_result(thought)
|
||||||
@@ -1173,14 +1178,25 @@ class ThinkingEngine:
|
|||||||
raw = run.content if hasattr(run, "content") else str(run)
|
raw = run.content if hasattr(run, "content") else str(run)
|
||||||
return _THINK_TAG_RE.sub("", raw) if raw else raw
|
return _THINK_TAG_RE.sub("", raw) if raw else raw
|
||||||
|
|
||||||
def _store_thought(self, content: str, seed_type: str) -> Thought:
|
def _store_thought(
|
||||||
"""Persist a thought to SQLite."""
|
self,
|
||||||
|
content: str,
|
||||||
|
seed_type: str,
|
||||||
|
*,
|
||||||
|
arrived_at: str | None = None,
|
||||||
|
) -> Thought:
|
||||||
|
"""Persist a thought to SQLite.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
arrived_at: ISO-8601 timestamp captured when the thinking cycle
|
||||||
|
started. Falls back to now() for callers that don't supply it.
|
||||||
|
"""
|
||||||
thought = Thought(
|
thought = Thought(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
content=content,
|
content=content,
|
||||||
seed_type=seed_type,
|
seed_type=seed_type,
|
||||||
parent_id=self._last_thought_id,
|
parent_id=self._last_thought_id,
|
||||||
created_at=datetime.now(UTC).isoformat(),
|
created_at=arrived_at or datetime.now(UTC).isoformat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
with _get_conn(self._db_path) as conn:
|
with _get_conn(self._db_path) as conn:
|
||||||
@@ -1261,6 +1277,53 @@ class ThinkingEngine:
|
|||||||
logger.debug("Failed to broadcast thought: %s", exc)
|
logger.debug("Failed to broadcast thought: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_thoughts(
|
||||||
|
db_path: Path, query: str, seed_type: str | None, limit: int
|
||||||
|
) -> list[sqlite3.Row]:
|
||||||
|
"""Run the thought-search SQL and return matching rows."""
|
||||||
|
pattern = f"%{query}%"
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
if seed_type:
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, content, seed_type, created_at
|
||||||
|
FROM thoughts
|
||||||
|
WHERE content LIKE ? AND seed_type = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(pattern, seed_type, limit),
|
||||||
|
).fetchall()
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, content, seed_type, created_at
|
||||||
|
FROM thoughts
|
||||||
|
WHERE content LIKE ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(pattern, limit),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_thought_rows(rows: list[sqlite3.Row], query: str, seed_type: str | None) -> str:
|
||||||
|
"""Format thought rows into a human-readable string."""
|
||||||
|
lines = [f'Found {len(rows)} thought(s) matching "{query}":']
|
||||||
|
if seed_type:
|
||||||
|
lines[0] += f' [seed_type="{seed_type}"]'
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
ts = datetime.fromisoformat(row["created_at"])
|
||||||
|
local_ts = ts.astimezone()
|
||||||
|
time_str = local_ts.strftime("%Y-%m-%d %I:%M %p").lstrip("0")
|
||||||
|
seed = row["seed_type"]
|
||||||
|
content = row["content"].replace("\n", " ") # Flatten newlines for display
|
||||||
|
lines.append(f"[{time_str}] ({seed}) {content[:150]}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def search_thoughts(query: str, seed_type: str | None = None, limit: int = 10) -> str:
|
def search_thoughts(query: str, seed_type: str | None = None, limit: int = 10) -> str:
|
||||||
"""Search Timmy's thought history for reflections matching a query.
|
"""Search Timmy's thought history for reflections matching a query.
|
||||||
|
|
||||||
@@ -1278,58 +1341,17 @@ def search_thoughts(query: str, seed_type: str | None = None, limit: int = 10) -
|
|||||||
Formatted string with matching thoughts, newest first, including
|
Formatted string with matching thoughts, newest first, including
|
||||||
timestamps and seed types. Returns a helpful message if no matches found.
|
timestamps and seed types. Returns a helpful message if no matches found.
|
||||||
"""
|
"""
|
||||||
# Clamp limit to reasonable bounds
|
|
||||||
limit = max(1, min(limit, 50))
|
limit = max(1, min(limit, 50))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
engine = thinking_engine
|
rows = _query_thoughts(thinking_engine._db_path, query, seed_type, limit)
|
||||||
db_path = engine._db_path
|
|
||||||
|
|
||||||
# Build query with optional seed_type filter
|
|
||||||
with _get_conn(db_path) as conn:
|
|
||||||
if seed_type:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, content, seed_type, created_at
|
|
||||||
FROM thoughts
|
|
||||||
WHERE content LIKE ? AND seed_type = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
""",
|
|
||||||
(f"%{query}%", seed_type, limit),
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, content, seed_type, created_at
|
|
||||||
FROM thoughts
|
|
||||||
WHERE content LIKE ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
""",
|
|
||||||
(f"%{query}%", limit),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
if seed_type:
|
if seed_type:
|
||||||
return f'No thoughts found matching "{query}" with seed_type="{seed_type}".'
|
return f'No thoughts found matching "{query}" with seed_type="{seed_type}".'
|
||||||
return f'No thoughts found matching "{query}".'
|
return f'No thoughts found matching "{query}".'
|
||||||
|
|
||||||
# Format results
|
return _format_thought_rows(rows, query, seed_type)
|
||||||
lines = [f'Found {len(rows)} thought(s) matching "{query}":']
|
|
||||||
if seed_type:
|
|
||||||
lines[0] += f' [seed_type="{seed_type}"]'
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
ts = datetime.fromisoformat(row["created_at"])
|
|
||||||
local_ts = ts.astimezone()
|
|
||||||
time_str = local_ts.strftime("%Y-%m-%d %I:%M %p").lstrip("0")
|
|
||||||
seed = row["seed_type"]
|
|
||||||
content = row["content"].replace("\n", " ") # Flatten newlines for display
|
|
||||||
lines.append(f"[{time_str}] ({seed}) {content[:150]}")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Thought search failed: %s", exc)
|
logger.warning("Thought search failed: %s", exc)
|
||||||
|
|||||||
@@ -909,82 +909,35 @@ def _experiment_tool_catalog() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_CREATIVE_CATALOG_SOURCES: list[tuple[str, str, list[str]]] = [
|
||||||
|
("creative.tools.git_tools", "GIT_TOOL_CATALOG", ["forge", "helm", "orchestrator"]),
|
||||||
|
("creative.tools.image_tools", "IMAGE_TOOL_CATALOG", ["pixel", "orchestrator"]),
|
||||||
|
("creative.tools.music_tools", "MUSIC_TOOL_CATALOG", ["lyra", "orchestrator"]),
|
||||||
|
("creative.tools.video_tools", "VIDEO_TOOL_CATALOG", ["reel", "orchestrator"]),
|
||||||
|
("creative.director", "DIRECTOR_TOOL_CATALOG", ["orchestrator"]),
|
||||||
|
("creative.assembler", "ASSEMBLER_TOOL_CATALOG", ["reel", "orchestrator"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _import_creative_catalogs(catalog: dict) -> None:
|
def _import_creative_catalogs(catalog: dict) -> None:
|
||||||
"""Import and merge creative tool catalogs from creative module."""
|
"""Import and merge creative tool catalogs from creative module."""
|
||||||
# ── Git tools ─────────────────────────────────────────────────────────────
|
for module_path, attr_name, available_in in _CREATIVE_CATALOG_SOURCES:
|
||||||
try:
|
_merge_catalog(catalog, module_path, attr_name, available_in)
|
||||||
from creative.tools.git_tools import GIT_TOOL_CATALOG
|
|
||||||
|
|
||||||
for tool_id, info in GIT_TOOL_CATALOG.items():
|
|
||||||
|
def _merge_catalog(
|
||||||
|
catalog: dict, module_path: str, attr_name: str, available_in: list[str]
|
||||||
|
) -> None:
|
||||||
|
"""Import a single creative catalog and merge its entries."""
|
||||||
|
try:
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
source_catalog = getattr(import_module(module_path), attr_name)
|
||||||
|
for tool_id, info in source_catalog.items():
|
||||||
catalog[tool_id] = {
|
catalog[tool_id] = {
|
||||||
"name": info["name"],
|
"name": info["name"],
|
||||||
"description": info["description"],
|
"description": info["description"],
|
||||||
"available_in": ["forge", "helm", "orchestrator"],
|
"available_in": available_in,
|
||||||
}
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ── Image tools ────────────────────────────────────────────────────────────
|
|
||||||
try:
|
|
||||||
from creative.tools.image_tools import IMAGE_TOOL_CATALOG
|
|
||||||
|
|
||||||
for tool_id, info in IMAGE_TOOL_CATALOG.items():
|
|
||||||
catalog[tool_id] = {
|
|
||||||
"name": info["name"],
|
|
||||||
"description": info["description"],
|
|
||||||
"available_in": ["pixel", "orchestrator"],
|
|
||||||
}
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ── Music tools ────────────────────────────────────────────────────────────
|
|
||||||
try:
|
|
||||||
from creative.tools.music_tools import MUSIC_TOOL_CATALOG
|
|
||||||
|
|
||||||
for tool_id, info in MUSIC_TOOL_CATALOG.items():
|
|
||||||
catalog[tool_id] = {
|
|
||||||
"name": info["name"],
|
|
||||||
"description": info["description"],
|
|
||||||
"available_in": ["lyra", "orchestrator"],
|
|
||||||
}
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ── Video tools ────────────────────────────────────────────────────────────
|
|
||||||
try:
|
|
||||||
from creative.tools.video_tools import VIDEO_TOOL_CATALOG
|
|
||||||
|
|
||||||
for tool_id, info in VIDEO_TOOL_CATALOG.items():
|
|
||||||
catalog[tool_id] = {
|
|
||||||
"name": info["name"],
|
|
||||||
"description": info["description"],
|
|
||||||
"available_in": ["reel", "orchestrator"],
|
|
||||||
}
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ── Creative pipeline ──────────────────────────────────────────────────────
|
|
||||||
try:
|
|
||||||
from creative.director import DIRECTOR_TOOL_CATALOG
|
|
||||||
|
|
||||||
for tool_id, info in DIRECTOR_TOOL_CATALOG.items():
|
|
||||||
catalog[tool_id] = {
|
|
||||||
"name": info["name"],
|
|
||||||
"description": info["description"],
|
|
||||||
"available_in": ["orchestrator"],
|
|
||||||
}
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ── Assembler tools ───────────────────────────────────────────────────────
|
|
||||||
try:
|
|
||||||
from creative.assembler import ASSEMBLER_TOOL_CATALOG
|
|
||||||
|
|
||||||
for tool_id, info in ASSEMBLER_TOOL_CATALOG.items():
|
|
||||||
catalog[tool_id] = {
|
|
||||||
"name": info["name"],
|
|
||||||
"description": info["description"],
|
|
||||||
"available_in": ["reel", "orchestrator"],
|
|
||||||
}
|
}
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -326,6 +326,46 @@ def get_live_system_status() -> dict[str, Any]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pytest_cmd(venv_python: Path, scope: str) -> list[str]:
|
||||||
|
"""Build the pytest command list for the given scope."""
|
||||||
|
cmd = [str(venv_python), "-m", "pytest", "-x", "-q", "--tb=short", "--timeout=30"]
|
||||||
|
|
||||||
|
if scope == "fast":
|
||||||
|
cmd.extend(
|
||||||
|
[
|
||||||
|
"--ignore=tests/functional",
|
||||||
|
"--ignore=tests/e2e",
|
||||||
|
"--ignore=tests/integrations",
|
||||||
|
"tests/",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif scope == "full":
|
||||||
|
cmd.append("tests/")
|
||||||
|
else:
|
||||||
|
cmd.append(scope)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_pytest_output(output: str) -> dict[str, int]:
|
||||||
|
"""Extract passed/failed/error counts from pytest output."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
passed = failed = errors = 0
|
||||||
|
for line in output.splitlines():
|
||||||
|
if "passed" in line or "failed" in line or "error" in line:
|
||||||
|
nums = re.findall(r"(\d+) (passed|failed|error)", line)
|
||||||
|
for count, kind in nums:
|
||||||
|
if kind == "passed":
|
||||||
|
passed = int(count)
|
||||||
|
elif kind == "failed":
|
||||||
|
failed = int(count)
|
||||||
|
elif kind == "error":
|
||||||
|
errors = int(count)
|
||||||
|
|
||||||
|
return {"passed": passed, "failed": failed, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
def run_self_tests(scope: str = "fast", _repo_root: str | None = None) -> dict[str, Any]:
|
def run_self_tests(scope: str = "fast", _repo_root: str | None = None) -> dict[str, Any]:
|
||||||
"""Run Timmy's own test suite and report results.
|
"""Run Timmy's own test suite and report results.
|
||||||
|
|
||||||
@@ -349,49 +389,17 @@ def run_self_tests(scope: str = "fast", _repo_root: str | None = None) -> dict[s
|
|||||||
if not venv_python.exists():
|
if not venv_python.exists():
|
||||||
return {"success": False, "error": f"No venv found at {venv_python}"}
|
return {"success": False, "error": f"No venv found at {venv_python}"}
|
||||||
|
|
||||||
cmd = [str(venv_python), "-m", "pytest", "-x", "-q", "--tb=short", "--timeout=30"]
|
cmd = _build_pytest_cmd(venv_python, scope)
|
||||||
|
|
||||||
if scope == "fast":
|
|
||||||
# Unit tests only — skip functional/e2e/integration
|
|
||||||
cmd.extend(
|
|
||||||
[
|
|
||||||
"--ignore=tests/functional",
|
|
||||||
"--ignore=tests/e2e",
|
|
||||||
"--ignore=tests/integrations",
|
|
||||||
"tests/",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
elif scope == "full":
|
|
||||||
cmd.append("tests/")
|
|
||||||
else:
|
|
||||||
# Specific path
|
|
||||||
cmd.append(scope)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, cwd=repo)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, cwd=repo)
|
||||||
output = result.stdout + result.stderr
|
output = result.stdout + result.stderr
|
||||||
|
counts = _parse_pytest_output(output)
|
||||||
# Parse pytest output for counts
|
|
||||||
passed = failed = errors = 0
|
|
||||||
for line in output.splitlines():
|
|
||||||
if "passed" in line or "failed" in line or "error" in line:
|
|
||||||
import re
|
|
||||||
|
|
||||||
nums = re.findall(r"(\d+) (passed|failed|error)", line)
|
|
||||||
for count, kind in nums:
|
|
||||||
if kind == "passed":
|
|
||||||
passed = int(count)
|
|
||||||
elif kind == "failed":
|
|
||||||
failed = int(count)
|
|
||||||
elif kind == "error":
|
|
||||||
errors = int(count)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": result.returncode == 0,
|
"success": result.returncode == 0,
|
||||||
"passed": passed,
|
**counts,
|
||||||
"failed": failed,
|
"total": counts["passed"] + counts["failed"] + counts["errors"],
|
||||||
"errors": errors,
|
|
||||||
"total": passed + failed + errors,
|
|
||||||
"return_code": result.returncode,
|
"return_code": result.returncode,
|
||||||
"summary": output[-2000:] if len(output) > 2000 else output,
|
"summary": output[-2000:] if len(output) > 2000 else output,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ DEFAULT_MAX_UTTERANCE = 30.0 # safety cap — don't record forever
|
|||||||
DEFAULT_SESSION_ID = "voice"
|
DEFAULT_SESSION_ID = "voice"
|
||||||
|
|
||||||
|
|
||||||
|
def _rms(block: np.ndarray) -> float:
|
||||||
|
"""Compute root-mean-square energy of an audio block."""
|
||||||
|
return float(np.sqrt(np.mean(block.astype(np.float32) ** 2)))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VoiceConfig:
|
class VoiceConfig:
|
||||||
"""Configuration for the voice loop."""
|
"""Configuration for the voice loop."""
|
||||||
@@ -161,13 +166,6 @@ class VoiceLoop:
|
|||||||
min_blocks = int(self.config.min_utterance / 0.1)
|
min_blocks = int(self.config.min_utterance / 0.1)
|
||||||
max_blocks = int(self.config.max_utterance / 0.1)
|
max_blocks = int(self.config.max_utterance / 0.1)
|
||||||
|
|
||||||
audio_chunks: list[np.ndarray] = []
|
|
||||||
silent_count = 0
|
|
||||||
recording = False
|
|
||||||
|
|
||||||
def _rms(block: np.ndarray) -> float:
|
|
||||||
return float(np.sqrt(np.mean(block.astype(np.float32) ** 2)))
|
|
||||||
|
|
||||||
sys.stdout.write("\n 🎤 Listening... (speak now)\n")
|
sys.stdout.write("\n 🎤 Listening... (speak now)\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
@@ -177,42 +175,69 @@ class VoiceLoop:
|
|||||||
dtype="float32",
|
dtype="float32",
|
||||||
blocksize=block_size,
|
blocksize=block_size,
|
||||||
) as stream:
|
) as stream:
|
||||||
while self._running:
|
chunks = self._capture_audio_blocks(stream, block_size, silence_blocks, max_blocks)
|
||||||
block, overflowed = stream.read(block_size)
|
|
||||||
if overflowed:
|
|
||||||
logger.debug("Audio buffer overflowed")
|
|
||||||
|
|
||||||
rms = _rms(block)
|
return self._finalize_utterance(chunks, min_blocks, sr)
|
||||||
|
|
||||||
if not recording:
|
def _capture_audio_blocks(
|
||||||
if rms > self.config.silence_threshold:
|
self,
|
||||||
recording = True
|
stream,
|
||||||
silent_count = 0
|
block_size: int,
|
||||||
audio_chunks.append(block.copy())
|
silence_blocks: int,
|
||||||
sys.stdout.write(" 📢 Recording...\r")
|
max_blocks: int,
|
||||||
sys.stdout.flush()
|
) -> list[np.ndarray]:
|
||||||
|
"""Read audio blocks from *stream* until silence or max length.
|
||||||
|
|
||||||
|
Returns the list of captured audio chunks (may be empty).
|
||||||
|
"""
|
||||||
|
chunks: list[np.ndarray] = []
|
||||||
|
silent_count = 0
|
||||||
|
recording = False
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
block, overflowed = stream.read(block_size)
|
||||||
|
if overflowed:
|
||||||
|
logger.debug("Audio buffer overflowed")
|
||||||
|
|
||||||
|
rms = _rms(block)
|
||||||
|
|
||||||
|
if not recording:
|
||||||
|
if rms > self.config.silence_threshold:
|
||||||
|
recording = True
|
||||||
|
silent_count = 0
|
||||||
|
chunks.append(block.copy())
|
||||||
|
sys.stdout.write(" 📢 Recording...\r")
|
||||||
|
sys.stdout.flush()
|
||||||
|
else:
|
||||||
|
chunks.append(block.copy())
|
||||||
|
|
||||||
|
if rms < self.config.silence_threshold:
|
||||||
|
silent_count += 1
|
||||||
else:
|
else:
|
||||||
audio_chunks.append(block.copy())
|
silent_count = 0
|
||||||
|
|
||||||
if rms < self.config.silence_threshold:
|
if silent_count >= silence_blocks:
|
||||||
silent_count += 1
|
break
|
||||||
else:
|
|
||||||
silent_count = 0
|
|
||||||
|
|
||||||
# End of utterance
|
if len(chunks) >= max_blocks:
|
||||||
if silent_count >= silence_blocks:
|
logger.info("Max utterance length reached, stopping.")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Safety cap
|
return chunks
|
||||||
if len(audio_chunks) >= max_blocks:
|
|
||||||
logger.info("Max utterance length reached, stopping.")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not audio_chunks or len(audio_chunks) < min_blocks:
|
@staticmethod
|
||||||
|
def _finalize_utterance(
|
||||||
|
chunks: list[np.ndarray], min_blocks: int, sample_rate: int
|
||||||
|
) -> np.ndarray | None:
|
||||||
|
"""Concatenate recorded chunks and report duration.
|
||||||
|
|
||||||
|
Returns ``None`` if the utterance is too short to be meaningful.
|
||||||
|
"""
|
||||||
|
if not chunks or len(chunks) < min_blocks:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
audio = np.concatenate(audio_chunks, axis=0).flatten()
|
audio = np.concatenate(chunks, axis=0).flatten()
|
||||||
duration = len(audio) / sr
|
duration = len(audio) / sample_rate
|
||||||
sys.stdout.write(f" ✂️ Captured {duration:.1f}s of audio\n")
|
sys.stdout.write(f" ✂️ Captured {duration:.1f}s of audio\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
return audio
|
return audio
|
||||||
@@ -369,15 +394,33 @@ class VoiceLoop:
|
|||||||
|
|
||||||
# ── Main Loop ───────────────────────────────────────────────────────
|
# ── Main Loop ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def run(self) -> None:
|
# Whisper hallucinates these on silence/noise — skip them.
|
||||||
"""Run the voice loop. Blocks until Ctrl-C."""
|
_WHISPER_HALLUCINATIONS = frozenset(
|
||||||
self._ensure_piper()
|
{
|
||||||
|
"you",
|
||||||
|
"thanks.",
|
||||||
|
"thank you.",
|
||||||
|
"bye.",
|
||||||
|
"",
|
||||||
|
"thanks for watching!",
|
||||||
|
"thank you for watching!",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Suppress MCP / Agno stderr noise during voice mode.
|
# Spoken phrases that end the voice session.
|
||||||
_suppress_mcp_noise()
|
_EXIT_COMMANDS = frozenset(
|
||||||
# Suppress MCP async-generator teardown tracebacks on exit.
|
{
|
||||||
_install_quiet_asyncgen_hooks()
|
"goodbye",
|
||||||
|
"exit",
|
||||||
|
"quit",
|
||||||
|
"stop",
|
||||||
|
"goodbye timmy",
|
||||||
|
"stop listening",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _log_banner(self) -> None:
|
||||||
|
"""Log the startup banner with STT/TTS/LLM configuration."""
|
||||||
tts_label = (
|
tts_label = (
|
||||||
"macOS say"
|
"macOS say"
|
||||||
if self.config.use_say_fallback
|
if self.config.use_say_fallback
|
||||||
@@ -393,52 +436,50 @@ class VoiceLoop:
|
|||||||
" Press Ctrl-C to exit.\n" + "=" * 60
|
" Press Ctrl-C to exit.\n" + "=" * 60
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _is_hallucination(self, text: str) -> bool:
|
||||||
|
"""Return True if *text* is a known Whisper hallucination."""
|
||||||
|
return not text or text.lower() in self._WHISPER_HALLUCINATIONS
|
||||||
|
|
||||||
|
def _is_exit_command(self, text: str) -> bool:
|
||||||
|
"""Return True if the user asked to stop the voice session."""
|
||||||
|
return text.lower().strip().rstrip(".!") in self._EXIT_COMMANDS
|
||||||
|
|
||||||
|
def _process_turn(self, text: str) -> None:
|
||||||
|
"""Handle a single listen-think-speak turn after transcription."""
|
||||||
|
sys.stdout.write(f"\n 👤 You: {text}\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
response = self._think(text)
|
||||||
|
sys.stdout.write(f" 🤖 Timmy: {response}\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
self._speak(response)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Run the voice loop. Blocks until Ctrl-C."""
|
||||||
|
self._ensure_piper()
|
||||||
|
_suppress_mcp_noise()
|
||||||
|
_install_quiet_asyncgen_hooks()
|
||||||
|
self._log_banner()
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self._running:
|
while self._running:
|
||||||
# 1. LISTEN — record until silence
|
|
||||||
audio = self._record_utterance()
|
audio = self._record_utterance()
|
||||||
if audio is None:
|
if audio is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 2. TRANSCRIBE — Whisper STT
|
|
||||||
text = self._transcribe(audio)
|
text = self._transcribe(audio)
|
||||||
if not text or text.lower() in (
|
if self._is_hallucination(text):
|
||||||
"you",
|
|
||||||
"thanks.",
|
|
||||||
"thank you.",
|
|
||||||
"bye.",
|
|
||||||
"",
|
|
||||||
"thanks for watching!",
|
|
||||||
"thank you for watching!",
|
|
||||||
):
|
|
||||||
# Whisper hallucinations on silence/noise
|
|
||||||
logger.debug("Ignoring likely Whisper hallucination: '%s'", text)
|
logger.debug("Ignoring likely Whisper hallucination: '%s'", text)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
sys.stdout.write(f"\n 👤 You: {text}\n")
|
if self._is_exit_command(text):
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
# Exit commands
|
|
||||||
if text.lower().strip().rstrip(".!") in (
|
|
||||||
"goodbye",
|
|
||||||
"exit",
|
|
||||||
"quit",
|
|
||||||
"stop",
|
|
||||||
"goodbye timmy",
|
|
||||||
"stop listening",
|
|
||||||
):
|
|
||||||
logger.info("👋 Goodbye!")
|
logger.info("👋 Goodbye!")
|
||||||
break
|
break
|
||||||
|
|
||||||
# 3. THINK — send to Timmy
|
self._process_turn(text)
|
||||||
response = self._think(text)
|
|
||||||
sys.stdout.write(f" 🤖 Timmy: {response}\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
# 4. SPEAK — TTS output
|
|
||||||
self._speak(response)
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("👋 Voice loop stopped.")
|
logger.info("👋 Voice loop stopped.")
|
||||||
|
|||||||
@@ -174,6 +174,103 @@ class TestDiscordVendor:
|
|||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractContent:
|
||||||
|
def test_strips_bot_mention(self):
|
||||||
|
from integrations.chat_bridge.vendors.discord import DiscordVendor
|
||||||
|
|
||||||
|
vendor = DiscordVendor()
|
||||||
|
vendor._client = MagicMock()
|
||||||
|
vendor._client.user.id = 12345
|
||||||
|
msg = MagicMock()
|
||||||
|
msg.content = "<@12345> hello there"
|
||||||
|
assert vendor._extract_content(msg) == "hello there"
|
||||||
|
|
||||||
|
def test_no_client_user(self):
|
||||||
|
from integrations.chat_bridge.vendors.discord import DiscordVendor
|
||||||
|
|
||||||
|
vendor = DiscordVendor()
|
||||||
|
vendor._client = MagicMock()
|
||||||
|
vendor._client.user = None
|
||||||
|
msg = MagicMock()
|
||||||
|
msg.content = "hello"
|
||||||
|
assert vendor._extract_content(msg) == "hello"
|
||||||
|
|
||||||
|
def test_empty_after_strip(self):
|
||||||
|
from integrations.chat_bridge.vendors.discord import DiscordVendor
|
||||||
|
|
||||||
|
vendor = DiscordVendor()
|
||||||
|
vendor._client = MagicMock()
|
||||||
|
vendor._client.user.id = 99
|
||||||
|
msg = MagicMock()
|
||||||
|
msg.content = "<@99>"
|
||||||
|
assert vendor._extract_content(msg) == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvokeAgent:
|
||||||
|
@staticmethod
|
||||||
|
def _make_typing_target():
|
||||||
|
"""Build a mock target whose .typing() is an async context manager."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
target = AsyncMock()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _typing():
|
||||||
|
yield
|
||||||
|
|
||||||
|
target.typing = _typing
|
||||||
|
return target
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeout_returns_error(self):
|
||||||
|
from integrations.chat_bridge.vendors.discord import DiscordVendor
|
||||||
|
|
||||||
|
vendor = DiscordVendor()
|
||||||
|
target = self._make_typing_target()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"integrations.chat_bridge.vendors.discord.chat_with_tools", side_effect=TimeoutError
|
||||||
|
):
|
||||||
|
run_output, response = await vendor._invoke_agent("hi", "sess", target)
|
||||||
|
assert run_output is None
|
||||||
|
assert "too long" in response
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exception_returns_error(self):
|
||||||
|
from integrations.chat_bridge.vendors.discord import DiscordVendor
|
||||||
|
|
||||||
|
vendor = DiscordVendor()
|
||||||
|
target = self._make_typing_target()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"integrations.chat_bridge.vendors.discord.chat_with_tools",
|
||||||
|
side_effect=RuntimeError("boom"),
|
||||||
|
):
|
||||||
|
run_output, response = await vendor._invoke_agent("hi", "sess", target)
|
||||||
|
assert run_output is None
|
||||||
|
assert "trouble" in response
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendResponse:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_empty(self):
|
||||||
|
from integrations.chat_bridge.vendors.discord import DiscordVendor
|
||||||
|
|
||||||
|
target = AsyncMock()
|
||||||
|
await DiscordVendor._send_response(None, target)
|
||||||
|
target.send.assert_not_called()
|
||||||
|
await DiscordVendor._send_response("", target)
|
||||||
|
target.send.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_short_message(self):
|
||||||
|
from integrations.chat_bridge.vendors.discord import DiscordVendor
|
||||||
|
|
||||||
|
target = AsyncMock()
|
||||||
|
await DiscordVendor._send_response("hello", target)
|
||||||
|
target.send.assert_called_once_with("hello")
|
||||||
|
|
||||||
|
|
||||||
class TestChunkMessage:
|
class TestChunkMessage:
|
||||||
def test_short_message(self):
|
def test_short_message(self):
|
||||||
from integrations.chat_bridge.vendors.discord import _chunk_message
|
from integrations.chat_bridge.vendors.discord import _chunk_message
|
||||||
|
|||||||
@@ -361,6 +361,53 @@ class TestRun:
|
|||||||
assert response == "ok"
|
assert response == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
# ── _handle_retry_or_raise ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleRetryOrRaise:
|
||||||
|
def test_raises_on_last_attempt(self):
|
||||||
|
BaseAgent = _make_base_class()
|
||||||
|
with pytest.raises(ValueError, match="boom"):
|
||||||
|
BaseAgent._handle_retry_or_raise(
|
||||||
|
ValueError("boom"),
|
||||||
|
attempt=3,
|
||||||
|
max_retries=3,
|
||||||
|
transient=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_raises_on_last_attempt_transient(self):
|
||||||
|
BaseAgent = _make_base_class()
|
||||||
|
exc = httpx.ConnectError("down")
|
||||||
|
with pytest.raises(httpx.ConnectError):
|
||||||
|
BaseAgent._handle_retry_or_raise(
|
||||||
|
exc,
|
||||||
|
attempt=3,
|
||||||
|
max_retries=3,
|
||||||
|
transient=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_raise_on_early_attempt(self):
|
||||||
|
BaseAgent = _make_base_class()
|
||||||
|
# Should return None (no raise) on non-final attempt
|
||||||
|
result = BaseAgent._handle_retry_or_raise(
|
||||||
|
ValueError("retry me"),
|
||||||
|
attempt=1,
|
||||||
|
max_retries=3,
|
||||||
|
transient=False,
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_no_raise_on_early_transient(self):
|
||||||
|
BaseAgent = _make_base_class()
|
||||||
|
result = BaseAgent._handle_retry_or_raise(
|
||||||
|
httpx.ReadTimeout("busy"),
|
||||||
|
attempt=2,
|
||||||
|
max_retries=3,
|
||||||
|
transient=True,
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
# ── get_capabilities / get_status ────────────────────────────────────────────
|
# ── get_capabilities / get_status ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,14 +55,14 @@ def test_think_sends_topic_to_agent():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_think_passes_model_size_option():
|
def test_think_ignores_model_size_option():
|
||||||
"""think --model-size 70b must forward the model size to create_timmy."""
|
"""think --model-size is accepted but not forwarded to create_timmy."""
|
||||||
mock_timmy = MagicMock()
|
mock_timmy = MagicMock()
|
||||||
|
|
||||||
with patch("timmy.cli.create_timmy", return_value=mock_timmy) as mock_create:
|
with patch("timmy.cli.create_timmy", return_value=mock_timmy) as mock_create:
|
||||||
runner.invoke(app, ["think", "topic", "--model-size", "70b"])
|
runner.invoke(app, ["think", "topic", "--model-size", "70b"])
|
||||||
|
|
||||||
mock_create.assert_called_once_with(backend=None, model_size="70b", session_id="cli")
|
mock_create.assert_called_once_with(backend=None, session_id="cli")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ except ImportError:
|
|||||||
np = None
|
np = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from timmy.voice_loop import VoiceConfig, VoiceLoop, _strip_markdown
|
from timmy.voice_loop import VoiceConfig, VoiceLoop, _rms, _strip_markdown
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass # pytestmark will skip all tests anyway
|
pass # pytestmark will skip all tests anyway
|
||||||
|
|
||||||
@@ -147,6 +147,31 @@ class TestStripMarkdown:
|
|||||||
assert "*" not in result
|
assert "*" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestRms:
|
||||||
|
def test_silent_block(self):
|
||||||
|
block = np.zeros(1600, dtype=np.float32)
|
||||||
|
assert _rms(block) == pytest.approx(0.0, abs=1e-7)
|
||||||
|
|
||||||
|
def test_loud_block(self):
|
||||||
|
block = np.ones(1600, dtype=np.float32)
|
||||||
|
assert _rms(block) == pytest.approx(1.0, abs=1e-5)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFinalizeUtterance:
|
||||||
|
def test_returns_none_for_empty(self):
|
||||||
|
assert VoiceLoop._finalize_utterance([], min_blocks=5, sample_rate=16000) is None
|
||||||
|
|
||||||
|
def test_returns_none_for_too_short(self):
|
||||||
|
chunks = [np.zeros(1600, dtype=np.float32) for _ in range(3)]
|
||||||
|
assert VoiceLoop._finalize_utterance(chunks, min_blocks=5, sample_rate=16000) is None
|
||||||
|
|
||||||
|
def test_returns_audio_for_sufficient_chunks(self):
|
||||||
|
chunks = [np.ones(1600, dtype=np.float32) for _ in range(6)]
|
||||||
|
result = VoiceLoop._finalize_utterance(chunks, min_blocks=5, sample_rate=16000)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) == 6 * 1600
|
||||||
|
|
||||||
|
|
||||||
class TestThink:
|
class TestThink:
|
||||||
def test_think_returns_response(self):
|
def test_think_returns_response(self):
|
||||||
loop = VoiceLoop()
|
loop = VoiceLoop()
|
||||||
@@ -236,6 +261,7 @@ class TestHallucinationFilter:
|
|||||||
"""Whisper tends to hallucinate on silence/noise. The loop should filter these."""
|
"""Whisper tends to hallucinate on silence/noise. The loop should filter these."""
|
||||||
|
|
||||||
def test_known_hallucinations_filtered(self):
|
def test_known_hallucinations_filtered(self):
|
||||||
|
loop = VoiceLoop()
|
||||||
hallucinations = [
|
hallucinations = [
|
||||||
"you",
|
"you",
|
||||||
"thanks.",
|
"thanks.",
|
||||||
@@ -243,33 +269,35 @@ class TestHallucinationFilter:
|
|||||||
"Bye.",
|
"Bye.",
|
||||||
"Thanks for watching!",
|
"Thanks for watching!",
|
||||||
"Thank you for watching!",
|
"Thank you for watching!",
|
||||||
|
"",
|
||||||
]
|
]
|
||||||
for text in hallucinations:
|
for text in hallucinations:
|
||||||
assert text.lower() in (
|
assert loop._is_hallucination(text), f"'{text}' should be filtered"
|
||||||
"you",
|
|
||||||
"thanks.",
|
def test_real_speech_not_filtered(self):
|
||||||
"thank you.",
|
loop = VoiceLoop()
|
||||||
"bye.",
|
assert not loop._is_hallucination("Hello Timmy")
|
||||||
"",
|
assert not loop._is_hallucination("What time is it?")
|
||||||
"thanks for watching!",
|
|
||||||
"thank you for watching!",
|
|
||||||
), f"'{text}' should be filtered"
|
|
||||||
|
|
||||||
|
|
||||||
class TestExitCommands:
|
class TestExitCommands:
|
||||||
"""Voice loop should recognize exit commands."""
|
"""Voice loop should recognize exit commands."""
|
||||||
|
|
||||||
def test_exit_commands(self):
|
def test_exit_commands(self):
|
||||||
|
loop = VoiceLoop()
|
||||||
exits = ["goodbye", "exit", "quit", "stop", "goodbye timmy", "stop listening"]
|
exits = ["goodbye", "exit", "quit", "stop", "goodbye timmy", "stop listening"]
|
||||||
for cmd in exits:
|
for cmd in exits:
|
||||||
assert cmd.lower().strip().rstrip(".!") in (
|
assert loop._is_exit_command(cmd), f"'{cmd}' should be an exit command"
|
||||||
"goodbye",
|
|
||||||
"exit",
|
def test_exit_with_punctuation(self):
|
||||||
"quit",
|
loop = VoiceLoop()
|
||||||
"stop",
|
assert loop._is_exit_command("goodbye!")
|
||||||
"goodbye timmy",
|
assert loop._is_exit_command("stop.")
|
||||||
"stop listening",
|
|
||||||
), f"'{cmd}' should be an exit command"
|
def test_non_exit_commands(self):
|
||||||
|
loop = VoiceLoop()
|
||||||
|
assert not loop._is_exit_command("hello")
|
||||||
|
assert not loop._is_exit_command("what time is it")
|
||||||
|
|
||||||
|
|
||||||
class TestPlayAudio:
|
class TestPlayAudio:
|
||||||
|
|||||||
Reference in New Issue
Block a user