/ plugins / memory / byterover / __init__.py
__init__.py
  1  """ByteRover memory plugin — MemoryProvider interface.
  2  
  3  Persistent memory via the ByteRover CLI (``brv``). Organizes knowledge into
  4  a hierarchical context tree with tiered retrieval (fuzzy text → LLM-driven
  5  search). Local-first with optional cloud sync.
  6  
  7  Original PR #3499 by hieuntg81, adapted to MemoryProvider ABC.
  8  
  9  Requires: ``brv`` CLI installed (npm install -g byterover-cli or
 10  curl -fsSL https://byterover.dev/install.sh | sh).
 11  
 12  Config via environment variables (profile-scoped via each profile's .env):
 13    BRV_API_KEY   — ByteRover API key (for cloud features, optional for local)
 14  
 15  Working directory: $HERMES_HOME/byterover/ (profile-scoped context tree)
 16  """
 17  
 18  from __future__ import annotations
 19  
 20  import json
 21  import logging
 22  import os
 23  import shutil
 24  import subprocess
 25  import threading
 26  from pathlib import Path
 27  from typing import Any, Dict, List, Optional
 28  
 29  from agent.memory_provider import MemoryProvider
 30  from tools.registry import tool_error
 31  
 32  logger = logging.getLogger(__name__)
 33  
 34  # Timeouts
 35  _QUERY_TIMEOUT = 10   # brv query — should be fast
 36  _CURATE_TIMEOUT = 120  # brv curate — may involve LLM processing
 37  
 38  # Minimum lengths to filter noise
 39  _MIN_QUERY_LEN = 10
 40  _MIN_OUTPUT_LEN = 20
 41  
 42  
 43  # ---------------------------------------------------------------------------
 44  # brv binary resolution (cached, thread-safe)
 45  # ---------------------------------------------------------------------------
 46  
 47  _brv_path_lock = threading.Lock()
 48  _cached_brv_path: Optional[str] = None
 49  
 50  
 51  def _resolve_brv_path() -> Optional[str]:
 52      """Find the brv binary on PATH or well-known install locations."""
 53      global _cached_brv_path
 54      with _brv_path_lock:
 55          if _cached_brv_path is not None:
 56              return _cached_brv_path if _cached_brv_path != "" else None
 57  
 58      found = shutil.which("brv")
 59      if not found:
 60          home = Path.home()
 61          candidates = [
 62              home / ".brv-cli" / "bin" / "brv",
 63              Path("/usr/local/bin/brv"),
 64              home / ".npm-global" / "bin" / "brv",
 65          ]
 66          for c in candidates:
 67              if c.exists():
 68                  found = str(c)
 69                  break
 70  
 71      with _brv_path_lock:
 72          if _cached_brv_path is not None:
 73              return _cached_brv_path if _cached_brv_path != "" else None
 74          _cached_brv_path = found or ""
 75      return found
 76  
 77  
 78  def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT,
 79               cwd: str = None) -> dict:
 80      """Run a brv CLI command. Returns {success, output, error}."""
 81      brv_path = _resolve_brv_path()
 82      if not brv_path:
 83          return {"success": False, "error": "brv CLI not found. Install: npm install -g byterover-cli"}
 84  
 85      cmd = [brv_path] + args
 86      effective_cwd = cwd or str(_get_brv_cwd())
 87      Path(effective_cwd).mkdir(parents=True, exist_ok=True)
 88  
 89      env = os.environ.copy()
 90      brv_bin_dir = str(Path(brv_path).parent)
 91      env["PATH"] = brv_bin_dir + os.pathsep + env.get("PATH", "")
 92  
 93      try:
 94          result = subprocess.run(
 95              cmd, capture_output=True, text=True,
 96              timeout=timeout, cwd=effective_cwd, env=env,
 97          )
 98          stdout = result.stdout.strip()
 99          stderr = result.stderr.strip()
100  
101          if result.returncode == 0:
102              return {"success": True, "output": stdout}
103          return {"success": False, "error": stderr or stdout or f"brv exited {result.returncode}"}
104  
105      except subprocess.TimeoutExpired:
106          return {"success": False, "error": f"brv timed out after {timeout}s"}
107      except FileNotFoundError:
108          global _cached_brv_path
109          with _brv_path_lock:
110              _cached_brv_path = None
111          return {"success": False, "error": "brv CLI not found"}
112      except Exception as e:
113          return {"success": False, "error": str(e)}
114  
115  
116  def _get_brv_cwd() -> Path:
117      """Profile-scoped working directory for the brv context tree."""
118      from hermes_constants import get_hermes_home
119      return get_hermes_home() / "byterover"
120  
121  
122  # ---------------------------------------------------------------------------
123  # Tool schemas
124  # ---------------------------------------------------------------------------
125  
126  QUERY_SCHEMA = {
127      "name": "brv_query",
128      "description": (
129          "Search ByteRover's persistent knowledge tree for relevant context. "
130          "Returns memories, project knowledge, architectural decisions, and "
131          "patterns from previous sessions. Use for any question where past "
132          "context would help."
133      ),
134      "parameters": {
135          "type": "object",
136          "properties": {
137              "query": {"type": "string", "description": "What to search for."},
138          },
139          "required": ["query"],
140      },
141  }
142  
143  CURATE_SCHEMA = {
144      "name": "brv_curate",
145      "description": (
146          "Store important information in ByteRover's persistent knowledge tree. "
147          "Use for architectural decisions, bug fixes, user preferences, project "
148          "patterns — anything worth remembering across sessions. ByteRover's LLM "
149          "automatically categorizes and organizes the memory."
150      ),
151      "parameters": {
152          "type": "object",
153          "properties": {
154              "content": {"type": "string", "description": "The information to remember."},
155          },
156          "required": ["content"],
157      },
158  }
159  
160  STATUS_SCHEMA = {
161      "name": "brv_status",
162      "description": "Check ByteRover status — CLI version, context tree stats, cloud sync state.",
163      "parameters": {"type": "object", "properties": {}, "required": []},
164  }
165  
166  
167  # ---------------------------------------------------------------------------
168  # MemoryProvider implementation
169  # ---------------------------------------------------------------------------
170  
171  class ByteRoverMemoryProvider(MemoryProvider):
172      """ByteRover persistent memory via the brv CLI."""
173  
174      def __init__(self):
175          self._cwd = ""
176          self._session_id = ""
177          self._turn_count = 0
178          self._sync_thread: Optional[threading.Thread] = None
179  
180      @property
181      def name(self) -> str:
182          return "byterover"
183  
184      def is_available(self) -> bool:
185          """Check if brv CLI is installed. No network calls."""
186          return _resolve_brv_path() is not None
187  
188      def get_config_schema(self):
189          return [
190              {
191                  "key": "api_key",
192                  "description": "ByteRover API key (optional, for cloud sync)",
193                  "secret": True,
194                  "env_var": "BRV_API_KEY",
195                  "url": "https://app.byterover.dev",
196              },
197          ]
198  
199      def initialize(self, session_id: str, **kwargs) -> None:
200          self._cwd = str(_get_brv_cwd())
201          self._session_id = session_id
202          self._turn_count = 0
203          Path(self._cwd).mkdir(parents=True, exist_ok=True)
204  
205      def system_prompt_block(self) -> str:
206          if not _resolve_brv_path():
207              return ""
208          return (
209              "# ByteRover Memory\n"
210              "Active. Persistent knowledge tree with hierarchical context.\n"
211              "Use brv_query to search past knowledge, brv_curate to store "
212              "important facts, brv_status to check state."
213          )
214  
215      def prefetch(self, query: str, *, session_id: str = "") -> str:
216          """Run brv query synchronously before the agent's first LLM call.
217  
218          Blocks until the query completes (up to _QUERY_TIMEOUT seconds), ensuring
219          the result is available as context before the model is called.
220          """
221          if not query or len(query.strip()) < _MIN_QUERY_LEN:
222              return ""
223          result = _run_brv(
224              ["query", "--", query.strip()[:5000]],
225              timeout=_QUERY_TIMEOUT, cwd=self._cwd,
226          )
227          if result["success"] and result.get("output"):
228              output = result["output"].strip()
229              if len(output) > _MIN_OUTPUT_LEN:
230                  return f"## ByteRover Context\n{output}"
231          return ""
232  
233      def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
234          """No-op: prefetch() now runs synchronously at turn start."""
235          pass
236  
237      def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
238          """Curate the conversation turn in background (non-blocking)."""
239          self._turn_count += 1
240  
241          # Only curate substantive turns
242          if len(user_content.strip()) < _MIN_QUERY_LEN:
243              return
244  
245          def _sync():
246              try:
247                  combined = f"User: {user_content[:2000]}\nAssistant: {assistant_content[:2000]}"
248                  _run_brv(
249                      ["curate", "--", combined],
250                      timeout=_CURATE_TIMEOUT, cwd=self._cwd,
251                  )
252              except Exception as e:
253                  logger.debug("ByteRover sync failed: %s", e)
254  
255          # Wait for previous sync
256          if self._sync_thread and self._sync_thread.is_alive():
257              self._sync_thread.join(timeout=5.0)
258  
259          self._sync_thread = threading.Thread(
260              target=_sync, daemon=True, name="brv-sync"
261          )
262          self._sync_thread.start()
263  
264      def on_memory_write(self, action: str, target: str, content: str) -> None:
265          """Mirror built-in memory writes to ByteRover."""
266          if action not in ("add", "replace") or not content:
267              return
268  
269          def _write():
270              try:
271                  label = "User profile" if target == "user" else "Agent memory"
272                  _run_brv(
273                      ["curate", "--", f"[{label}] {content}"],
274                      timeout=_CURATE_TIMEOUT, cwd=self._cwd,
275                  )
276              except Exception as e:
277                  logger.debug("ByteRover memory mirror failed: %s", e)
278  
279          t = threading.Thread(target=_write, daemon=True, name="brv-memwrite")
280          t.start()
281  
282      def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
283          """Extract insights before context compression discards turns."""
284          if not messages:
285              return ""
286  
287          # Build a summary of messages about to be compressed
288          parts = []
289          for msg in messages[-10:]:  # last 10 messages
290              role = msg.get("role", "")
291              content = msg.get("content", "")
292              if isinstance(content, str) and content.strip() and role in ("user", "assistant"):
293                  parts.append(f"{role}: {content[:500]}")
294  
295          if not parts:
296              return ""
297  
298          combined = "\n".join(parts)
299  
300          def _flush():
301              try:
302                  _run_brv(
303                      ["curate", "--", f"[Pre-compression context]\n{combined}"],
304                      timeout=_CURATE_TIMEOUT, cwd=self._cwd,
305                  )
306                  logger.info("ByteRover pre-compression flush: %d messages", len(parts))
307              except Exception as e:
308                  logger.debug("ByteRover pre-compression flush failed: %s", e)
309  
310          t = threading.Thread(target=_flush, daemon=True, name="brv-flush")
311          t.start()
312          return ""
313  
314      def get_tool_schemas(self) -> List[Dict[str, Any]]:
315          return [QUERY_SCHEMA, CURATE_SCHEMA, STATUS_SCHEMA]
316  
317      def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
318          if tool_name == "brv_query":
319              return self._tool_query(args)
320          elif tool_name == "brv_curate":
321              return self._tool_curate(args)
322          elif tool_name == "brv_status":
323              return self._tool_status()
324          return tool_error(f"Unknown tool: {tool_name}")
325  
326      def shutdown(self) -> None:
327          if self._sync_thread and self._sync_thread.is_alive():
328              self._sync_thread.join(timeout=10.0)
329  
330      # -- Tool implementations ------------------------------------------------
331  
332      def _tool_query(self, args: dict) -> str:
333          query = args.get("query", "")
334          if not query:
335              return tool_error("query is required")
336  
337          result = _run_brv(
338              ["query", "--", query.strip()[:5000]],
339              timeout=_QUERY_TIMEOUT, cwd=self._cwd,
340          )
341  
342          if not result["success"]:
343              return tool_error(result.get("error", "Query failed"))
344  
345          output = result.get("output", "").strip()
346          if not output or len(output) < _MIN_OUTPUT_LEN:
347              return json.dumps({"result": "No relevant memories found."})
348  
349          # Truncate very long results
350          if len(output) > 8000:
351              output = output[:8000] + "\n\n[... truncated]"
352  
353          return json.dumps({"result": output})
354  
355      def _tool_curate(self, args: dict) -> str:
356          content = args.get("content", "")
357          if not content:
358              return tool_error("content is required")
359  
360          result = _run_brv(
361              ["curate", "--", content],
362              timeout=_CURATE_TIMEOUT, cwd=self._cwd,
363          )
364  
365          if not result["success"]:
366              return tool_error(result.get("error", "Curate failed"))
367  
368          return json.dumps({"result": "Memory curated successfully."})
369  
370      def _tool_status(self) -> str:
371          result = _run_brv(["status"], timeout=15, cwd=self._cwd)
372          if not result["success"]:
373              return tool_error(result.get("error", "Status check failed"))
374          return json.dumps({"status": result.get("output", "")})
375  
376  
377  # ---------------------------------------------------------------------------
378  # Plugin entry point
379  # ---------------------------------------------------------------------------
380  
381  def register(ctx) -> None:
382      """Register ByteRover as a memory provider plugin."""
383      ctx.register_memory_provider(ByteRoverMemoryProvider())