"""Per-task persistence: project-memory (HTTP) and Obsidian vault (git).""" from __future__ import annotations import asyncio import json import logging import os import time from datetime import date import httpx from slugify import slugify from app.config import settings logger = logging.getLogger(__name__) MCP_ACCEPT = "application/json, text/event-stream" def _parse_mcp_response(resp: httpx.Response) -> dict: """Parse JSON or SSE body from streamable HTTP MCP responses.""" content_type = resp.headers.get("content-type", "") text = resp.text.strip() if "text/event-stream" in content_type or text.startswith("event:") or text.startswith("data:"): for line in text.splitlines(): if line.startswith("data:"): payload = line[5:].strip() if payload and payload != "[DONE]": return json.loads(payload) return {} if text: return resp.json() return {} class _MemoryMcpSession: """Reusable streamable-HTTP session for the project-memory MCP server. Performs the initialize handshake once and reuses the HTTP client + Mcp-Session-Id across calls, re-initializing only when the session is rejected (e.g. server restart / expired session). """ def __init__(self) -> None: self._client: httpx.AsyncClient | None = None self._session_id: str | None = None self._lock = asyncio.Lock() self._req_id = 0 def _headers(self) -> dict[str, str]: headers = {"Content-Type": "application/json", "Accept": MCP_ACCEPT} if settings.memory_mcp_token: headers["Authorization"] = f"Bearer {settings.memory_mcp_token}" if self._session_id: headers["Mcp-Session-Id"] = self._session_id return headers async def _ensure_client(self) -> httpx.AsyncClient: if self._client is None: self._client = httpx.AsyncClient(timeout=30) return self._client async def _handshake(self) -> None: client = await self._ensure_client() self._session_id = None init_body = { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "agentic-os", "version": "0.1.0"}, }, } resp = await client.post(settings.memory_mcp_url, headers=self._headers(), json=init_body) resp.raise_for_status() self._session_id = resp.headers.get("mcp-session-id") or resp.headers.get("Mcp-Session-Id") await client.post( settings.memory_mcp_url, headers=self._headers(), json={"jsonrpc": "2.0", "method": "notifications/initialized"}, ) async def _call_once(self, tool_name: str, arguments: dict) -> dict | None: client = await self._ensure_client() self._req_id += 1 body = { "jsonrpc": "2.0", "id": self._req_id, "method": "tools/call", "params": {"name": tool_name, "arguments": arguments}, } resp = await client.post(settings.memory_mcp_url, headers=self._headers(), json=body) resp.raise_for_status() data = _parse_mcp_response(resp) if data.get("error"): raise RuntimeError(data["error"].get("message", str(data["error"]))) return data.get("result") async def call(self, tool_name: str, arguments: dict) -> dict | None: async with self._lock: if self._session_id is None: await self._handshake() try: return await self._call_once(tool_name, arguments) except (httpx.HTTPStatusError, RuntimeError) as exc: # Session may have expired; re-handshake once and retry. logger.info("memory MCP retry after error: %s", exc) await self._handshake() return await self._call_once(tool_name, arguments) _memory_session = _MemoryMcpSession() async def memory_mcp_call(tool_name: str, arguments: dict) -> dict | None: """Run one MCP tool call against project-memory over a reused HTTP session.""" if not settings.memory_mcp_url: return None return await _memory_session.call(tool_name, arguments) async def create_task_memory( title: str, content: str, memory_type: str = "fix", tags: list[str] | None = None, importance: int = 3, *, vessel_name: str | None = None, task_id: int | None = None, ) -> str | None: """Call memory_upsert on the project-memory MCP over HTTP JSON-RPC.""" tag_list = list(tags or ["agentic-os"]) if vessel_name: from slugify import slugify tag_list.append(f"vessel:{slugify(vessel_name, lowercase=True)}") if task_id is not None: tag_list.append(f"task:{task_id}") body_parts = [] if vessel_name: body_parts.append(f"Vessel: {vessel_name}") if task_id is not None: body_parts.append(f"Task ID: {task_id}") body_parts.append(content) full_content = "\n\n".join(body_parts) args = { "project_id": settings.memory_task_project_id, "memory_type": memory_type, "title": title, "content": full_content, "tags": tag_list, "importance": importance, } try: result = await memory_mcp_call("memory_upsert", args) if not isinstance(result, dict): return None structured = result.get("structuredContent") if isinstance(structured, dict): mid = structured.get("id") or structured.get("memory_id") if mid: return str(mid) for block in result.get("content", []): text = block.get("text") if isinstance(block, dict) else None if text and "id" in text: return text[:128] except Exception as exc: # noqa: BLE001 logger.warning("create_task_memory failed: %s", exc) return None def _vault_dir() -> str: return os.path.join(settings.runtime_dir, "obsidian-vault") async def _run(cmd: list[str], cwd: str | None = None) -> tuple[int, str]: proc = await asyncio.create_subprocess_exec( *cmd, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) out, _ = await proc.communicate() return proc.returncode or 0, out.decode(errors="replace") async def _repair_vault_git(vault: str) -> None: """Recover from stuck rebase/merge so commits can be pushed to origin/main.""" git_dir = os.path.join(vault, ".git") if not os.path.isdir(git_dir): return if os.path.isdir(os.path.join(git_dir, "rebase-merge")) or os.path.isdir( os.path.join(git_dir, "rebase-apply") ): logger.warning("Obsidian vault stuck in git rebase; attempting continue/abort") code, _ = await _run(["git", "rebase", "--continue"], cwd=vault) if code != 0: await _run(["git", "rebase", "--abort"], cwd=vault) if os.path.isfile(os.path.join(git_dir, "MERGE_HEAD")): logger.warning("Obsidian vault stuck in git merge; aborting") await _run(["git", "merge", "--abort"], cwd=vault) code, _ = await _run(["git", "checkout", "main"], cwd=vault) if code != 0: await _run(["git", "checkout", "-B", "main"], cwd=vault) async def _vault_pull(vault: str) -> None: await _repair_vault_git(vault) code, out = await _run(["git", "pull", "--ff-only", "origin", "main"], cwd=vault) if code != 0: logger.warning("vault ff-only pull failed: %s", out[:300]) await _repair_vault_git(vault) await _run(["git", "pull", "--no-rebase", "origin", "main"], cwd=vault) async def _vault_commit_and_push(vault: str, rel_path: str, title: str) -> tuple[bool, str | None]: """Stage, commit, and push one note. Returns (pushed, error_message).""" if not settings.obsidian_auto_push or not settings.gitea_token: return False, None await _repair_vault_git(vault) await _run(["git", "add", rel_path], cwd=vault) code, out = await _run( [ "git", "-c", "user.email=agentic-os@paraskeva.net", "-c", "user.name=Agentic OS", "commit", "-m", f"agentic-os task: {title}", ], cwd=vault, ) if code != 0 and "nothing to commit" not in out.lower(): return False, out.strip()[:500] await _repair_vault_git(vault) code, out = await _run(["git", "push", "origin", "main"], cwd=vault) if code != 0: logger.warning("vault push failed: %s", out) return False, out.strip()[:500] return True, None _VAULT_PULL_TTL = 300.0 # seconds between background `git pull` of the vault _vault_pull_lock = asyncio.Lock() _vault_last_pull = 0.0 async def ensure_vault(*, force_pull: bool = False) -> str | None: global _vault_last_pull vault = _vault_dir() clone_url = settings.gitea_clone_url(settings.obsidian_vault_repo) if os.path.isdir(os.path.join(vault, ".git")): # Throttle pulls: skip if pulled recently (deduped across concurrent tasks). now = time.monotonic() if force_pull or now - _vault_last_pull >= _VAULT_PULL_TTL: async with _vault_pull_lock: if force_pull or time.monotonic() - _vault_last_pull >= _VAULT_PULL_TTL: await _vault_pull(vault) _vault_last_pull = time.monotonic() return vault os.makedirs(settings.runtime_dir, exist_ok=True) code, out = await _run(["git", "clone", clone_url, vault]) if code != 0: logger.warning("vault clone failed: %s", out) return None _vault_last_pull = time.monotonic() return vault async def write_obsidian_note( title: str, issue: str, steps: list[str] | str, cause: str | None, resolution: str | None, resolved: bool, *, vessel_name: str | None = None, task_id: int | None = None, diagnostics_md: str | None = None, ) -> tuple[str | None, str | None]: """Write task note to vault. Returns (relative_path, push_error).""" vault = await ensure_vault() if not vault: return None, "Obsidian vault unavailable (clone failed)" folder = settings.obsidian_tasks_folder os.makedirs(os.path.join(vault, folder), exist_ok=True) slug = slugify(title)[:60] or "task" filename = f"{date.today().isoformat()}-{slug}.md" rel_path = f"{folder}/{filename}" abs_path = os.path.join(vault, rel_path) steps_md = ( "\n".join(f"- {s}" for s in steps) if isinstance(steps, list) else str(steps or "") ) status_label = "Resolved" if resolved else "Unresolved" vessel_line = f"vessel: {vessel_name}\n" if vessel_name else "" task_line = f"task_id: {task_id}\n" if task_id is not None else "" diag_section = "" if diagnostics_md: diag_section = f"\n## Diagnostic findings\n{diagnostics_md}\n" body = f"""--- tags: [agentic-os, troubleshooting] {vessel_line}{task_line}status: {status_label.lower()} created: {date.today().isoformat()} --- # {title} ## Vessel {vessel_name or "_Not specified._"} ## Issue {issue} ## Troubleshooting steps {steps_md} ## Root cause {cause or "_Not determined._"} ## Resolution ({status_label}) {resolution or "_No resolution recorded._"} {diag_section}""" def _write() -> None: with open(abs_path, "w", encoding="utf-8") as fh: fh.write(body) await asyncio.to_thread(_write) pushed, push_error = await _vault_commit_and_push(vault, rel_path, title) if push_error: logger.warning("Obsidian note written locally at %s but git push failed: %s", rel_path, push_error) elif not pushed and settings.obsidian_auto_push: push_error = "Note saved in vault clone only (auto-push disabled or no Gitea token)" return rel_path, push_error