"""Load prior knowledge from project-memory and Obsidian before agent triage.""" from __future__ import annotations import asyncio import json import logging import os import re from typing import Any from app.config import settings from app.services import integrations logger = logging.getLogger(__name__) _STOPWORDS = frozenset( """ a an the and or but in on at to for of is are was were be been being with from by as it this that these those i we you they he she check status health task issue vessel ship """.split() ) _MAX_MEMORY_HITS = 8 _MAX_OBSIDIAN_HITS = 6 _EXCERPT_CHARS = 400 def extract_search_keywords(text: str, *, max_terms: int = 8) -> list[str]: """Pull meaningful terms from title + issue for memory/Obsidian search.""" raw = re.findall(r"[a-zA-Z0-9][a-zA-Z0-9._/-]{1,}", text.lower()) terms: list[str] = [] seen: set[str] = set() for tok in raw: if tok in _STOPWORDS or len(tok) < 3: continue if tok.isdigit(): continue if tok not in seen: seen.add(tok) terms.append(tok) if len(terms) >= max_terms: break return terms def _parse_memory_tool_result(result: dict | None) -> list[dict]: """Normalize memory MCP tool results into a list of entry dicts.""" if not result: return [] structured = result.get("structuredContent") if isinstance(structured, dict): for key in ("memories", "results", "entries", "items"): val = structured.get(key) if isinstance(val, list): return [x for x in val if isinstance(x, dict)] if structured.get("title") or structured.get("content"): return [structured] for block in result.get("content", []): if not isinstance(block, dict): continue text = block.get("text") if not text: continue text = text.strip() try: parsed = json.loads(text) except json.JSONDecodeError: continue if isinstance(parsed, list): return [x for x in parsed if isinstance(x, dict)] if isinstance(parsed, dict): for key in ("memories", "results", "entries", "items"): val = parsed.get(key) if isinstance(val, list): return [x for x in val if isinstance(x, dict)] if parsed.get("title") or parsed.get("content"): return [parsed] return [] def _memory_vessel_from_hit(hit: dict) -> str | None: for tag in hit.get("tags") or []: if isinstance(tag, str) and tag.startswith("vessel:"): return tag.split(":", 1)[1].replace("-", " ") content = hit.get("content") or "" m = re.search(r"^Vessel:\s*(.+)$", content, re.MULTILINE) if m: return m.group(1).strip() title = hit.get("title") or "" m = re.search(r"^\[([^\]]+)\]", title) if m and m.group(1).lower() not in ("task", "fix"): return m.group(1).strip() return None def _obsidian_vessel_from_text(text: str) -> str | None: if not text.startswith("---"): return None end = text.find("\n---", 3) if end == -1: return None front = text[3:end] for line in front.splitlines(): if line.lower().startswith("vessel:"): return line.split(":", 1)[1].strip() return None def _memory_hit_from_entry(entry: dict, *, project_id: str) -> dict: hit = { "project_id": entry.get("project_id") or project_id, "title": (entry.get("title") or "").strip(), "content": (entry.get("content") or "")[:_EXCERPT_CHARS], "memory_type": entry.get("memory_type") or entry.get("type") or "note", "importance": entry.get("importance"), "tags": entry.get("tags") or [], "source": "memory", } return _enrich_hit_vessel(hit) def _enrich_hit_vessel(hit: dict, *, vessel_name: str | None = None) -> dict: vessel = hit.get("vessel") if not vessel and hit.get("source") == "memory": vessel = _memory_vessel_from_hit(hit) if not vessel and hit.get("source") == "obsidian": vessel = _obsidian_vessel_from_text(hit.get("excerpt") or "") if vessel: hit["vessel"] = vessel elif vessel_name: hit["vessel"] = vessel_name return hit async def _search_memory_projects(query: str, project_ids: list[str]) -> list[dict]: hits: list[dict] = [] seen_titles: set[str] = set() for project_id in project_ids: try: result = await integrations.memory_mcp_call( "memory_search", {"project_id": project_id, "query": query, "limit": 5}, ) for entry in _parse_memory_tool_result(result): hit = _memory_hit_from_entry(entry, project_id=project_id) key = hit["title"].lower() if not hit["title"] or key in seen_titles: continue seen_titles.add(key) hits.append(hit) except Exception as exc: # noqa: BLE001 logger.warning("memory_search %s failed: %s", project_id, exc) return hits[:_MAX_MEMORY_HITS] async def _recent_memory(project_ids: list[str]) -> list[dict]: hits: list[dict] = [] for project_id in project_ids[:2]: try: result = await integrations.memory_mcp_call( "memory_list_recent", {"project_id": project_id, "limit": 10}, ) for entry in _parse_memory_tool_result(result): hits.append(_memory_hit_from_entry(entry, project_id=project_id)) except Exception as exc: # noqa: BLE001 logger.warning("memory_list_recent %s failed: %s", project_id, exc) return hits def _obsidian_search_roots(vault: str) -> list[str]: roots: list[str] = [] for rel in settings.obsidian_search_paths.split(","): rel = rel.strip() if not rel: continue path = os.path.join(vault, rel) if os.path.isdir(path): roots.append(path) tasks = os.path.join(vault, settings.obsidian_tasks_folder) if os.path.isdir(tasks) and tasks not in roots: roots.insert(0, tasks) return roots def _score_obsidian_file( path: str, text: str, keywords: list[str], *, vessel_name: str | None = None ) -> int: name = os.path.basename(path).lower() body = text.lower() score = 0 for kw in keywords: if kw in name: score += 3 if kw in body: score += 1 if vessel_name: vslug = vessel_name.lower().replace(" ", "-") note_vessel = _obsidian_vessel_from_text(text) if note_vessel and note_vessel.lower() == vessel_name.lower(): score += 5 elif vslug in name or vessel_name.lower() in body: score += 2 return score def _obsidian_title(text: str, filename: str) -> str: for line in text.splitlines(): if line.startswith("# "): return line[2:].strip() return os.path.splitext(filename)[0].replace("-", " ") async def search_obsidian_notes( title: str, issue: str, *, vessel_name: str | None = None ) -> list[dict]: """Search cloned Obsidian vault for notes related to this issue.""" vault = await integrations.ensure_vault() if not vault: return [] keywords = extract_search_keywords(f"{title} {issue}") if vessel_name: keywords = keywords + [vessel_name.lower().replace(" ", "-"), vessel_name.lower()] if not keywords: return [] # The vault scan is synchronous disk I/O — run it off the event loop. return await asyncio.to_thread(_scan_obsidian_notes, vault, keywords, vessel_name) def _scan_obsidian_notes( vault: str, keywords: list[str], vessel_name: str | None ) -> list[dict]: scored: list[tuple[int, dict]] = [] for root in _obsidian_search_roots(vault): for dirpath, _, files in os.walk(root): for filename in files: if not filename.endswith(".md"): continue abs_path = os.path.join(dirpath, filename) try: with open(abs_path, encoding="utf-8", errors="replace") as fh: text = fh.read() except OSError: continue score = _score_obsidian_file(abs_path, text, keywords, vessel_name=vessel_name) if score <= 0: continue rel = os.path.relpath(abs_path, vault) excerpt = text[:_EXCERPT_CHARS].strip() hit = { "path": rel, "title": _obsidian_title(text, filename), "excerpt": excerpt, "score": score, "source": "obsidian", } scored.append((score, _enrich_hit_vessel(hit, vessel_name=vessel_name))) scored.sort(key=lambda x: (-x[0], x[1]["path"])) return [hit for _, hit in scored[:_MAX_OBSIDIAN_HITS]] async def gather_investigation_context( title: str, issue: str, *, vessel_name: str | None = None, vessel_id: int | None = None, ) -> dict[str, Any]: """Search memory MCP + Obsidian vault for prior related investigations.""" keywords = extract_search_keywords(f"{title} {issue}") query_parts = list(keywords) if vessel_name: query_parts.insert(0, vessel_name) query = " ".join(query_parts) if query_parts else f"{title} {issue}"[:200] project_ids = [ p.strip() for p in settings.memory_search_projects.split(",") if p.strip() ] if settings.memory_task_project_id not in project_ids: project_ids.insert(0, settings.memory_task_project_id) memory_hits = await _search_memory_projects(query, project_ids) # If keyword search is sparse, add recent task memories for continuity. if len(memory_hits) < 3: recent = await _recent_memory(project_ids) seen = {h["title"].lower() for h in memory_hits if h.get("title")} for hit in recent: t = hit.get("title", "").lower() if t and t not in seen: memory_hits.append(hit) seen.add(t) if len(memory_hits) >= _MAX_MEMORY_HITS: break obsidian_hits = await search_obsidian_notes(title, issue, vessel_name=vessel_name) return { "query": query, "keywords": keywords, "vessel_name": vessel_name, "vessel_id": vessel_id, "memory_hits": memory_hits[:_MAX_MEMORY_HITS], "obsidian_hits": obsidian_hits, "memory_count": len(memory_hits), "obsidian_count": len(obsidian_hits), }