"""Note agent — Markdown note management (list, get, create, update, propose edit).""" from __future__ import annotations import asyncio import re from typing import Any from langchain_core.tools import tool from app.core.note_summarizer import generate_note_summary from app.core.ws_context import execute_on_client _UUID_RE = re.compile( r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" ) def _is_uuid(value: str) -> bool: return bool(_UUID_RE.match(value)) def _fmt_summary(row: dict) -> str: summary = (row.get("aiSummary") or row.get("ai_summary") or "").strip() if summary: return f" — {summary}" snippet = (row.get("content") or "")[:120].replace("\n", " ").strip() return f" — {snippet}" if snippet else "" @tool async def list_notes(project_id: str = "") -> str: """List notes with AI summaries, optionally scoped to a project by project_id. Returns id, title, and ai_summary for each note so you can decide which note to read in full with get_note before creating or updating. """ normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else "" result = await execute_on_client( action="select", table="notes", filters={"projectId": normalized_project_id or None}, ) rows = result.get("rows", []) if not rows: return "No notes found." lines = [f" - [{r['id']}] {r['title']}{_fmt_summary(r)}" 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.""" 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 async def create_note( title: str, content: str, project_id: str = "", ) -> str: """Create a new note. title: note heading (required) content: Markdown body text (required) project_id: optional UUID linking this note to a project """ result = await execute_on_client( action="insert", table="notes", data={ "title": title, "content": content, "projectId": project_id or None, }, ) row = result["row"] note_id: str = row["id"] # Generate summary asynchronously — fire-and-forget. asyncio.create_task(_refresh_summary(note_id, title, content)) return f"Note created: '{row['title']}' (id: {note_id})." @tool async def update_note( note_id: str, title: str = "", content: str = "", ) -> str: """Update an existing note directly (no approval required). Use propose_note_edit instead when human review is needed. note_id: UUID of the note (required) If you need to preserve existing content, call get_note first. """ updates: dict[str, Any] = {} if title: updates["title"] = title if content: updates["content"] = content result = await execute_on_client( action="update", table="notes", data={"id": note_id, "updates": updates}, ) row = result["row"] if content: new_title = title or row.get("title", "") asyncio.create_task(_refresh_summary(note_id, new_title, content)) return f"Note updated: '{row['title']}' (id: {row['id']})." @tool async def propose_note_edit( note_id: str, edit_type: str, proposed_content: str, reasoning: str = "", anchor_before: str = "", anchor_text: str = "", agent_id: str = "", run_id: str = "", ) -> str: """Propose an AI edit to an existing note, pending human approval. Use this instead of update_note when review_required is true. The user will see the proposal highlighted before it is merged. note_id: UUID of the target note (required) edit_type: 'append' | 'insert' | 'replace' - append: adds proposed_content at the end of the note - insert: inserts proposed_content immediately after anchor_before text - replace: replaces the first occurrence of anchor_text with proposed_content proposed_content: the new Markdown text to add or substitute (required) reasoning: brief explanation shown to the user (recommended) anchor_before: for 'insert' — the text snippet that precedes the insertion point anchor_text: for 'replace' — the exact text to be replaced agent_id: agent identifier (for traceability) run_id: run identifier (for traceability) """ if edit_type not in ("append", "insert", "replace"): return f"Invalid edit_type '{edit_type}'. Use 'append', 'insert', or 'replace'." result = await execute_on_client( action="propose_note_edit", data={ "noteId": note_id, "type": edit_type, "proposedContent": proposed_content, "reasoning": reasoning or None, "anchorBefore": anchor_before or None, "anchorText": anchor_text or None, "agentId": agent_id or None, "runId": run_id or None, }, ) edit_id = result.get("id", "?") return ( f"Edit proposal created (id: {edit_id}) for note {note_id}. " f"Status: pending user approval." ) @tool async def delete_note(note_id: str) -> str: """Delete a note permanently by its UUID.""" await execute_on_client(action="delete", table="notes", data={"id": note_id}) return f"Note {note_id} deleted." async def _refresh_summary(note_id: str, title: str, content: str) -> None: """Generate and persist the AI summary for a note. Fire-and-forget.""" try: summary = await generate_note_summary(title, content) if summary: await execute_on_client( action="update", table="notes", data={ "id": note_id, "updates": { "aiSummary": summary, "aiSummaryUpdatedAt": int(__import__("time").time() * 1000), }, }, ) except Exception: pass # fire-and-forget; errors logged by generate_note_summary NOTE_TOOLS: list[Any] = [ list_notes, get_note, create_note, update_note, propose_note_edit, delete_note, ] NOTE_READ_TOOLS: list[Any] = [ list_notes, get_note, ]