Files
project-lyra/cortex/autonomy/tools/executors/trilium.py
2025-12-31 22:36:24 -05:00

217 lines
6.9 KiB
Python

"""
Trilium notes executor for searching and creating notes via ETAPI.
This module provides integration with Trilium notes through the ETAPI HTTP API
with improved resilience: timeout configuration, retry logic, and connection pooling.
"""
import os
import asyncio
import aiohttp
from typing import Dict, Optional
from ..utils.resilience import async_retry
TRILIUM_URL = os.getenv("TRILIUM_URL", "http://localhost:8080")
TRILIUM_TOKEN = os.getenv("TRILIUM_ETAPI_TOKEN", "")
# Module-level session for connection pooling
_session: Optional[aiohttp.ClientSession] = None
def get_session() -> aiohttp.ClientSession:
"""Get or create shared aiohttp session for connection pooling."""
global _session
if _session is None or _session.closed:
timeout = aiohttp.ClientTimeout(
total=float(os.getenv("TRILIUM_TIMEOUT", "30.0")),
connect=float(os.getenv("TRILIUM_CONNECT_TIMEOUT", "10.0"))
)
_session = aiohttp.ClientSession(timeout=timeout)
return _session
@async_retry(
max_attempts=3,
exceptions=(aiohttp.ClientError, asyncio.TimeoutError)
)
async def search_notes(args: Dict) -> Dict:
"""Search Trilium notes via ETAPI with retry logic.
Args:
args: Dictionary containing:
- query (str): Search query
- limit (int, optional): Maximum notes to return (default: 5, max: 20)
Returns:
dict: Search results containing:
- notes (list): List of notes with noteId, title, content, type
- count (int): Number of notes returned
OR
- error (str): Error message if search failed
"""
query = args.get("query")
limit = args.get("limit", 5)
# Validation
if not query:
return {"error": "No query provided"}
if not TRILIUM_TOKEN:
return {
"error": "TRILIUM_ETAPI_TOKEN not configured in environment",
"hint": "Set TRILIUM_ETAPI_TOKEN in .env file"
}
# Cap limit
limit = min(max(limit, 1), 20)
try:
session = get_session()
async with session.get(
f"{TRILIUM_URL}/etapi/notes",
params={"search": query, "limit": limit},
headers={"Authorization": TRILIUM_TOKEN}
) as resp:
if resp.status == 200:
data = await resp.json()
# ETAPI returns {"results": [...]} format
results = data.get("results", [])
return {
"notes": results,
"count": len(results)
}
elif resp.status == 401:
return {
"error": "Authentication failed. Check TRILIUM_ETAPI_TOKEN",
"status": 401
}
elif resp.status == 404:
return {
"error": "Trilium API endpoint not found. Check TRILIUM_URL",
"status": 404,
"url": TRILIUM_URL
}
else:
error_text = await resp.text()
return {
"error": f"HTTP {resp.status}: {error_text}",
"status": resp.status
}
except aiohttp.ClientConnectorError as e:
return {
"error": f"Cannot connect to Trilium at {TRILIUM_URL}",
"hint": "Check if Trilium is running and URL is correct",
"details": str(e)
}
except asyncio.TimeoutError:
timeout = os.getenv("TRILIUM_TIMEOUT", "30.0")
return {
"error": f"Trilium request timeout after {timeout}s",
"hint": "Trilium may be slow or unresponsive"
}
except Exception as e:
return {
"error": f"Search failed: {str(e)}",
"type": type(e).__name__
}
@async_retry(
max_attempts=3,
exceptions=(aiohttp.ClientError, asyncio.TimeoutError)
)
async def create_note(args: Dict) -> Dict:
"""Create a note in Trilium via ETAPI with retry logic.
Args:
args: Dictionary containing:
- title (str): Note title
- content (str): Note content in markdown or HTML
- parent_note_id (str, optional): Parent note ID to nest under
Returns:
dict: Creation result containing:
- noteId (str): ID of created note
- title (str): Title of created note
- success (bool): True if created successfully
OR
- error (str): Error message if creation failed
"""
title = args.get("title")
content = args.get("content")
parent_note_id = args.get("parent_note_id", "root") # Default to root if not specified
# Validation
if not title:
return {"error": "No title provided"}
if not content:
return {"error": "No content provided"}
if not TRILIUM_TOKEN:
return {
"error": "TRILIUM_ETAPI_TOKEN not configured in environment",
"hint": "Set TRILIUM_ETAPI_TOKEN in .env file"
}
# Prepare payload
payload = {
"parentNoteId": parent_note_id, # Always include parentNoteId
"title": title,
"content": content,
"type": "text",
"mime": "text/html"
}
try:
session = get_session()
async with session.post(
f"{TRILIUM_URL}/etapi/create-note",
json=payload,
headers={"Authorization": TRILIUM_TOKEN}
) as resp:
if resp.status in [200, 201]:
data = await resp.json()
return {
"noteId": data.get("noteId"),
"title": title,
"success": True
}
elif resp.status == 401:
return {
"error": "Authentication failed. Check TRILIUM_ETAPI_TOKEN",
"status": 401
}
elif resp.status == 404:
return {
"error": "Trilium API endpoint not found. Check TRILIUM_URL",
"status": 404,
"url": TRILIUM_URL
}
else:
error_text = await resp.text()
return {
"error": f"HTTP {resp.status}: {error_text}",
"status": resp.status
}
except aiohttp.ClientConnectorError as e:
return {
"error": f"Cannot connect to Trilium at {TRILIUM_URL}",
"hint": "Check if Trilium is running and URL is correct",
"details": str(e)
}
except asyncio.TimeoutError:
timeout = os.getenv("TRILIUM_TIMEOUT", "30.0")
return {
"error": f"Trilium request timeout after {timeout}s",
"hint": "Trilium may be slow or unresponsive"
}
except Exception as e:
return {
"error": f"Note creation failed: {str(e)}",
"type": type(e).__name__
}