diff --git a/scripts/launchd/com.brainlayer.enrich.plist b/scripts/launchd/com.brainlayer.enrich.plist
new file mode 100644
index 0000000..7be463a
--- /dev/null
+++ b/scripts/launchd/com.brainlayer.enrich.plist
@@ -0,0 +1,45 @@
+
+
+
+
+ Label
+ com.brainlayer.enrich
+
+ ProgramArguments
+
+ __BRAINLAYER_BIN__
+ enrich
+ --batch-size
+ 50
+ --max
+ 500
+
+
+ StartInterval
+ 3600
+
+ StandardOutPath
+ __HOME__/.local/share/brainlayer/logs/enrich.log
+ StandardErrorPath
+ __HOME__/.local/share/brainlayer/logs/enrich.err
+
+ EnvironmentVariables
+
+ PATH
+ /usr/local/bin:/usr/bin:/bin:__HOME__/.local/bin
+ PYTHONUNBUFFERED
+ 1
+ BRAINLAYER_STALL_TIMEOUT
+ 300
+
+
+ RunAtLoad
+
+
+ Nice
+ 15
+
+ ProcessType
+ Background
+
+
diff --git a/scripts/launchd/com.brainlayer.index.plist b/scripts/launchd/com.brainlayer.index.plist
new file mode 100644
index 0000000..56db8b7
--- /dev/null
+++ b/scripts/launchd/com.brainlayer.index.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ Label
+ com.brainlayer.index
+
+ ProgramArguments
+
+ __BRAINLAYER_BIN__
+ index
+
+
+ StartInterval
+ 1800
+
+ StandardOutPath
+ __HOME__/.local/share/brainlayer/logs/index.log
+ StandardErrorPath
+ __HOME__/.local/share/brainlayer/logs/index.err
+
+ EnvironmentVariables
+
+ PATH
+ /usr/local/bin:/usr/bin:/bin:__HOME__/.local/bin
+ PYTHONUNBUFFERED
+ 1
+
+
+ RunAtLoad
+
+
+ Nice
+ 10
+
+
diff --git a/scripts/launchd/install.sh b/scripts/launchd/install.sh
new file mode 100755
index 0000000..e08df03
--- /dev/null
+++ b/scripts/launchd/install.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# Install BrainLayer launchd plists for auto-indexing and enrichment.
+#
+# Usage:
+# ./scripts/launchd/install.sh # Install both
+# ./scripts/launchd/install.sh index # Install indexing only
+# ./scripts/launchd/install.sh enrich # Install enrichment only
+# ./scripts/launchd/install.sh remove # Unload and remove all
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+LAUNCH_DIR="$HOME/Library/LaunchAgents"
+LOG_DIR="$HOME/.local/share/brainlayer/logs"
+BRAINLAYER_BIN="${BRAINLAYER_BIN:-$(which brainlayer 2>/dev/null || echo "$HOME/.local/bin/brainlayer")}"
+
+if [ ! -x "$BRAINLAYER_BIN" ]; then
+ echo "ERROR: brainlayer binary not found at $BRAINLAYER_BIN"
+ echo "Install with: pip install -e . (from brainlayer repo)"
+ echo "Or set BRAINLAYER_BIN=/path/to/brainlayer"
+ exit 1
+fi
+
+mkdir -p "$LAUNCH_DIR" "$LOG_DIR"
+
+install_plist() {
+ local name="$1"
+ local src="$SCRIPT_DIR/com.brainlayer.${name}.plist"
+ local dst="$LAUNCH_DIR/com.brainlayer.${name}.plist"
+
+ if [ ! -f "$src" ]; then
+ echo "ERROR: $src not found"
+ return 1
+ fi
+
+ # Replace placeholders
+ sed \
+ -e "s|__HOME__|$HOME|g" \
+ -e "s|__BRAINLAYER_BIN__|$BRAINLAYER_BIN|g" \
+ "$src" > "$dst"
+
+ echo "Installed: $dst"
+ echo " Binary: $BRAINLAYER_BIN"
+ echo " Logs: $LOG_DIR/"
+
+ # Unload if already loaded, then load
+ launchctl bootout "gui/$(id -u)/com.brainlayer.${name}" 2>/dev/null || true
+ launchctl bootstrap "gui/$(id -u)" "$dst"
+ echo " Loaded: com.brainlayer.${name}"
+}
+
+remove_plist() {
+ local name="$1"
+ local dst="$LAUNCH_DIR/com.brainlayer.${name}.plist"
+ launchctl bootout "gui/$(id -u)/com.brainlayer.${name}" 2>/dev/null || true
+ rm -f "$dst"
+ echo "Removed: com.brainlayer.${name}"
+}
+
+case "${1:-all}" in
+ index)
+ install_plist index
+ ;;
+ enrich)
+ install_plist enrich
+ ;;
+ all)
+ install_plist index
+ install_plist enrich
+ ;;
+ remove)
+ remove_plist index
+ remove_plist enrich
+ ;;
+ *)
+ echo "Usage: $0 [index|enrich|all|remove]"
+ exit 1
+ ;;
+esac
+
+echo ""
+echo "Done. Check logs at: $LOG_DIR/"
+echo "Status: launchctl list | grep brainlayer"
diff --git a/src/brainlayer/engine.py b/src/brainlayer/engine.py
index 3b7dcc8..80a5365 100644
--- a/src/brainlayer/engine.py
+++ b/src/brainlayer/engine.py
@@ -393,7 +393,7 @@ class CurrentContext:
def format(self) -> str:
"""Format as concise markdown — designed for voice/quick context."""
- if not self.recent_sessions:
+ if not self.recent_sessions and not self.active_projects and not self.recent_files:
return "No recent session context available."
parts = ["## Current Context\n"]
@@ -434,6 +434,10 @@ def current_context(
Designed for voice assistants and quick context injection.
Lightweight — no embedding model needed.
+ Uses two data sources:
+ 1. session_context table (git overlay data — may be sparse)
+ 2. chunks table (always populated from indexing)
+
Args:
store: VectorStore instance
hours: How many hours back to look (default: 24)
@@ -442,15 +446,32 @@ def current_context(
CurrentContext with recent sessions, files, projects, branches
"""
result = CurrentContext()
+ cursor = store.conn.cursor()
+ date_from = (datetime.now() - timedelta(hours=hours)).isoformat()
- # Get recent sessions
- recent = sessions(store, days=max(1, hours // 24) or 1, limit=10)
+ # 1. Try session_context first (richest data)
+ # Convert hours to days properly — ceil division, minimum 1
+ days = max(1, -(-hours // 24)) # ceiling division trick
+ recent = sessions(store, days=days, limit=10)
result.recent_sessions = recent
- if not recent:
- return result
+ # 2. Also query chunks table directly for recent projects
+ # This catches sessions that haven't been through git_overlay yet
+ chunk_projects = list(
+ cursor.execute(
+ """
+ SELECT project
+ FROM chunks
+ WHERE created_at >= ? AND project IS NOT NULL
+ GROUP BY project
+ ORDER BY MAX(created_at) DESC
+ LIMIT 10
+ """,
+ (date_from,),
+ )
+ )
- # Extract active projects and branches
+ # Extract active projects and branches from session_context
projects = []
branches = []
plans = []
@@ -462,15 +483,17 @@ def current_context(
if s.plan_name and s.plan_name not in plans:
plans.append(s.plan_name)
+ # Merge in projects from chunks table (may have projects not in session_context)
+ for row in chunk_projects:
+ if row[0] and row[0] not in projects:
+ projects.append(row[0])
+
result.active_projects = projects[:5]
result.active_branches = branches[:5]
if plans:
result.active_plan = plans[0] # Most recent plan
- # Get recent files from file_interactions
- cursor = store.conn.cursor()
- date_from = (datetime.now() - timedelta(hours=hours)).isoformat()
-
+ # 3. Get recent files from file_interactions
rows = list(
cursor.execute(
"""
@@ -485,6 +508,22 @@ def current_context(
)
result.recent_files = [r[0] for r in rows if r[0]]
+ # 4. If no files from interactions, try chunks metadata for file references
+ if not result.recent_files:
+ file_rows = list(
+ cursor.execute(
+ """
+ SELECT DISTINCT source_file
+ FROM chunks
+ WHERE created_at >= ? AND source_file IS NOT NULL
+ ORDER BY created_at DESC
+ LIMIT 20
+ """,
+ (date_from,),
+ )
+ )
+ result.recent_files = [r[0] for r in file_rows if r[0]]
+
return result
diff --git a/src/brainlayer/pipeline/enrichment.py b/src/brainlayer/pipeline/enrichment.py
index 8de0116..343b260 100644
--- a/src/brainlayer/pipeline/enrichment.py
+++ b/src/brainlayer/pipeline/enrichment.py
@@ -52,14 +52,42 @@ def _get_thread_store(db_path: Path) -> VectorStore:
# AIDEV-NOTE: Uses local LLM only — never sends chunk content to cloud APIs
-# Backend selection: ollama (default) or mlx
-ENRICH_BACKEND = os.environ.get("BRAINLAYER_ENRICH_BACKEND", "ollama")
-OLLAMA_URL = "http://127.0.0.1:11434/api/generate"
+# Backend selection: auto-detect by default
+# - arm64 Mac → mlx (lighter, faster on Apple Silicon)
+# - Other → ollama (universal fallback)
+# Override with BRAINLAYER_ENRICH_BACKEND=ollama|mlx
+
+
+def _detect_default_backend() -> str:
+ """Auto-detect the best enrichment backend for this platform.
+
+ arm64 Mac → mlx (native Apple Silicon, no Docker overhead)
+ Everything else → ollama (universal, works everywhere)
+ """
+ import platform
+
+ explicit = os.environ.get("BRAINLAYER_ENRICH_BACKEND")
+ if explicit:
+ return explicit
+
+ if platform.machine() == "arm64" and platform.system() == "Darwin":
+ return "mlx"
+ return "ollama"
+
+
+ENRICH_BACKEND = _detect_default_backend()
+OLLAMA_URL = os.environ.get("BRAINLAYER_OLLAMA_URL", "http://127.0.0.1:11434/api/generate")
+OLLAMA_BASE_URL = OLLAMA_URL.rsplit("/api/", 1)[0] if "/api/" in OLLAMA_URL else OLLAMA_URL.rstrip("/")
# MLX URL: scripts also check MLX_URL for health, so accept both env vars
MLX_URL = os.environ.get("BRAINLAYER_MLX_URL", os.environ.get("MLX_URL", "http://127.0.0.1:8080/v1/chat/completions"))
MLX_BASE_URL = MLX_URL.rsplit("/v1/", 1)[0] if "/v1/" in MLX_URL else MLX_URL.rstrip("/")
MODEL = os.environ.get("BRAINLAYER_ENRICH_MODEL", "glm-4.7-flash")
MLX_MODEL = os.environ.get("BRAINLAYER_MLX_MODEL", "mlx-community/Qwen2.5-Coder-14B-Instruct-4bit")
+
+# Stall detection: max seconds a single chunk can take before being considered stalled
+STALL_TIMEOUT = int(os.environ.get("BRAINLAYER_STALL_TIMEOUT", "300")) # 5 minutes default
+# Heartbeat: log progress every N chunks (min 1 to avoid ZeroDivisionError)
+HEARTBEAT_INTERVAL = max(1, int(os.environ.get("BRAINLAYER_HEARTBEAT_INTERVAL", "25")))
from ..paths import DEFAULT_DB_PATH
# Supabase usage logging — track GLM calls even though they're free
@@ -384,9 +412,14 @@ def call_mlx(prompt: str, timeout: int = 240) -> Optional[str]:
return None
-def call_llm(prompt: str, timeout: int = 240) -> Optional[str]:
- """Call local LLM using configured backend (ollama or mlx)."""
- if ENRICH_BACKEND == "mlx":
+def call_llm(prompt: str, timeout: int = 240, backend: Optional[str] = None) -> Optional[str]:
+ """Call local LLM using configured backend (ollama or mlx).
+
+ Args:
+ backend: Override backend for this call. If None, uses ENRICH_BACKEND.
+ """
+ effective = backend or ENRICH_BACKEND
+ if effective == "mlx":
return call_mlx(prompt, timeout=timeout)
return call_glm(prompt, timeout=timeout)
@@ -474,11 +507,17 @@ def _enrich_one(
store_or_path,
chunk: Dict[str, Any],
with_context: bool = True,
+ backend: Optional[str] = None,
) -> bool:
"""Enrich a single chunk. Returns True on success, False on failure.
Args:
store_or_path: VectorStore instance (sequential) or Path (parallel, uses thread-local store).
+ backend: Override backend for LLM calls.
+
+ Stall detection: if the LLM call takes longer than STALL_TIMEOUT, it's logged
+ as a stall. The requests timeout in call_llm/call_glm already handles HTTP-level
+ timeouts, but this adds observability at the enrichment layer.
"""
# In parallel mode, each thread gets its own VectorStore connection.
if isinstance(store_or_path, Path):
@@ -492,7 +531,18 @@ def _enrich_one(
context_chunks = [c for c in ctx.get("context", []) if not c.get("is_target")]
prompt = build_prompt(chunk, context_chunks)
- response = call_llm(prompt)
+
+ chunk_start = time.time()
+ response = call_llm(prompt, backend=backend)
+ chunk_duration = time.time() - chunk_start
+
+ # Stall detection: log warning if chunk took too long
+ if chunk_duration > STALL_TIMEOUT:
+ print(
+ f" STALL: chunk {chunk['id'][:12]} took {chunk_duration:.0f}s "
+ f"(threshold: {STALL_TIMEOUT}s, chars: {chunk.get('char_count', '?')})",
+ file=sys.stderr,
+ )
enrichment = parse_enrichment(response)
if enrichment:
@@ -519,12 +569,14 @@ def enrich_batch(
content_types: Optional[List[str]] = None,
with_context: bool = True,
parallel: int = 1,
+ backend: Optional[str] = None,
) -> Dict[str, int]:
"""Process one batch of unenriched chunks. Returns counts.
Args:
parallel: Number of concurrent workers (1=sequential, >1=ThreadPoolExecutor).
MLX server supports concurrent requests. Ollama may not benefit.
+ backend: Override backend for LLM calls (used when run_enrichment detects fallback).
"""
types = content_types or HIGH_VALUE_TYPES
chunks = store.get_unenriched_chunks(batch_size=batch_size, content_types=types)
@@ -535,12 +587,15 @@ def enrich_batch(
success = 0
failed = 0
+ batch_start = time.time()
+ last_heartbeat = batch_start
+
if parallel > 1:
# Parallel: pass db_path so each thread gets its own VectorStore connection.
# APSW connections are not safe for concurrent use from multiple threads.
db_path = store.db_path
with ThreadPoolExecutor(max_workers=parallel) as pool:
- futures = {pool.submit(_enrich_one, db_path, chunk, with_context): chunk for chunk in chunks}
+ futures = {pool.submit(_enrich_one, db_path, chunk, with_context, backend): chunk for chunk in chunks}
for future in as_completed(futures):
try:
if future.result():
@@ -552,13 +607,16 @@ def enrich_batch(
failed += 1
done = success + failed
- if done % 10 == 0:
- print(f" [{done}/{len(chunks)}] ok={success} fail={failed}")
+ now = time.time()
+ if done % HEARTBEAT_INTERVAL == 0 or now - last_heartbeat > 60:
+ rate = done / (now - batch_start) if now > batch_start else 0
+ print(f" HEARTBEAT [{done}/{len(chunks)}] ok={success} fail={failed} rate={rate:.1f}/s")
+ last_heartbeat = now
else:
# Sequential: one chunk at a time (original behavior)
for chunk in chunks:
start = time.time()
- ok = _enrich_one(store, chunk, with_context)
+ ok = _enrich_one(store, chunk, with_context, backend=backend)
duration = time.time() - start
if ok:
@@ -567,8 +625,14 @@ def enrich_batch(
failed += 1
done = success + failed
- if done % 10 == 0:
- print(f" [{done}/{len(chunks)}] {duration:.1f}s | ok={success} fail={failed}")
+ now = time.time()
+ if done % HEARTBEAT_INTERVAL == 0 or now - last_heartbeat > 60:
+ rate = done / (now - batch_start) if now > batch_start else 0
+ print(
+ f" HEARTBEAT [{done}/{len(chunks)}] {duration:.1f}s | "
+ f"ok={success} fail={failed} rate={rate:.1f}/s"
+ )
+ last_heartbeat = now
return {"processed": len(chunks), "success": success, "failed": failed}
@@ -586,25 +650,40 @@ def run_enrichment(
store = VectorStore(path)
try:
- # Check LLM backend is running
- if ENRICH_BACKEND == "mlx":
+ # Check LLM backend is running — with auto-fallback
+ active_backend = ENRICH_BACKEND
+ if active_backend == "mlx":
try:
resp = requests.get(f"{MLX_BASE_URL}/v1/models", timeout=5)
resp.raise_for_status()
print(f"Backend: MLX ({MLX_BASE_URL})")
except Exception:
- raise RuntimeError(
- f"MLX server not running at {MLX_BASE_URL}. Start with: "
- "python3 -m mlx_lm.server --model --port 8080"
- )
+ # MLX not running — try falling back to Ollama
+ print(f"MLX not available at {MLX_BASE_URL}, trying Ollama fallback...", file=sys.stderr)
+ try:
+ ollama_base = OLLAMA_BASE_URL
+ resp = requests.get(f"{ollama_base}/api/tags", timeout=5)
+ resp.raise_for_status()
+ active_backend = "ollama"
+ print(f"Backend: Ollama ({MODEL}) [fallback from MLX]")
+ except Exception:
+ raise RuntimeError(
+ f"Neither MLX ({MLX_BASE_URL}) nor Ollama is running.\n"
+ f"Start MLX: python3 -m mlx_lm.server --model {MLX_MODEL} --port 8080\n"
+ f"Start Ollama: ollama serve"
+ )
else:
try:
- resp = requests.get("http://127.0.0.1:11434/api/tags", timeout=5)
+ ollama_base = OLLAMA_BASE_URL
+ resp = requests.get(f"{ollama_base}/api/tags", timeout=5)
resp.raise_for_status()
print(f"Backend: Ollama ({MODEL})")
except Exception:
raise RuntimeError("Ollama is not running. Start it with: ollama serve")
+ # Override module-level backend for this run if fallback was used
+ _run_backend = active_backend
+
stats = store.get_enrichment_stats()
print(f"Enrichment status: {stats['enriched']}/{stats['total_chunks']} ({stats['percent']}%)")
print(f"Remaining: {stats['remaining']}")
@@ -625,6 +704,7 @@ def run_enrichment(
content_types=content_types,
with_context=with_context,
parallel=parallel,
+ backend=_run_backend,
)
if result["processed"] == 0:
diff --git a/tests/test_engine.py b/tests/test_engine.py
index 4afc59a..db6a60e 100644
--- a/tests/test_engine.py
+++ b/tests/test_engine.py
@@ -231,10 +231,20 @@ class TestCurrentContextFormat:
"""Test CurrentContext formatting."""
def test_empty_context(self):
- """No sessions returns message."""
+ """Fully empty context returns message."""
ctx = CurrentContext()
assert "No recent session" in ctx.format()
+ def test_projects_without_sessions_still_shown(self):
+ """Projects from chunks fallback are shown even without sessions."""
+ ctx = CurrentContext(
+ active_projects=["golems", "brainlayer"],
+ recent_files=["src/auth.py"],
+ )
+ formatted = ctx.format()
+ assert "golems" in formatted
+ assert "No recent session" not in formatted
+
def test_with_projects(self):
"""Active projects are shown."""
ctx = CurrentContext(
diff --git a/tests/test_phase2.py b/tests/test_phase2.py
new file mode 100644
index 0000000..fd4339a
--- /dev/null
+++ b/tests/test_phase2.py
@@ -0,0 +1,287 @@
+"""Tests for Phase 2: MLX migration, auto-indexing, current_context fix.
+
+Tests cover:
+- Backend auto-detection (MLX on arm64 Mac, Ollama elsewhere)
+- Enrichment stall detection
+- Heartbeat logging
+- current_context fix (hours→days conversion, chunks fallback)
+- call_llm backend override
+"""
+
+import os
+from datetime import datetime
+from unittest.mock import MagicMock, patch
+
+
+class TestBackendAutoDetection:
+ """Test _detect_default_backend() auto-detection logic."""
+
+ def test_explicit_env_overrides_detection(self):
+ """BRAINLAYER_ENRICH_BACKEND env var overrides auto-detection."""
+ from brainlayer.pipeline.enrichment import _detect_default_backend
+
+ with patch.dict(os.environ, {"BRAINLAYER_ENRICH_BACKEND": "ollama"}):
+ assert _detect_default_backend() == "ollama"
+
+ with patch.dict(os.environ, {"BRAINLAYER_ENRICH_BACKEND": "mlx"}):
+ assert _detect_default_backend() == "mlx"
+
+ def test_arm64_darwin_defaults_mlx(self):
+ """arm64 macOS defaults to MLX."""
+ from brainlayer.pipeline.enrichment import _detect_default_backend
+
+ env = {k: v for k, v in os.environ.items() if k != "BRAINLAYER_ENRICH_BACKEND"}
+ with patch.dict(os.environ, env, clear=True):
+ with patch("platform.machine", return_value="arm64"):
+ with patch("platform.system", return_value="Darwin"):
+ assert _detect_default_backend() == "mlx"
+
+ def test_x86_defaults_ollama(self):
+ """x86_64 defaults to Ollama."""
+ from brainlayer.pipeline.enrichment import _detect_default_backend
+
+ env = {k: v for k, v in os.environ.items() if k != "BRAINLAYER_ENRICH_BACKEND"}
+ with patch.dict(os.environ, env, clear=True):
+ with patch("platform.machine", return_value="x86_64"):
+ assert _detect_default_backend() == "ollama"
+
+ def test_linux_defaults_ollama(self):
+ """Linux defaults to Ollama regardless of arch."""
+ from brainlayer.pipeline.enrichment import _detect_default_backend
+
+ env = {k: v for k, v in os.environ.items() if k != "BRAINLAYER_ENRICH_BACKEND"}
+ with patch.dict(os.environ, env, clear=True):
+ with patch("platform.machine", return_value="arm64"):
+ with patch("platform.system", return_value="Linux"):
+ assert _detect_default_backend() == "ollama"
+
+
+class TestCallLlmBackendOverride:
+ """Test that call_llm respects the backend parameter."""
+
+ @patch("brainlayer.pipeline.enrichment.call_mlx", return_value='{"summary":"test"}')
+ @patch("brainlayer.pipeline.enrichment.call_glm", return_value='{"summary":"test"}')
+ def test_override_to_mlx(self, mock_glm, mock_mlx):
+ """Backend override to MLX calls call_mlx."""
+ from brainlayer.pipeline.enrichment import call_llm
+
+ call_llm("test prompt", backend="mlx")
+ mock_mlx.assert_called_once()
+ mock_glm.assert_not_called()
+
+ @patch("brainlayer.pipeline.enrichment.call_mlx", return_value='{"summary":"test"}')
+ @patch("brainlayer.pipeline.enrichment.call_glm", return_value='{"summary":"test"}')
+ def test_override_to_ollama(self, mock_glm, mock_mlx):
+ """Backend override to Ollama calls call_glm."""
+ from brainlayer.pipeline.enrichment import call_llm
+
+ call_llm("test prompt", backend="ollama")
+ mock_glm.assert_called_once()
+ mock_mlx.assert_not_called()
+
+ @patch("brainlayer.pipeline.enrichment.call_mlx", return_value='{"summary":"test"}')
+ @patch("brainlayer.pipeline.enrichment.call_glm", return_value='{"summary":"test"}')
+ def test_none_uses_module_default(self, mock_glm, mock_mlx):
+ """None backend uses the module-level ENRICH_BACKEND."""
+ from brainlayer.pipeline import enrichment
+ from brainlayer.pipeline.enrichment import call_llm
+
+ original = enrichment.ENRICH_BACKEND
+ try:
+ enrichment.ENRICH_BACKEND = "ollama"
+ call_llm("test prompt", backend=None)
+ mock_glm.assert_called_once()
+ finally:
+ enrichment.ENRICH_BACKEND = original
+
+
+class TestStallDetection:
+ """Test enrichment stall detection and heartbeat logging."""
+
+ @patch("brainlayer.pipeline.enrichment.call_llm")
+ def test_stall_logged_when_slow(self, mock_llm, capsys):
+ """Stall warning is printed when chunk takes too long."""
+ from brainlayer.pipeline import enrichment
+ from brainlayer.pipeline.enrichment import _enrich_one
+
+ # Make call_llm "take" a long time by manipulating time
+ original_timeout = enrichment.STALL_TIMEOUT
+ enrichment.STALL_TIMEOUT = 0 # Any duration triggers stall
+
+ mock_llm.return_value = '{"summary":"test summary","tags":["test"],"importance":5,"intent":"debugging"}'
+
+ mock_store = MagicMock()
+ mock_store.get_context.return_value = {"context": []}
+ mock_store.update_enrichment.return_value = None
+
+ chunk = {
+ "id": "test-chunk-123",
+ "content": "test content",
+ "project": "test",
+ "content_type": "user_message",
+ "conversation_id": None,
+ "position": None,
+ "char_count": 100,
+ }
+
+ try:
+ result = _enrich_one(mock_store, chunk, with_context=False)
+ assert result is True # Should still succeed
+ captured = capsys.readouterr()
+ assert "STALL" in captured.err
+ finally:
+ enrichment.STALL_TIMEOUT = original_timeout
+
+ @patch("brainlayer.pipeline.enrichment.call_llm")
+ def test_no_stall_when_fast(self, mock_llm, capsys):
+ """No stall warning when chunk processes quickly."""
+ from brainlayer.pipeline import enrichment
+ from brainlayer.pipeline.enrichment import _enrich_one
+
+ original_timeout = enrichment.STALL_TIMEOUT
+ enrichment.STALL_TIMEOUT = 9999 # Very high threshold
+
+ mock_llm.return_value = '{"summary":"test summary","tags":["test"],"importance":5,"intent":"debugging"}'
+
+ mock_store = MagicMock()
+ mock_store.get_context.return_value = {"context": []}
+
+ chunk = {
+ "id": "test-chunk-fast",
+ "content": "test content",
+ "project": "test",
+ "content_type": "user_message",
+ "conversation_id": None,
+ "position": None,
+ "char_count": 50,
+ }
+
+ try:
+ _enrich_one(mock_store, chunk, with_context=False)
+ captured = capsys.readouterr()
+ assert "STALL" not in captured.err
+ finally:
+ enrichment.STALL_TIMEOUT = original_timeout
+
+
+class TestCurrentContextFix:
+ """Test that current_context returns data from chunks table when session_context is empty."""
+
+ def _make_store(self, tmp_path):
+ """Create a test VectorStore with sample data."""
+ from brainlayer.vector_store import VectorStore
+
+ db_path = tmp_path / "test.db"
+ store = VectorStore(db_path)
+ return store
+
+ def test_hours_to_days_precision(self):
+ """Hours to days conversion uses ceiling division."""
+ # hours=4 should produce days=1 (not 0)
+ # hours=25 should produce days=2 (not 1)
+ # hours=48 should produce days=2
+ assert max(1, -(-4 // 24)) == 1
+ assert max(1, -(-25 // 24)) == 2
+ assert max(1, -(-48 // 24)) == 2
+ assert max(1, -(-1 // 24)) == 1
+
+ def test_empty_store_returns_empty(self, tmp_path):
+ """Empty database returns empty context."""
+ from brainlayer.engine import current_context
+
+ store = self._make_store(tmp_path)
+ result = current_context(store, hours=24)
+ assert result.active_projects == []
+ assert result.recent_files == []
+ assert result.recent_sessions == []
+ store.close()
+
+ def test_chunks_provide_project_fallback(self, tmp_path):
+ """Projects are found from chunks table even without session_context entries."""
+ from brainlayer.engine import current_context
+ from brainlayer.vector_store import VectorStore
+
+ db_path = tmp_path / "test.db"
+ store = VectorStore(db_path)
+
+ # Insert a chunk with a recent created_at and project
+ now = datetime.now().isoformat()
+ cursor = store.conn.cursor()
+ cursor.execute(
+ """INSERT INTO chunks (id, content, metadata, source_file, project, content_type, char_count, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+ ("chunk-1", "test content", "{}", "test.py", "my-project", "user_message", 100, now),
+ )
+
+ result = current_context(store, hours=24)
+ assert "my-project" in result.active_projects
+ store.close()
+
+ def test_source_files_fallback(self, tmp_path):
+ """Recent files come from chunks.source_file when file_interactions is empty."""
+ from brainlayer.engine import current_context
+ from brainlayer.vector_store import VectorStore
+
+ db_path = tmp_path / "test.db"
+ store = VectorStore(db_path)
+
+ now = datetime.now().isoformat()
+ cursor = store.conn.cursor()
+ cursor.execute(
+ """INSERT INTO chunks (id, content, metadata, source_file, project, content_type, char_count, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+ ("chunk-1", "test content", "{}", "src/auth.py", "my-project", "user_message", 100, now),
+ )
+
+ result = current_context(store, hours=24)
+ assert "src/auth.py" in result.recent_files
+ store.close()
+
+
+class TestParseEnrichment:
+ """Test enrichment JSON parsing — existing but good to verify."""
+
+ def test_valid_json(self):
+ from brainlayer.pipeline.enrichment import parse_enrichment
+
+ text = '{"summary":"Test summary here","tags":["python","testing"],"importance":7,"intent":"debugging"}'
+ result = parse_enrichment(text)
+ assert result is not None
+ assert result["summary"] == "Test summary here"
+ assert "python" in result["tags"]
+ assert result["importance"] == 7.0
+ assert result["intent"] == "debugging"
+
+ def test_json_with_extra_text(self):
+ from brainlayer.pipeline.enrichment import parse_enrichment
+
+ text = 'Here is the result:\n{"summary":"Found it","tags":["test"],"importance":5,"intent":"implementing"}\nDone.'
+ result = parse_enrichment(text)
+ assert result is not None
+ assert result["summary"] == "Found it"
+
+ def test_invalid_json(self):
+ from brainlayer.pipeline.enrichment import parse_enrichment
+
+ assert parse_enrichment("not json at all") is None
+ assert parse_enrichment("") is None
+ assert parse_enrichment(None) is None
+
+ def test_missing_required_fields(self):
+ from brainlayer.pipeline.enrichment import parse_enrichment
+
+ # Missing tags — should return None
+ text = '{"summary":"test"}'
+ assert parse_enrichment(text) is None
+
+ def test_extended_fields_optional(self):
+ from brainlayer.pipeline.enrichment import parse_enrichment
+
+ text = '{"summary":"Testing extended fields work correctly","tags":["python"],"importance":5,"intent":"debugging","primary_symbols":["MyClass"],"resolved_query":"How to fix it?","epistemic_level":"validated","debt_impact":"resolution","external_deps":["fastapi"]}'
+ result = parse_enrichment(text)
+ assert result is not None
+ assert result["primary_symbols"] == ["MyClass"]
+ assert result["resolved_query"] == "How to fix it?"
+ assert result["epistemic_level"] == "validated"
+ assert result["debt_impact"] == "resolution"
+ assert result["external_deps"] == ["fastapi"]