bfaeb0fc9a
Implements the ByteRover integration (from PR #3499 by hieuntg81) as a MemoryProvider plugin instead of direct run_agent.py modifications. ByteRover provides persistent memory via the brv CLI — a hierarchical knowledge tree with tiered retrieval (fuzzy text then LLM-driven search). Local-first with optional cloud sync. Plugin capabilities: - prefetch: background brv query for relevant context - sync_turn: curate conversation turns (threaded, non-blocking) - on_memory_write: mirror built-in memory writes to brv - on_pre_compress: extract insights before context compression Tools (3): - brv_query: search the knowledge tree - brv_curate: store facts/decisions/patterns - brv_status: check CLI version and context tree state Profile isolation: working directory at $HERMES_HOME/byterover/ (scoped per profile). Binary resolution cached with thread-safe double-checked locking. All write operations threaded to avoid blocking the agent (curate can take 120s with LLM processing).
398 lines
14 KiB
Python
398 lines
14 KiB
Python
"""ByteRover memory plugin — MemoryProvider interface.
|
|
|
|
Persistent memory via the ByteRover CLI (``brv``). Organizes knowledge into
|
|
a hierarchical context tree with tiered retrieval (fuzzy text → LLM-driven
|
|
search). Local-first with optional cloud sync.
|
|
|
|
Original PR #3499 by hieuntg81, adapted to MemoryProvider ABC.
|
|
|
|
Requires: ``brv`` CLI installed (npm install -g byterover-cli or
|
|
curl -fsSL https://byterover.dev/install.sh | sh).
|
|
|
|
Config via environment variables (profile-scoped via each profile's .env):
|
|
BRV_API_KEY — ByteRover API key (for cloud features, optional for local)
|
|
|
|
Working directory: $HERMES_HOME/byterover/ (profile-scoped context tree)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from agent.memory_provider import MemoryProvider
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Timeouts
|
|
_QUERY_TIMEOUT = 30 # brv query — should be fast
|
|
_CURATE_TIMEOUT = 120 # brv curate — may involve LLM processing
|
|
|
|
# Minimum lengths to filter noise
|
|
_MIN_QUERY_LEN = 10
|
|
_MIN_OUTPUT_LEN = 20
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# brv binary resolution (cached, thread-safe)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_brv_path_lock = threading.Lock()
|
|
_cached_brv_path: Optional[str] = None
|
|
|
|
|
|
def _resolve_brv_path() -> Optional[str]:
|
|
"""Find the brv binary on PATH or well-known install locations."""
|
|
global _cached_brv_path
|
|
with _brv_path_lock:
|
|
if _cached_brv_path is not None:
|
|
return _cached_brv_path if _cached_brv_path != "" else None
|
|
|
|
found = shutil.which("brv")
|
|
if not found:
|
|
home = Path.home()
|
|
candidates = [
|
|
home / ".brv-cli" / "bin" / "brv",
|
|
Path("/usr/local/bin/brv"),
|
|
home / ".npm-global" / "bin" / "brv",
|
|
]
|
|
for c in candidates:
|
|
if c.exists():
|
|
found = str(c)
|
|
break
|
|
|
|
with _brv_path_lock:
|
|
if _cached_brv_path is not None:
|
|
return _cached_brv_path if _cached_brv_path != "" else None
|
|
_cached_brv_path = found or ""
|
|
return found
|
|
|
|
|
|
def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT,
|
|
cwd: str = None) -> dict:
|
|
"""Run a brv CLI command. Returns {success, output, error}."""
|
|
brv_path = _resolve_brv_path()
|
|
if not brv_path:
|
|
return {"success": False, "error": "brv CLI not found. Install: npm install -g byterover-cli"}
|
|
|
|
cmd = [brv_path] + args
|
|
effective_cwd = cwd or str(_get_brv_cwd())
|
|
Path(effective_cwd).mkdir(parents=True, exist_ok=True)
|
|
|
|
env = os.environ.copy()
|
|
brv_bin_dir = str(Path(brv_path).parent)
|
|
env["PATH"] = brv_bin_dir + os.pathsep + env.get("PATH", "")
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True,
|
|
timeout=timeout, cwd=effective_cwd, env=env,
|
|
)
|
|
stdout = result.stdout.strip()
|
|
stderr = result.stderr.strip()
|
|
|
|
if result.returncode == 0:
|
|
return {"success": True, "output": stdout}
|
|
return {"success": False, "error": stderr or stdout or f"brv exited {result.returncode}"}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {"success": False, "error": f"brv timed out after {timeout}s"}
|
|
except FileNotFoundError:
|
|
global _cached_brv_path
|
|
with _brv_path_lock:
|
|
_cached_brv_path = None
|
|
return {"success": False, "error": "brv CLI not found"}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def _get_brv_cwd() -> Path:
|
|
"""Profile-scoped working directory for the brv context tree."""
|
|
from hermes_constants import get_hermes_home
|
|
return get_hermes_home() / "byterover"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
QUERY_SCHEMA = {
|
|
"name": "brv_query",
|
|
"description": (
|
|
"Search ByteRover's persistent knowledge tree for relevant context. "
|
|
"Returns memories, project knowledge, architectural decisions, and "
|
|
"patterns from previous sessions. Use for any question where past "
|
|
"context would help."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "description": "What to search for."},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
}
|
|
|
|
CURATE_SCHEMA = {
|
|
"name": "brv_curate",
|
|
"description": (
|
|
"Store important information in ByteRover's persistent knowledge tree. "
|
|
"Use for architectural decisions, bug fixes, user preferences, project "
|
|
"patterns — anything worth remembering across sessions. ByteRover's LLM "
|
|
"automatically categorizes and organizes the memory."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"content": {"type": "string", "description": "The information to remember."},
|
|
},
|
|
"required": ["content"],
|
|
},
|
|
}
|
|
|
|
STATUS_SCHEMA = {
|
|
"name": "brv_status",
|
|
"description": "Check ByteRover status — CLI version, context tree stats, cloud sync state.",
|
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MemoryProvider implementation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ByteRoverMemoryProvider(MemoryProvider):
|
|
"""ByteRover persistent memory via the brv CLI."""
|
|
|
|
def __init__(self):
|
|
self._cwd = ""
|
|
self._session_id = ""
|
|
self._turn_count = 0
|
|
self._prefetch_result = ""
|
|
self._prefetch_lock = threading.Lock()
|
|
self._prefetch_thread: Optional[threading.Thread] = None
|
|
self._sync_thread: Optional[threading.Thread] = None
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "byterover"
|
|
|
|
def is_available(self) -> bool:
|
|
"""Check if brv CLI is installed. No network calls."""
|
|
return _resolve_brv_path() is not None
|
|
|
|
def get_config_schema(self):
|
|
return [
|
|
{
|
|
"key": "api_key",
|
|
"description": "ByteRover API key (optional, for cloud sync)",
|
|
"secret": True,
|
|
"env_var": "BRV_API_KEY",
|
|
"url": "https://app.byterover.dev",
|
|
},
|
|
]
|
|
|
|
def initialize(self, session_id: str, **kwargs) -> None:
|
|
self._cwd = str(_get_brv_cwd())
|
|
self._session_id = session_id
|
|
self._turn_count = 0
|
|
Path(self._cwd).mkdir(parents=True, exist_ok=True)
|
|
|
|
def system_prompt_block(self) -> str:
|
|
if not _resolve_brv_path():
|
|
return ""
|
|
return (
|
|
"# ByteRover Memory\n"
|
|
"Active. Persistent knowledge tree with hierarchical context.\n"
|
|
"Use brv_query to search past knowledge, brv_curate to store "
|
|
"important facts, brv_status to check state."
|
|
)
|
|
|
|
def prefetch(self, query: str) -> str:
|
|
if self._prefetch_thread and self._prefetch_thread.is_alive():
|
|
self._prefetch_thread.join(timeout=3.0)
|
|
with self._prefetch_lock:
|
|
result = self._prefetch_result
|
|
self._prefetch_result = ""
|
|
if not result:
|
|
return ""
|
|
return f"## ByteRover Context\n{result}"
|
|
|
|
def queue_prefetch(self, query: str) -> None:
|
|
if not query or len(query.strip()) < _MIN_QUERY_LEN:
|
|
return
|
|
|
|
def _run():
|
|
try:
|
|
result = _run_brv(
|
|
["query", "--", query.strip()[:5000]],
|
|
timeout=_QUERY_TIMEOUT, cwd=self._cwd,
|
|
)
|
|
if result["success"] and result.get("output"):
|
|
output = result["output"].strip()
|
|
if len(output) > _MIN_OUTPUT_LEN:
|
|
with self._prefetch_lock:
|
|
self._prefetch_result = output
|
|
except Exception as e:
|
|
logger.debug("ByteRover prefetch failed: %s", e)
|
|
|
|
self._prefetch_thread = threading.Thread(
|
|
target=_run, daemon=True, name="brv-prefetch"
|
|
)
|
|
self._prefetch_thread.start()
|
|
|
|
def sync_turn(self, user_content: str, assistant_content: str) -> None:
|
|
"""Curate the conversation turn in background (non-blocking)."""
|
|
self._turn_count += 1
|
|
|
|
# Only curate substantive turns
|
|
if len(user_content.strip()) < _MIN_QUERY_LEN:
|
|
return
|
|
|
|
def _sync():
|
|
try:
|
|
combined = f"User: {user_content[:2000]}\nAssistant: {assistant_content[:2000]}"
|
|
_run_brv(
|
|
["curate", "--", combined],
|
|
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("ByteRover sync failed: %s", e)
|
|
|
|
# Wait for previous sync
|
|
if self._sync_thread and self._sync_thread.is_alive():
|
|
self._sync_thread.join(timeout=5.0)
|
|
|
|
self._sync_thread = threading.Thread(
|
|
target=_sync, daemon=True, name="brv-sync"
|
|
)
|
|
self._sync_thread.start()
|
|
|
|
def on_memory_write(self, action: str, target: str, content: str) -> None:
|
|
"""Mirror built-in memory writes to ByteRover."""
|
|
if action not in ("add", "replace") or not content:
|
|
return
|
|
|
|
def _write():
|
|
try:
|
|
label = "User profile" if target == "user" else "Agent memory"
|
|
_run_brv(
|
|
["curate", "--", f"[{label}] {content}"],
|
|
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("ByteRover memory mirror failed: %s", e)
|
|
|
|
t = threading.Thread(target=_write, daemon=True, name="brv-memwrite")
|
|
t.start()
|
|
|
|
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> None:
|
|
"""Extract insights before context compression discards turns."""
|
|
if not messages:
|
|
return
|
|
|
|
# Build a summary of messages about to be compressed
|
|
parts = []
|
|
for msg in messages[-10:]: # last 10 messages
|
|
role = msg.get("role", "")
|
|
content = msg.get("content", "")
|
|
if isinstance(content, str) and content.strip() and role in ("user", "assistant"):
|
|
parts.append(f"{role}: {content[:500]}")
|
|
|
|
if not parts:
|
|
return
|
|
|
|
combined = "\n".join(parts)
|
|
|
|
def _flush():
|
|
try:
|
|
_run_brv(
|
|
["curate", "--", f"[Pre-compression context]\n{combined}"],
|
|
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
|
|
)
|
|
logger.info("ByteRover pre-compression flush: %d messages", len(parts))
|
|
except Exception as e:
|
|
logger.debug("ByteRover pre-compression flush failed: %s", e)
|
|
|
|
t = threading.Thread(target=_flush, daemon=True, name="brv-flush")
|
|
t.start()
|
|
|
|
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
|
return [QUERY_SCHEMA, CURATE_SCHEMA, STATUS_SCHEMA]
|
|
|
|
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
|
if tool_name == "brv_query":
|
|
return self._tool_query(args)
|
|
elif tool_name == "brv_curate":
|
|
return self._tool_curate(args)
|
|
elif tool_name == "brv_status":
|
|
return self._tool_status()
|
|
return json.dumps({"error": f"Unknown tool: {tool_name}"})
|
|
|
|
def shutdown(self) -> None:
|
|
for t in (self._sync_thread, self._prefetch_thread):
|
|
if t and t.is_alive():
|
|
t.join(timeout=10.0)
|
|
|
|
# -- Tool implementations ------------------------------------------------
|
|
|
|
def _tool_query(self, args: dict) -> str:
|
|
query = args.get("query", "")
|
|
if not query:
|
|
return json.dumps({"error": "query is required"})
|
|
|
|
result = _run_brv(
|
|
["query", "--", query.strip()[:5000]],
|
|
timeout=_QUERY_TIMEOUT, cwd=self._cwd,
|
|
)
|
|
|
|
if not result["success"]:
|
|
return json.dumps({"error": result.get("error", "Query failed")})
|
|
|
|
output = result.get("output", "").strip()
|
|
if not output or len(output) < _MIN_OUTPUT_LEN:
|
|
return json.dumps({"result": "No relevant memories found."})
|
|
|
|
# Truncate very long results
|
|
if len(output) > 8000:
|
|
output = output[:8000] + "\n\n[... truncated]"
|
|
|
|
return json.dumps({"result": output})
|
|
|
|
def _tool_curate(self, args: dict) -> str:
|
|
content = args.get("content", "")
|
|
if not content:
|
|
return json.dumps({"error": "content is required"})
|
|
|
|
result = _run_brv(
|
|
["curate", "--", content],
|
|
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
|
|
)
|
|
|
|
if not result["success"]:
|
|
return json.dumps({"error": result.get("error", "Curate failed")})
|
|
|
|
return json.dumps({"result": "Memory curated successfully."})
|
|
|
|
def _tool_status(self) -> str:
|
|
result = _run_brv(["status"], timeout=15, cwd=self._cwd)
|
|
if not result["success"]:
|
|
return json.dumps({"error": result.get("error", "Status check failed")})
|
|
return json.dumps({"status": result.get("output", "")})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def register(ctx) -> None:
|
|
"""Register ByteRover as a memory provider plugin."""
|
|
ctx.register_memory_provider(ByteRoverMemoryProvider())
|