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"]