diff --git a/AI_REFACTOR_PLAN.md b/AI_REFACTOR_PLAN.md index fc759ba..db662bd 100644 --- a/AI_REFACTOR_PLAN.md +++ b/AI_REFACTOR_PLAN.md @@ -97,11 +97,11 @@ Tools must use **camelCase** field names (Drizzle maps them to snake_case intern - **Outcome:** Any tool can `await execute_on_client(...)` to query/mutate the user's local DB. ### Step B.2 — Rewrite all 23 tools to use `execute_on_client()` -- [ ] Each tool: same `@tool` decorator, same parameters, same docstring. Replace `return json.dumps({...})` body with: +- [x] Each tool: same `@tool` decorator, same parameters, same docstring. Replace `return json.dumps({...})` body with: 1. Call `result = await execute_on_client(action=..., table=..., data/filters=...)` 2. Return human-readable string with confirmation + key data from `result` -- [ ] **`app/agents/task_agent.py` (8 tools):** +- [x] **`app/agents/task_agent.py` (8 tools):** - `list_tasks(project_id, status, search, order_by)`: ```python result = await execute_on_client(action="select", table="tasks", filters={ @@ -133,7 +133,7 @@ Tools must use **camelCase** field names (Drizzle maps them to snake_case intern - `add_task_comment(task_id, author, content)`: `execute_on_client(action="insert", table="taskComments", data={...})` → return confirmation - `delete_task_comment(comment_id)`: `execute_on_client(action="delete", table="taskComments", data={"id": comment_id})` → return confirmation -- [ ] **`app/agents/project_agent.py` (6 tools):** +- [x] **`app/agents/project_agent.py` (6 tools):** - `list_projects(client_id, include_archived)`: `execute_on_client(action="select", table="projects", filters={clientId, includeArchived})` → format + return - `list_all_projects()`: `execute_on_client(action="select", table="projects")` → format + return - `get_project(project_id)`: `execute_on_client(action="get", table="projects", data={"id": project_id})` → return project details or "not found" @@ -141,13 +141,13 @@ Tools must use **camelCase** field names (Drizzle maps them to snake_case intern - `update_project(project_id, ...)`: build updates → `execute_on_client(action="update", ...)` → return confirmation - `delete_project(project_id)`: `execute_on_client(action="delete", ...)` → return confirmation -- [ ] **`app/agents/checkpoint_agent.py` (4 tools):** +- [x] **`app/agents/checkpoint_agent.py` (4 tools):** - `list_checkpoints(project_id)`: `execute_on_client(action="select", table="checkpoints", filters={projectId})` → format + return - `create_checkpoint(project_id, title, date, ...)`: `execute_on_client(action="insert", table="checkpoints", data={...})` → return confirmation + id - `update_checkpoint(checkpoint_id, ...)`: build updates → `execute_on_client(action="update", ...)` → return confirmation - `delete_checkpoint(checkpoint_id)`: `execute_on_client(action="delete", ...)` → return confirmation -- [ ] **`app/agents/note_agent.py` (5 tools):** +- [x] **`app/agents/note_agent.py` (5 tools):** - `list_notes(project_id)`: `execute_on_client(action="select", table="notes", filters={projectId})` → format + return - `get_note(note_id)`: `execute_on_client(action="get", table="notes", data={"id": note_id})` → return full content or "not found" - `create_note(title, content, project_id)`: `execute_on_client(action="insert", table="notes", data={...})` → then `execute_on_client(action="vector_upsert", data={id, projectId, content}, vector=await embed(content))` → return confirmation diff --git a/app/agents/checkpoint_agent.py b/app/agents/checkpoint_agent.py index a42f865..3de2eb8 100644 --- a/app/agents/checkpoint_agent.py +++ b/app/agents/checkpoint_agent.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from typing import Any from langchain_core.messages import HumanMessage, SystemMessage @@ -10,6 +9,7 @@ from langchain_core.tools import tool from app.core.agent_registry import ChatAgent, registry from app.core.llm import get_llm +from app.core.ws_context import execute_on_client _SYSTEM_PROMPT = ( "You are a project checkpoint assistant. Checkpoints are milestone dates that\n" @@ -28,11 +28,16 @@ _SYSTEM_PROMPT = ( @tool async def list_checkpoints(project_id: str = "") -> str: """List checkpoints. Provide project_id to scope to a specific project.""" - return json.dumps({ - "action": "list", - "table": "checkpoints", - "filters": {"projectId": project_id or None}, - }) + result = await execute_on_client( + action="select", + table="checkpoints", + filters={"projectId": project_id or None}, + ) + rows = result.get("rows", []) + if not rows: + return "No checkpoints found." + lines = [f"- {r['title']} (date: {r['date']}, id: {r['id']})" for r in rows] + return f"Found {len(rows)} checkpoint(s):\n" + "\n".join(lines) @tool @@ -50,17 +55,19 @@ async def create_checkpoint( is_ai_suggested: 1 if proactively suggested, 0 if user-requested is_approved: 0 until the user confirms """ - return json.dumps({ - "action": "create_record", - "table": "checkpoints", - "data": { + result = await execute_on_client( + action="insert", + table="checkpoints", + data={ "projectId": project_id, "title": title, "date": date, "isAiSuggested": is_ai_suggested, "isApproved": is_approved, }, - }) + ) + row = result["row"] + return f"Checkpoint created: '{row['title']}' (id: {row['id']}, date: {row['date']})" @tool @@ -82,21 +89,20 @@ async def update_checkpoint( updates["date"] = date if is_approved != -1: updates["isApproved"] = is_approved - return json.dumps({ - "action": "update_record", - "table": "checkpoints", - "data": {"id": checkpoint_id, "updates": updates}, - }) + result = await execute_on_client( + action="update", + table="checkpoints", + data={"id": checkpoint_id, "updates": updates}, + ) + row = result["row"] + return f"Checkpoint updated: '{row['title']}' (id: {row['id']})" @tool async def delete_checkpoint(checkpoint_id: str) -> str: """Delete a checkpoint permanently by its UUID.""" - return json.dumps({ - "action": "delete_record", - "table": "checkpoints", - "data": {"id": checkpoint_id}, - }) + await execute_on_client(action="delete", table="checkpoints", data={"id": checkpoint_id}) + return f"Checkpoint {checkpoint_id} deleted." @registry.register diff --git a/app/agents/note_agent.py b/app/agents/note_agent.py index 905820e..5589ba1 100644 --- a/app/agents/note_agent.py +++ b/app/agents/note_agent.py @@ -2,14 +2,14 @@ from __future__ import annotations -import json from typing import Any from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.tools import tool from app.core.agent_registry import ChatAgent, registry -from app.core.llm import get_llm +from app.core.llm import embed, get_llm +from app.core.ws_context import execute_on_client _SYSTEM_PROMPT = ( "You are a note-taking assistant. You help users create, retrieve, update,\n" @@ -29,21 +29,26 @@ _SYSTEM_PROMPT = ( @tool async def list_notes(project_id: str = "") -> str: """List notes, optionally scoped to a project by project_id.""" - return json.dumps({ - "action": "list", - "table": "notes", - "filters": {"projectId": project_id or None}, - }) + result = await execute_on_client( + action="select", + table="notes", + filters={"projectId": project_id or None}, + ) + rows = result.get("rows", []) + if not rows: + return "No notes found." + lines = [f"- {r['title']} (id: {r['id']})" for r in rows] + return f"Found {len(rows)} note(s):\n" + "\n".join(lines) @tool async def get_note(note_id: str) -> str: """Fetch a single note by its UUID to read its full Markdown content.""" - return json.dumps({ - "action": "get", - "table": "notes", - "data": {"id": note_id}, - }) + result = await execute_on_client(action="get", table="notes", data={"id": note_id}) + row = result.get("row") + if not row: + return f"Note {note_id} not found." + return f"Note '{row['title']}' (id: {row['id']}):\n\n{row['content']}" @tool @@ -57,15 +62,24 @@ async def create_note( content: Markdown body text (required) project_id: optional UUID linking this note to a project """ - return json.dumps({ - "action": "create_record", - "table": "notes", - "data": { + result = await execute_on_client( + action="insert", + table="notes", + data={ "title": title, "content": content, "projectId": project_id or None, }, - }) + ) + row = result["row"] + # Index the note content in the vector store. + vector = await embed(content) + await execute_on_client( + action="vector_upsert", + data={"id": row["id"], "projectId": row.get("projectId"), "content": content}, + vector=vector, + ) + return f"Note created: '{row['title']}' (id: {row['id']})." @tool @@ -83,21 +97,28 @@ async def update_note( updates["title"] = title if content: updates["content"] = content - return json.dumps({ - "action": "update_record", - "table": "notes", - "data": {"id": note_id, "updates": updates}, - }) + result = await execute_on_client( + action="update", + table="notes", + data={"id": note_id, "updates": updates}, + ) + row = result["row"] + # Re-index if content changed. + if content: + vector = await embed(content) + await execute_on_client( + action="vector_upsert", + data={"id": note_id, "projectId": row.get("projectId"), "content": content}, + vector=vector, + ) + return f"Note updated: '{row['title']}' (id: {row['id']})." @tool async def delete_note(note_id: str) -> str: """Delete a note permanently by its UUID.""" - return json.dumps({ - "action": "delete_record", - "table": "notes", - "data": {"id": note_id}, - }) + await execute_on_client(action="delete", table="notes", data={"id": note_id}) + return f"Note {note_id} deleted." @registry.register diff --git a/app/agents/project_agent.py b/app/agents/project_agent.py index b8bc14f..e01f1c6 100644 --- a/app/agents/project_agent.py +++ b/app/agents/project_agent.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from typing import Any from langchain_core.messages import HumanMessage, SystemMessage @@ -10,6 +9,7 @@ from langchain_core.tools import tool from app.core.agent_registry import ChatAgent, registry from app.core.llm import get_llm +from app.core.ws_context import execute_on_client _SYSTEM_PROMPT = ( "You are a project management assistant. You help users create, find,\n" @@ -36,14 +36,19 @@ async def list_projects( """List projects, optionally filtered by client_id. include_archived: 1 to include archived projects, 0 for active only (default). """ - return json.dumps({ - "action": "list", - "table": "projects", - "filters": { + result = await execute_on_client( + action="select", + table="projects", + filters={ "clientId": client_id or None, "includeArchived": bool(include_archived), }, - }) + ) + rows = result.get("rows", []) + if not rows: + return "No projects found." + lines = [f"- {r['name']} (status: {r['status']}, id: {r['id']})" for r in rows] + return f"Found {len(rows)} project(s):\n" + "\n".join(lines) @tool @@ -51,20 +56,25 @@ async def list_all_projects() -> str: """List every project regardless of client or status. Use only when the user wants a complete cross-client overview. """ - return json.dumps({ - "action": "list_all", - "table": "projects", - }) + result = await execute_on_client(action="select", table="projects") + rows = result.get("rows", []) + if not rows: + return "No projects found." + lines = [f"- {r['name']} (status: {r['status']}, id: {r['id']})" for r in rows] + return f"All projects ({len(rows)}):\n" + "\n".join(lines) @tool async def get_project(project_id: str) -> str: """Fetch a single project by its UUID.""" - return json.dumps({ - "action": "get", - "table": "projects", - "data": {"id": project_id}, - }) + result = await execute_on_client(action="get", table="projects", data={"id": project_id}) + row = result.get("row") + if not row: + return f"Project {project_id} not found." + return ( + f"Project: '{row['name']}' (id: {row['id']}, status: {row['status']}, " + f"clientId: {row.get('clientId', 'none')})" + ) @tool @@ -76,14 +86,13 @@ async def create_project( name: human-readable project name (required) client_id: optional UUID of the owning client """ - return json.dumps({ - "action": "create_record", - "table": "projects", - "data": { - "name": name, - "clientId": client_id or None, - }, - }) + result = await execute_on_client( + action="insert", + table="projects", + data={"name": name, "clientId": client_id or None}, + ) + row = result["row"] + return f"Project created: '{row['name']}' (id: {row['id']})" @tool @@ -108,11 +117,13 @@ async def update_project( updates["status"] = status if ai_summary: updates["aiSummary"] = ai_summary - return json.dumps({ - "action": "update_record", - "table": "projects", - "data": {"id": project_id, "updates": updates}, - }) + result = await execute_on_client( + action="update", + table="projects", + data={"id": project_id, "updates": updates}, + ) + row = result["row"] + return f"Project updated: '{row['name']}' (id: {row['id']}, status: {row['status']})" @tool @@ -121,11 +132,8 @@ async def delete_project(project_id: str) -> str: IMPORTANT: prefer update_project(status='archived') unless the user has explicitly confirmed they want permanent deletion. """ - return json.dumps({ - "action": "delete_record", - "table": "projects", - "data": {"id": project_id}, - }) + await execute_on_client(action="delete", table="projects", data={"id": project_id}) + return f"Project {project_id} permanently deleted." @registry.register diff --git a/app/agents/task_agent.py b/app/agents/task_agent.py index 07ac619..6d932a7 100644 --- a/app/agents/task_agent.py +++ b/app/agents/task_agent.py @@ -2,7 +2,7 @@ from __future__ import annotations -import json +from datetime import datetime, timezone from typing import Any from langchain_core.messages import HumanMessage, SystemMessage @@ -10,6 +10,7 @@ from langchain_core.tools import tool from app.core.agent_registry import ChatAgent, registry from app.core.llm import get_llm +from app.core.ws_context import execute_on_client _SYSTEM_PROMPT = ( "You are a task management assistant for a project workspace.\n" @@ -41,16 +42,24 @@ async def list_tasks( ) -> str: """List tasks, optionally filtered by project_id, status (todo|in_progress|done), a search string, or an order_by field name (dueDate|priority|createdAt).""" - return json.dumps({ - "action": "list", - "table": "tasks", - "filters": { + result = await execute_on_client( + action="select", + table="tasks", + filters={ "projectId": project_id or None, "status": status or None, "search": search or None, "orderBy": order_by or None, }, - }) + ) + rows = result.get("rows", []) + if not rows: + return "No tasks found matching the given filters." + lines = [ + f"- {r['title']} (status: {r['status']}, priority: {r['priority']}, id: {r['id']})" + for r in rows + ] + return f"Found {len(rows)} task(s):\n" + "\n".join(lines) @tool @@ -76,10 +85,10 @@ async def create_task( is_ai_suggested: 1 if proactively suggested, 0 if user-requested is_approved: 0 until the user confirms; 1 when confirmed """ - return json.dumps({ - "action": "create_record", - "table": "tasks", - "data": { + result = await execute_on_client( + action="insert", + table="tasks", + data={ "title": title, "description": description or None, "status": status, @@ -90,7 +99,12 @@ async def create_task( "isAiSuggested": is_ai_suggested, "isApproved": is_approved, }, - }) + ) + row = result["row"] + return ( + f"Task created: '{row['title']}' " + f"(id: {row['id']}, status: {row['status']}, priority: {row['priority']})" + ) @tool @@ -127,30 +141,41 @@ async def update_task( updates["projectId"] = project_id if is_approved != -1: updates["isApproved"] = is_approved - return json.dumps({ - "action": "update_record", - "table": "tasks", - "data": {"id": task_id, "updates": updates}, - }) + result = await execute_on_client( + action="update", + table="tasks", + data={"id": task_id, "updates": updates}, + ) + row = result["row"] + return f"Task updated: '{row['title']}' (id: {row['id']}, status: {row['status']})" @tool async def delete_task(task_id: str) -> str: """Delete a task permanently by its UUID.""" - return json.dumps({ - "action": "delete_record", - "table": "tasks", - "data": {"id": task_id}, - }) + await execute_on_client(action="delete", table="tasks", data={"id": task_id}) + return f"Task {task_id} deleted." @tool async def list_tasks_due_today() -> str: """List all tasks whose due date falls on today's date.""" - return json.dumps({ - "action": "list_due_today", - "table": "tasks", - }) + now = datetime.now(tz=timezone.utc) + start_ms = int(datetime(now.year, now.month, now.day, tzinfo=timezone.utc).timestamp() * 1000) + end_ms = start_ms + 86_400_000 - 1 # last ms of today + result = await execute_on_client( + action="select", + table="tasks", + filters={"dueDateFrom": start_ms, "dueDateTo": end_ms}, + ) + rows = result.get("rows", []) + if not rows: + return "No tasks are due today." + lines = [ + f"- {r['title']} (priority: {r['priority']}, status: {r['status']}, id: {r['id']})" + for r in rows + ] + return f"Tasks due today ({len(rows)}):\n" + "\n".join(lines) # ── Task comment tools ──────────────────────────────────────────────── @@ -159,11 +184,16 @@ async def list_tasks_due_today() -> str: @tool async def list_task_comments(task_id: str) -> str: """List all comments on a task by its UUID.""" - return json.dumps({ - "action": "list", - "table": "taskComments", - "filters": {"taskId": task_id}, - }) + result = await execute_on_client( + action="select", + table="taskComments", + filters={"taskId": task_id}, + ) + rows = result.get("rows", []) + if not rows: + return f"No comments found for task {task_id}." + lines = [f"- [{r['author']}]: {r['content']} (id: {r['id']})" for r in rows] + return f"Found {len(rows)} comment(s):\n" + "\n".join(lines) @tool @@ -173,25 +203,20 @@ async def add_task_comment(task_id: str, author: str, content: str) -> str: author: name or ID of the comment author content: comment text """ - return json.dumps({ - "action": "create_record", - "table": "taskComments", - "data": { - "taskId": task_id, - "author": author, - "content": content, - }, - }) + result = await execute_on_client( + action="insert", + table="taskComments", + data={"taskId": task_id, "author": author, "content": content}, + ) + row = result["row"] + return f"Comment added by {row['author']} on task {row['taskId']} (comment id: {row['id']})." @tool async def delete_task_comment(comment_id: str) -> str: """Delete a task comment by its UUID.""" - return json.dumps({ - "action": "delete_record", - "table": "taskComments", - "data": {"id": comment_id}, - }) + await execute_on_client(action="delete", table="taskComments", data={"id": comment_id}) + return f"Comment {comment_id} deleted." # ── Agent ───────────────────────────────────────────────────────────── diff --git a/app/core/llm.py b/app/core/llm.py index c6a69ea..0a717a2 100644 --- a/app/core/llm.py +++ b/app/core/llm.py @@ -17,6 +17,8 @@ Switch providers by changing **LLM_MODEL** / **LLM_ROUTER_MODEL** in ``.env`` from __future__ import annotations +from openai import AsyncOpenAI + from langchain_openai import ChatOpenAI from litellm import get_supported_openai_params # noqa: F401 – validates install @@ -66,3 +68,13 @@ def get_router_llm( ) -> ChatOpenAI: """Return the lighter model used for intent classification / routing.""" return get_llm(model=settings.LLM_ROUTER_MODEL, temperature=temperature) + + +async def embed(text: str) -> list[float]: + """Return a 1536-dim embedding vector for *text* using text-embedding-3-small.""" + client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + response = await client.embeddings.create( + model="text-embedding-3-small", + input=text, + ) + return response.data[0].embedding