mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
Odysseus v1.0
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
_common.py
|
||||
|
||||
Shared constants and helpers for built-in MCP servers.
|
||||
"""
|
||||
|
||||
MAX_OUTPUT_CHARS = 10_000
|
||||
MAX_READ_CHARS = 20_000
|
||||
SHELL_TIMEOUT = 60
|
||||
PYTHON_TIMEOUT = 30
|
||||
SEARCH_TIMEOUT = 30
|
||||
|
||||
|
||||
def truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
|
||||
"""Truncate text to *limit* characters with a suffix note."""
|
||||
if len(text) > limit:
|
||||
return text[:limit] + f"\n... (truncated, {len(text)} chars total)"
|
||||
return text
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
image_gen_server.py
|
||||
|
||||
MCP server exposing image generation via OpenAI-compatible APIs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
server = Server("image_gen")
|
||||
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
return [
|
||||
Tool(
|
||||
name="generate_image",
|
||||
description="Generate an image using an image-capable model (e.g. gpt-image-1)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "Image description prompt"},
|
||||
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
|
||||
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
|
||||
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
if name != "generate_image":
|
||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
|
||||
prompt = arguments.get("prompt", "")
|
||||
model_spec = arguments.get("model", "")
|
||||
size = arguments.get("size", "1024x1024")
|
||||
quality = arguments.get("quality", "medium")
|
||||
|
||||
if not prompt:
|
||||
return [TextContent(type="text", text="Error: Image prompt is required")]
|
||||
|
||||
try:
|
||||
import httpx
|
||||
from src.settings import load_settings, get_setting
|
||||
from src.ai_interaction import _resolve_model
|
||||
|
||||
if not get_setting("image_gen_enabled", True):
|
||||
return [TextContent(type="text", text="Error: Image generation is disabled by the administrator.")]
|
||||
|
||||
_settings = load_settings()
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
|
||||
# Auto-detect best available image model
|
||||
if not model_spec:
|
||||
for candidate in ("gpt-image-1.5", "gpt-image-1", "dall-e-3"):
|
||||
try:
|
||||
_resolve_model(candidate)
|
||||
model_spec = candidate
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if not model_spec:
|
||||
return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")]
|
||||
|
||||
url, model_id, headers = _resolve_model(model_spec)
|
||||
|
||||
is_gpt_image = "gpt-image" in model_id.lower()
|
||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||
images_url = base_url + "/images/generations"
|
||||
|
||||
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
||||
valid_dalle3_sizes = {"1024x1024", "1024x1792", "1792x1024"}
|
||||
if is_gpt_image and size not in valid_gpt_sizes:
|
||||
size = "1024x1024"
|
||||
elif not is_gpt_image and size not in valid_dalle3_sizes:
|
||||
size = "1024x1024"
|
||||
|
||||
payload = {"model": model_id, "prompt": prompt, "n": 1, "size": size}
|
||||
if is_gpt_image:
|
||||
payload["quality"] = quality if quality in ("low", "medium", "high", "auto") else "medium"
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
|
||||
resp = await client.post(images_url, json=payload, headers=headers)
|
||||
|
||||
if resp.status_code != 200:
|
||||
error_text = resp.text[:500]
|
||||
try:
|
||||
err_json = resp.json()
|
||||
error_text = err_json.get("error", {}).get("message", error_text) if isinstance(err_json.get("error"), dict) else str(err_json.get("error", error_text))
|
||||
except Exception:
|
||||
pass
|
||||
return [TextContent(type="text", text=f"Error: Image generation failed ({resp.status_code}): {error_text}")]
|
||||
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
if not images:
|
||||
return [TextContent(type="text", text="Error: No images returned from API")]
|
||||
|
||||
img = images[0]
|
||||
image_url = None
|
||||
|
||||
if img.get("b64_json"):
|
||||
img_dir = Path("data/generated_images")
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
img_path = img_dir / filename
|
||||
img_path.write_bytes(base64.b64decode(img["b64_json"]))
|
||||
image_url = f"/api/generated-image/{filename}"
|
||||
|
||||
# Save to gallery
|
||||
try:
|
||||
from src.database import SessionLocal, GalleryImage
|
||||
db = SessionLocal()
|
||||
db.add(GalleryImage(
|
||||
id=str(uuid.uuid4()),
|
||||
filename=filename,
|
||||
prompt=prompt,
|
||||
model=model_id,
|
||||
size=size,
|
||||
quality=payload.get("quality", "medium"),
|
||||
))
|
||||
db.commit()
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif img.get("url"):
|
||||
image_url = img["url"]
|
||||
else:
|
||||
return [TextContent(type="text", text="Error: Unexpected image API response format")]
|
||||
|
||||
result = f"Generated image for: {prompt[:100]}\nimage_url: {image_url}\nmodel: {model_id}\nsize: {size}"
|
||||
return [TextContent(type="text", text=result)]
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return [TextContent(type="text", text="Error: Image generation timed out (300s)")]
|
||||
except ValueError as e:
|
||||
return [TextContent(type="text", text=f"Error: {e}")]
|
||||
except Exception as e:
|
||||
return [TextContent(type="text", text=f"Error: {e}")]
|
||||
|
||||
|
||||
async def run():
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
memory_server.py
|
||||
|
||||
MCP server exposing memory management (list, add, edit, delete, search).
|
||||
Imports MemoryManager and MemoryVectorStore from the Odysseus codebase.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
server = Server("memory")
|
||||
|
||||
# Late-initialized managers (set during first tool call)
|
||||
_memory_manager = None
|
||||
_memory_vector = None
|
||||
_initialized = False
|
||||
|
||||
|
||||
def _ensure_init():
|
||||
"""Lazy-init memory managers on first use."""
|
||||
global _memory_manager, _memory_vector, _initialized
|
||||
if _initialized:
|
||||
return
|
||||
_initialized = True
|
||||
|
||||
from src.constants import DATA_DIR
|
||||
from src.memory import MemoryManager
|
||||
_memory_manager = MemoryManager(DATA_DIR)
|
||||
|
||||
try:
|
||||
from src.memory_vector import MemoryVectorStore
|
||||
_memory_vector = MemoryVectorStore(DATA_DIR)
|
||||
if not _memory_vector.healthy:
|
||||
_memory_vector = None
|
||||
except Exception:
|
||||
_memory_vector = None
|
||||
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
return [
|
||||
Tool(
|
||||
name="manage_memory",
|
||||
description="Manage the user's memory system: list, add, edit, delete, or search memories.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list", "add", "edit", "delete", "search"],
|
||||
"description": "The action to perform",
|
||||
},
|
||||
"text": {"type": "string", "description": "Memory text (add/edit) or search query (search)"},
|
||||
"memory_id": {"type": "string", "description": "Memory ID (edit/delete)"},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["fact", "event", "contact", "preference"],
|
||||
"description": "Memory category (add/list filter)",
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
if name != "manage_memory":
|
||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
|
||||
_ensure_init()
|
||||
if not _memory_manager:
|
||||
return [TextContent(type="text", text="Error: Memory manager not available")]
|
||||
|
||||
action = arguments.get("action", "")
|
||||
|
||||
if action == "list":
|
||||
category_filter = arguments.get("category", "")
|
||||
memories = _memory_manager.load()
|
||||
if category_filter:
|
||||
memories = [m for m in memories if m.get("category", "").lower() == category_filter.lower()]
|
||||
if not memories:
|
||||
msg = "No memories found"
|
||||
if category_filter:
|
||||
msg += f" in category '{category_filter}'"
|
||||
return [TextContent(type="text", text=msg + ".")]
|
||||
lines = [f"Found {len(memories)} memory entries:\n"]
|
||||
for m in memories[:100]:
|
||||
cat = m.get("category", "fact")
|
||||
mid = m.get("id", "?")[:8]
|
||||
text = m.get("text", "")
|
||||
if len(text) > 150:
|
||||
text = text[:150] + "..."
|
||||
lines.append(f"- [{cat}] `{mid}` — {text}")
|
||||
if len(memories) > 100:
|
||||
lines.append(f"... and {len(memories) - 100} more")
|
||||
return [TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
elif action == "add":
|
||||
text = arguments.get("text", "")
|
||||
category = arguments.get("category", "fact")
|
||||
if not text:
|
||||
return [TextContent(type="text", text="Error: Memory text cannot be empty")]
|
||||
entry = _memory_manager.add_entry(text, source="ai_agent", category=category)
|
||||
memories = _memory_manager.load_all()
|
||||
memories.append(entry)
|
||||
_memory_manager.save(memories)
|
||||
if _memory_vector and _memory_vector.healthy:
|
||||
try:
|
||||
_memory_vector.add(entry["id"], text)
|
||||
except Exception:
|
||||
pass
|
||||
return [TextContent(type="text", text=f"Memory added: [{category}] {text} (id: {entry['id'][:8]})")]
|
||||
|
||||
elif action == "edit":
|
||||
memory_id = arguments.get("memory_id", "")
|
||||
new_text = arguments.get("text", "")
|
||||
if not memory_id or not new_text:
|
||||
return [TextContent(type="text", text="Error: edit needs memory_id and text")]
|
||||
memories = _memory_manager.load_all()
|
||||
found = False
|
||||
full_id = None
|
||||
for m in memories:
|
||||
if m.get("id", "").startswith(memory_id):
|
||||
m["text"] = new_text
|
||||
m["timestamp"] = int(time.time())
|
||||
found = True
|
||||
full_id = m["id"]
|
||||
break
|
||||
if not found:
|
||||
return [TextContent(type="text", text=f"Error: Memory '{memory_id}' not found")]
|
||||
_memory_manager.save(memories)
|
||||
if _memory_vector and _memory_vector.healthy and full_id:
|
||||
try:
|
||||
_memory_vector.remove(full_id)
|
||||
_memory_vector.add(full_id, new_text)
|
||||
except Exception:
|
||||
pass
|
||||
return [TextContent(type="text", text=f"Memory updated: {new_text}")]
|
||||
|
||||
elif action == "delete":
|
||||
memory_id = arguments.get("memory_id", "")
|
||||
if not memory_id:
|
||||
return [TextContent(type="text", text="Error: delete needs memory_id")]
|
||||
memories = _memory_manager.load_all()
|
||||
full_id = None
|
||||
deleted_text = ""
|
||||
deleted_category = ""
|
||||
for m in memories:
|
||||
if m.get("id", "").startswith(memory_id):
|
||||
full_id = m["id"]
|
||||
deleted_text = m.get("text", "")
|
||||
deleted_category = m.get("category", "")
|
||||
break
|
||||
original_len = len(memories)
|
||||
memories = [m for m in memories if not m.get("id", "").startswith(memory_id)]
|
||||
if len(memories) == original_len:
|
||||
return [TextContent(type="text", text=f"Error: Memory '{memory_id}' not found")]
|
||||
_memory_manager.save(memories)
|
||||
if _memory_vector and _memory_vector.healthy and full_id:
|
||||
try:
|
||||
_memory_vector.remove(full_id)
|
||||
except Exception:
|
||||
pass
|
||||
cat = f"[{deleted_category}] " if deleted_category else ""
|
||||
snippet = deleted_text if len(deleted_text) <= 120 else deleted_text[:117] + "..."
|
||||
return [TextContent(type="text", text=f"Memory deleted: {cat}{snippet} (id: {memory_id})")]
|
||||
|
||||
elif action == "search":
|
||||
query = arguments.get("text", "")
|
||||
if not query:
|
||||
return [TextContent(type="text", text="Error: search needs text (query)")]
|
||||
memories = _memory_manager.load()
|
||||
if hasattr(_memory_manager, 'get_relevant_memories'):
|
||||
results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
|
||||
else:
|
||||
query_lower = query.lower()
|
||||
results = [m for m in memories if query_lower in m.get("text", "").lower()][:20]
|
||||
if not results:
|
||||
return [TextContent(type="text", text=f"No memories found matching '{query}'.")]
|
||||
lines = [f"Found {len(results)} matching memories:\n"]
|
||||
for m in results:
|
||||
cat = m.get("category", "fact")
|
||||
mid = m.get("id", "?")[:8]
|
||||
text = m.get("text", "")
|
||||
lines.append(f"- [{cat}] `{mid}` — {text}")
|
||||
return [TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
else:
|
||||
return [TextContent(type="text", text=f"Error: Unknown action '{action}'. Use: list, add, edit, delete, search")]
|
||||
|
||||
|
||||
async def run():
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
rag_server.py
|
||||
|
||||
MCP server exposing RAG document management (list, add_directory, remove_directory).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
server = Server("rag")
|
||||
|
||||
_rag_manager = None
|
||||
_personal_docs_manager = None
|
||||
_initialized = False
|
||||
|
||||
|
||||
def _ensure_init():
|
||||
"""Lazy-init RAG managers on first use."""
|
||||
global _rag_manager, _personal_docs_manager, _initialized
|
||||
if _initialized:
|
||||
return
|
||||
_initialized = True
|
||||
|
||||
try:
|
||||
from src.rag_singleton import get_rag_manager
|
||||
_rag_manager = get_rag_manager()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from src.constants import PERSONAL_DIR
|
||||
from src.personal_docs import PersonalDocsManager
|
||||
_personal_docs_manager = PersonalDocsManager(PERSONAL_DIR, _rag_manager)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
return [
|
||||
Tool(
|
||||
name="manage_rag",
|
||||
description="Manage RAG indexed documents. List indexed files, add directories, or remove directories.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list", "add_directory", "remove_directory"],
|
||||
"description": "The action to perform",
|
||||
},
|
||||
"directory": {"type": "string", "description": "Directory path (for add/remove)"},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
if name != "manage_rag":
|
||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
|
||||
_ensure_init()
|
||||
action = arguments.get("action", "")
|
||||
|
||||
if action == "list":
|
||||
if not _personal_docs_manager:
|
||||
return [TextContent(type="text", text="Personal docs manager not available. RAG may not be configured.")]
|
||||
try:
|
||||
files = getattr(_personal_docs_manager, 'index', None) or []
|
||||
dirs = []
|
||||
if hasattr(_personal_docs_manager, 'get_indexed_directories'):
|
||||
dirs = _personal_docs_manager.get_indexed_directories()
|
||||
|
||||
lines = []
|
||||
if dirs:
|
||||
lines.append(f"**Indexed directories ({len(dirs)}):**")
|
||||
for d in dirs:
|
||||
lines.append(f" - `{d}`")
|
||||
if files:
|
||||
lines.append(f"\n**Indexed files ({len(files)}):**")
|
||||
for f in files[:50]:
|
||||
fname = f.get("name", str(f)) if isinstance(f, dict) else str(f)
|
||||
lines.append(f" - {fname}")
|
||||
if len(files) > 50:
|
||||
lines.append(f" ... and {len(files) - 50} more")
|
||||
if not lines:
|
||||
return [TextContent(type="text", text="No files or directories indexed in RAG.")]
|
||||
return [TextContent(type="text", text="\n".join(lines))]
|
||||
except Exception as e:
|
||||
return [TextContent(type="text", text=f"Error: {e}")]
|
||||
|
||||
elif action == "add_directory":
|
||||
directory = arguments.get("directory", "").strip()
|
||||
if not directory:
|
||||
return [TextContent(type="text", text="Error: add_directory needs a directory path")]
|
||||
directory = os.path.expanduser(directory)
|
||||
if not os.path.isdir(directory):
|
||||
return [TextContent(type="text", text=f"Error: Directory not found: {directory}")]
|
||||
if not _rag_manager:
|
||||
return [TextContent(type="text", text="Error: RAG manager not available")]
|
||||
try:
|
||||
result = _rag_manager.index_personal_documents(directory)
|
||||
indexed = result.get("indexed_count", 0) if isinstance(result, dict) else 0
|
||||
return [TextContent(type="text", text=f"Directory '{directory}' added to RAG index ({indexed} chunks indexed)")]
|
||||
except Exception as e:
|
||||
return [TextContent(type="text", text=f"Error: Failed to index directory: {e}")]
|
||||
|
||||
elif action == "remove_directory":
|
||||
directory = arguments.get("directory", "").strip()
|
||||
if not directory:
|
||||
return [TextContent(type="text", text="Error: remove_directory needs a directory path")]
|
||||
if not _personal_docs_manager:
|
||||
return [TextContent(type="text", text="Error: Personal docs manager not available")]
|
||||
try:
|
||||
if hasattr(_personal_docs_manager, 'remove_directory'):
|
||||
_personal_docs_manager.remove_directory(directory)
|
||||
if _rag_manager and hasattr(_rag_manager, 'remove_directory'):
|
||||
_rag_manager.remove_directory(directory)
|
||||
return [TextContent(type="text", text=f"Directory '{directory}' removed from RAG index")]
|
||||
except Exception as e:
|
||||
return [TextContent(type="text", text=f"Error: Failed to remove directory: {e}")]
|
||||
|
||||
else:
|
||||
return [TextContent(type="text", text=f"Error: Unknown action '{action}'. Use: list, add_directory, remove_directory")]
|
||||
|
||||
|
||||
async def run():
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
Reference in New Issue
Block a user