diff --git a/src/ai_memory_protocol/cli.py b/src/ai_memory_protocol/cli.py index c7ccbd2..3b06471 100644 --- a/src/ai_memory_protocol/cli.py +++ b/src/ai_memory_protocol/cli.py @@ -21,7 +21,6 @@ import argparse import json -import subprocess import sys import textwrap from datetime import date @@ -29,7 +28,15 @@ from . import __version__ from .config import TYPE_FILES -from .engine import expand_graph, find_workspace, load_needs, resolve_id, tag_match, text_match +from .engine import ( + expand_graph, + find_workspace, + load_needs, + resolve_id, + run_rebuild, + tag_match, + text_match, +) from .formatter import format_brief, format_compact, format_context_pack, format_full from .rst import ( add_tags_in_rst, @@ -327,29 +334,11 @@ def cmd_stale(args: argparse.Namespace) -> None: def cmd_rebuild(args: argparse.Namespace) -> None: """Rebuild needs.json by running Sphinx build.""" workspace = find_workspace(args.dir) - venv_sphinx = workspace / ".venv" / "bin" / "sphinx-build" - sphinx_cmd = str(venv_sphinx) if venv_sphinx.exists() else "sphinx-build" - cmd = [sphinx_cmd, "-b", "html", "-q", str(workspace), str(workspace / "_build" / "html")] - print(f"Building: {' '.join(cmd)}") - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - print(f"Build failed:\n{result.stderr}", file=sys.stderr) + success, message = run_rebuild(workspace) + print(message) + if not success: sys.exit(1) - from .engine import find_needs_json - - print(f"needs.json updated at {find_needs_json(workspace)}") - - needs = load_needs(workspace) - by_type: dict[str, int] = {} - by_status: dict[str, int] = {} - for n in needs.values(): - by_type[n.get("type", "?")] = by_type.get(n.get("type", "?"), 0) + 1 - by_status[n.get("status", "?")] = by_status.get(n.get("status", "?"), 0) + 1 - print(f"Total: {len(needs)} memories") - print(f" Types: {', '.join(f'{k}={v}' for k, v in sorted(by_type.items()))}") - print(f" Statuses: {', '.join(f'{k}={v}' for k, v in sorted(by_status.items()))}") - # --------------------------------------------------------------------------- # Helpers diff --git a/src/ai_memory_protocol/engine.py b/src/ai_memory_protocol/engine.py index 3da772a..9a60b53 100644 --- a/src/ai_memory_protocol/engine.py +++ b/src/ai_memory_protocol/engine.py @@ -4,6 +4,8 @@ import json import os +import shutil +import subprocess import sys from pathlib import Path from typing import Any @@ -167,3 +169,83 @@ def expand_graph( frontier = next_frontier return {nid: needs[nid] for nid in collected if nid in needs} + + +# --------------------------------------------------------------------------- +# Sphinx-build discovery and rebuild +# --------------------------------------------------------------------------- + + +def find_sphinx_build(workspace: Path) -> str: + """Locate the sphinx-build executable. + + Search order: + 1. ``workspace/.venv/bin/sphinx-build`` + 2. Walk parent directories for ``.venv/bin/sphinx-build`` + 3. ``shutil.which("sphinx-build")`` (system PATH) + + Returns the path string, or raises ``FileNotFoundError``. + """ + # 1. Workspace venv + candidate = workspace / ".venv" / "bin" / "sphinx-build" + if candidate.exists(): + return str(candidate) + + # 2. Walk parent directories + for parent in workspace.parents: + candidate = parent / ".venv" / "bin" / "sphinx-build" + if candidate.exists(): + return str(candidate) + # Also check sibling directories (e.g., ../ros2_medkit/.venv/) + if parent.is_dir(): + for sibling in parent.iterdir(): + if sibling.is_dir() and sibling != workspace: + candidate = sibling / ".venv" / "bin" / "sphinx-build" + if candidate.exists(): + return str(candidate) + break # Only check immediate parent's siblings + + # 3. System PATH + system = shutil.which("sphinx-build") + if system: + return system + + raise FileNotFoundError( + "sphinx-build not found. Install it with: pip install sphinx sphinx-needs\n" + "Or create a venv in your memory workspace: memory init --install " + ) + + +def run_rebuild(workspace: Path) -> tuple[bool, str]: + """Run Sphinx build to regenerate needs.json. + + Returns ``(success, message)`` — never raises on build failure. + """ + try: + sphinx_cmd = find_sphinx_build(workspace) + except FileNotFoundError as e: + return False, f"Rebuild skipped: {e}" + + cmd = [sphinx_cmd, "-b", "html", "-q", str(workspace), str(workspace / "_build" / "html")] + try: + result = subprocess.run(cmd, capture_output=True, text=True) + except OSError as e: + return False, f"Rebuild failed: {e}" + + if result.returncode != 0: + return False, f"Rebuild failed:\n{result.stderr}" + + needs = load_needs(workspace) + by_type: dict[str, int] = {} + by_status: dict[str, int] = {} + for n in needs.values(): + by_type[n.get("type", "?")] = by_type.get(n.get("type", "?"), 0) + 1 + by_status[n.get("status", "?")] = by_status.get(n.get("status", "?"), 0) + 1 + + lines = [ + f"needs.json updated at {find_needs_json(workspace)}", + f"Total: {len(needs)} memories", + f" Types: {', '.join(f'{k}={v}' for k, v in sorted(by_type.items()))}", + f" Statuses: {', '.join(f'{k}={v}' for k, v in sorted(by_status.items()))}", + ] + return True, "\n".join(lines) diff --git a/src/ai_memory_protocol/mcp_server.py b/src/ai_memory_protocol/mcp_server.py index fbcfc1f..22e497b 100644 --- a/src/ai_memory_protocol/mcp_server.py +++ b/src/ai_memory_protocol/mcp_server.py @@ -15,7 +15,6 @@ import json import logging -import subprocess from datetime import date from pathlib import Path from typing import Any @@ -23,7 +22,15 @@ from mcp.server import Server from mcp.types import TextContent, Tool -from .engine import expand_graph, find_workspace, load_needs, resolve_id, tag_match, text_match +from .engine import ( + expand_graph, + find_workspace, + load_needs, + resolve_id, + run_rebuild, + tag_match, + text_match, +) from .formatter import format_brief, format_compact, format_context_pack, format_full from .rst import ( add_tags_in_rst, @@ -385,18 +392,8 @@ def _format_output( def _do_rebuild(workspace: Path) -> str: """Run Sphinx build to regenerate needs.json.""" - venv_sphinx = workspace / ".venv" / "bin" / "sphinx-build" - sphinx_cmd = str(venv_sphinx) if venv_sphinx.exists() else "sphinx-build" - cmd = [sphinx_cmd, "-b", "html", "-q", str(workspace), str(workspace / "_build" / "html")] - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - return f"Rebuild failed: {result.stderr}" - - needs = load_needs(workspace) - by_type: dict[str, int] = {} - for n in needs.values(): - by_type[n.get("type", "?")] = by_type.get(n.get("type", "?"), 0) + 1 - return f"Rebuilt successfully. {len(needs)} memories: {', '.join(f'{k}={v}' for k, v in sorted(by_type.items()))}" + success, message = run_rebuild(workspace) + return message def _text_response(text: str) -> list[TextContent]: @@ -534,8 +531,12 @@ def _handle_add(args: dict[str, Any]) -> list[TextContent]: result_lines = [f"Added {nid} → {target.name}"] if args.get("rebuild", True): - rebuild_result = _do_rebuild(workspace) - result_lines.append(rebuild_result) + success, rebuild_msg = run_rebuild(workspace) + if success: + result_lines.append(rebuild_msg) + else: + result_lines.append(f"Warning: Memory was added but rebuild failed: {rebuild_msg}") + result_lines.append("Run memory_rebuild manually when sphinx-build is available.") return _text_response("\n".join(result_lines))