brainlayer 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brainlayer/__init__.py +3 -0
- brainlayer/cli/__init__.py +1545 -0
- brainlayer/cli/wizard.py +132 -0
- brainlayer/cli_new.py +151 -0
- brainlayer/client.py +164 -0
- brainlayer/clustering.py +736 -0
- brainlayer/daemon.py +1105 -0
- brainlayer/dashboard/README.md +129 -0
- brainlayer/dashboard/__init__.py +5 -0
- brainlayer/dashboard/app.py +151 -0
- brainlayer/dashboard/search.py +229 -0
- brainlayer/dashboard/views.py +230 -0
- brainlayer/embeddings.py +131 -0
- brainlayer/engine.py +550 -0
- brainlayer/index_new.py +87 -0
- brainlayer/mcp/__init__.py +1558 -0
- brainlayer/migrate.py +205 -0
- brainlayer/paths.py +43 -0
- brainlayer/pipeline/__init__.py +47 -0
- brainlayer/pipeline/analyze_communication.py +508 -0
- brainlayer/pipeline/brain_graph.py +567 -0
- brainlayer/pipeline/chat_tags.py +63 -0
- brainlayer/pipeline/chunk.py +422 -0
- brainlayer/pipeline/classify.py +472 -0
- brainlayer/pipeline/cluster_sampling.py +73 -0
- brainlayer/pipeline/enrichment.py +810 -0
- brainlayer/pipeline/extract.py +66 -0
- brainlayer/pipeline/extract_claude_desktop.py +149 -0
- brainlayer/pipeline/extract_corrections.py +231 -0
- brainlayer/pipeline/extract_markdown.py +195 -0
- brainlayer/pipeline/extract_whatsapp.py +227 -0
- brainlayer/pipeline/git_overlay.py +301 -0
- brainlayer/pipeline/longitudinal_analyzer.py +568 -0
- brainlayer/pipeline/obsidian_export.py +455 -0
- brainlayer/pipeline/operation_grouping.py +486 -0
- brainlayer/pipeline/plan_linking.py +313 -0
- brainlayer/pipeline/sanitize.py +549 -0
- brainlayer/pipeline/semantic_style.py +574 -0
- brainlayer/pipeline/session_enrichment.py +472 -0
- brainlayer/pipeline/style_embed.py +67 -0
- brainlayer/pipeline/style_index.py +139 -0
- brainlayer/pipeline/temporal_chains.py +203 -0
- brainlayer/pipeline/time_batcher.py +248 -0
- brainlayer/pipeline/unified_timeline.py +569 -0
- brainlayer/storage.py +66 -0
- brainlayer/store.py +155 -0
- brainlayer/taxonomy.json +80 -0
- brainlayer/vector_store.py +1891 -0
- brainlayer-1.0.0.dist-info/METADATA +313 -0
- brainlayer-1.0.0.dist-info/RECORD +53 -0
- brainlayer-1.0.0.dist-info/WHEEL +4 -0
- brainlayer-1.0.0.dist-info/entry_points.txt +4 -0
- brainlayer-1.0.0.dist-info/licenses/LICENSE +190 -0
brainlayer/cli/wizard.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Interactive setup wizard for BrainLayer.
|
|
2
|
+
|
|
3
|
+
Detects the user's environment, recommends configuration,
|
|
4
|
+
and guides through first-time setup.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class WizardConfig:
|
|
17
|
+
"""Configuration generated by the wizard."""
|
|
18
|
+
|
|
19
|
+
enrich_backend: str = "none"
|
|
20
|
+
extras: list[str] = field(default_factory=list)
|
|
21
|
+
claude_projects_dir: Optional[Path] = None
|
|
22
|
+
db_path: Optional[Path] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_environment() -> dict:
|
|
26
|
+
"""Detect available tools and data sources."""
|
|
27
|
+
env = {}
|
|
28
|
+
|
|
29
|
+
# Check Ollama
|
|
30
|
+
env["ollama_available"] = shutil.which("ollama") is not None
|
|
31
|
+
if env["ollama_available"]:
|
|
32
|
+
try:
|
|
33
|
+
result = subprocess.run(["ollama", "list"], capture_output=True, text=True, timeout=5)
|
|
34
|
+
env["ollama_models"] = result.stdout.strip().split("\n") if result.returncode == 0 else []
|
|
35
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
36
|
+
env["ollama_available"] = False
|
|
37
|
+
env["ollama_models"] = []
|
|
38
|
+
else:
|
|
39
|
+
env["ollama_models"] = []
|
|
40
|
+
|
|
41
|
+
# Check Apple Silicon (for MLX)
|
|
42
|
+
env["is_apple_silicon"] = platform.system() == "Darwin" and platform.machine() == "arm64"
|
|
43
|
+
|
|
44
|
+
# Check Claude Code conversations
|
|
45
|
+
claude_dir = Path.home() / ".claude" / "projects"
|
|
46
|
+
env["claude_projects_dir"] = claude_dir if claude_dir.exists() else None
|
|
47
|
+
env["conversation_count"] = len(list(claude_dir.rglob("*.jsonl"))) if claude_dir.exists() else 0
|
|
48
|
+
|
|
49
|
+
# Check for WhatsApp exports
|
|
50
|
+
env["whatsapp_available"] = False
|
|
51
|
+
|
|
52
|
+
# Check for existing DB
|
|
53
|
+
default_db = Path.home() / ".local" / "share" / "brainlayer" / "brainlayer.db"
|
|
54
|
+
env["existing_db"] = default_db.exists()
|
|
55
|
+
|
|
56
|
+
return env
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_wizard() -> WizardConfig:
|
|
60
|
+
"""Run the interactive setup wizard. Returns configuration."""
|
|
61
|
+
from rich.console import Console
|
|
62
|
+
from rich.panel import Panel
|
|
63
|
+
from rich.prompt import Confirm, Prompt
|
|
64
|
+
|
|
65
|
+
console = Console()
|
|
66
|
+
config = WizardConfig()
|
|
67
|
+
|
|
68
|
+
console.print(
|
|
69
|
+
Panel.fit(
|
|
70
|
+
"[bold]BrainLayer Setup Wizard[/bold]\nLike git for your AI conversations.",
|
|
71
|
+
border_style="blue",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
console.print("\n[dim]Detecting your environment...[/dim]")
|
|
76
|
+
env = detect_environment()
|
|
77
|
+
|
|
78
|
+
console.print(f"\n Claude Code conversations: [green]{env['conversation_count']}[/green] JSONL files")
|
|
79
|
+
console.print(
|
|
80
|
+
f" Ollama: {'[green]available[/green]' if env['ollama_available'] else '[yellow]not found[/yellow]'}"
|
|
81
|
+
)
|
|
82
|
+
console.print(f" Apple Silicon (MLX): {'[green]yes[/green]' if env['is_apple_silicon'] else '[dim]no[/dim]'}")
|
|
83
|
+
console.print(f" Existing DB: {'[green]found[/green]' if env['existing_db'] else '[dim]none[/dim]'}")
|
|
84
|
+
|
|
85
|
+
if env["is_apple_silicon"] and env["ollama_available"]:
|
|
86
|
+
backend = Prompt.ask(
|
|
87
|
+
"\nEnrichment backend",
|
|
88
|
+
choices=["ollama", "mlx", "none"],
|
|
89
|
+
default="ollama",
|
|
90
|
+
)
|
|
91
|
+
elif env["ollama_available"]:
|
|
92
|
+
backend = Prompt.ask(
|
|
93
|
+
"\nEnrichment backend",
|
|
94
|
+
choices=["ollama", "none"],
|
|
95
|
+
default="ollama",
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
console.print("\n[yellow]No local LLM found. Enrichment disabled.[/yellow]")
|
|
99
|
+
console.print("[dim]Install Ollama (ollama.ai) for local enrichment.[/dim]")
|
|
100
|
+
backend = "none"
|
|
101
|
+
config.enrich_backend = backend
|
|
102
|
+
|
|
103
|
+
extras = []
|
|
104
|
+
if Confirm.ask("\nInstall style analysis (communication patterns)?", default=False):
|
|
105
|
+
extras.append("style")
|
|
106
|
+
if Confirm.ask("Install YouTube transcript indexing?", default=False):
|
|
107
|
+
extras.append("youtube")
|
|
108
|
+
if Confirm.ask("Install Obsidian export?", default=False):
|
|
109
|
+
extras.append("obsidian")
|
|
110
|
+
config.extras = extras
|
|
111
|
+
|
|
112
|
+
if env["claude_projects_dir"]:
|
|
113
|
+
config.claude_projects_dir = env["claude_projects_dir"]
|
|
114
|
+
else:
|
|
115
|
+
custom = Prompt.ask(
|
|
116
|
+
"Path to Claude Code projects directory",
|
|
117
|
+
default=str(Path.home() / ".claude" / "projects"),
|
|
118
|
+
)
|
|
119
|
+
config.claude_projects_dir = Path(custom)
|
|
120
|
+
|
|
121
|
+
console.print(
|
|
122
|
+
Panel.fit(
|
|
123
|
+
f"[bold green]Setup complete![/bold green]\n\n"
|
|
124
|
+
f" Backend: {config.enrich_backend}\n"
|
|
125
|
+
f" Extras: {', '.join(config.extras) or 'none'}\n"
|
|
126
|
+
f" Source: {config.claude_projects_dir}\n\n"
|
|
127
|
+
f"Run [bold]brainlayer index[/bold] to index your conversations.",
|
|
128
|
+
border_style="green",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return config
|
brainlayer/cli_new.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Updated CLI commands using daemon client."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich import print as rprint
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from .client import get_client
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def search_command(
|
|
16
|
+
query: str = typer.Argument(..., help="Search query"),
|
|
17
|
+
n: int = typer.Option(5, "--num", "-n", help="Number of results", min=1, max=100),
|
|
18
|
+
project: str = typer.Option(None, "--project", "-p", help="Filter by project"),
|
|
19
|
+
content_type: str = typer.Option(None, "--type", "-t", help="Filter by content type"),
|
|
20
|
+
text: bool = typer.Option(False, "--text", help="Use text-based search instead of semantic search"),
|
|
21
|
+
hybrid: bool = True,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Search the knowledge base using fast daemon."""
|
|
24
|
+
try:
|
|
25
|
+
# Auto-detect domain-like queries and use text search
|
|
26
|
+
if not text and ("." in query or query.startswith("http") or "/" in query):
|
|
27
|
+
text = True
|
|
28
|
+
rprint("[dim]Auto-detected domain/URL query, using text search[/]")
|
|
29
|
+
|
|
30
|
+
search_type = "text" if text else ("hybrid" if hybrid else "semantic")
|
|
31
|
+
rprint(f"[bold blue]זיכרון[/] - Searching ({search_type}): [italic]{query}[/]")
|
|
32
|
+
|
|
33
|
+
# Search using daemon
|
|
34
|
+
client = get_client()
|
|
35
|
+
|
|
36
|
+
with console.status("[bold green]Searching..."):
|
|
37
|
+
results = client.search(
|
|
38
|
+
query=query,
|
|
39
|
+
n_results=n,
|
|
40
|
+
project_filter=project,
|
|
41
|
+
content_type_filter=content_type,
|
|
42
|
+
use_semantic=not text,
|
|
43
|
+
hybrid=hybrid and not text,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Display results
|
|
47
|
+
if not results["documents"]:
|
|
48
|
+
rprint("[yellow]No results found[/]")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
search_time = results["total_time_ms"]
|
|
52
|
+
rprint(f"[dim]Found {len(results['documents'])} results in {search_time:.1f}ms[/]\n")
|
|
53
|
+
|
|
54
|
+
result_ids = results.get("ids", [])
|
|
55
|
+
for i, (doc, meta, dist) in enumerate(zip(results["documents"], results["metadatas"], results["distances"])):
|
|
56
|
+
score = 1 - dist if dist is not None else None
|
|
57
|
+
score_str = f"[dim](score: {score:.3f})[/]" if score is not None else "[dim](text match)[/]"
|
|
58
|
+
proj = _clean_project_name(meta.get("project", "unknown"))
|
|
59
|
+
# Show contact name for WhatsApp/messaging sources
|
|
60
|
+
if proj == "unknown" and meta.get("contact_name"):
|
|
61
|
+
proj = meta["contact_name"]
|
|
62
|
+
chunk_id = result_ids[i] if i < len(result_ids) else None
|
|
63
|
+
|
|
64
|
+
# Truncate long content
|
|
65
|
+
content = doc[:500] + "..." if len(doc) > 500 else doc
|
|
66
|
+
|
|
67
|
+
rprint(f"[bold cyan]{i + 1}.[/] {score_str} [dim]({proj})[/]")
|
|
68
|
+
rprint(f"[white]{content}[/]")
|
|
69
|
+
if chunk_id:
|
|
70
|
+
rprint(f"[dim]ID: {chunk_id}[/]")
|
|
71
|
+
rprint()
|
|
72
|
+
|
|
73
|
+
except Exception as e:
|
|
74
|
+
rprint(f"[bold red]Error:[/] {e}")
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def stats_command() -> None:
|
|
79
|
+
"""Show knowledge base statistics."""
|
|
80
|
+
try:
|
|
81
|
+
client = get_client()
|
|
82
|
+
|
|
83
|
+
with console.status("[bold green]Getting stats..."):
|
|
84
|
+
stats = client.get_stats()
|
|
85
|
+
|
|
86
|
+
rprint("[bold blue]זיכרון[/] - Knowledge Base Statistics\n")
|
|
87
|
+
|
|
88
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
89
|
+
table.add_column("Metric", style="cyan")
|
|
90
|
+
table.add_column("Value", style="white")
|
|
91
|
+
|
|
92
|
+
table.add_row("Total Chunks", f"{stats['total_chunks']:,}")
|
|
93
|
+
table.add_row("Projects", str(len(stats["projects"])))
|
|
94
|
+
table.add_row("Content Types", str(len(stats["content_types"])))
|
|
95
|
+
|
|
96
|
+
console.print(table)
|
|
97
|
+
|
|
98
|
+
if stats["projects"]:
|
|
99
|
+
rprint(f"\n[bold]Projects:[/] {', '.join(stats['projects'])}")
|
|
100
|
+
|
|
101
|
+
if stats["content_types"]:
|
|
102
|
+
rprint(f"[bold]Content Types:[/] {', '.join(stats['content_types'])}")
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
rprint(f"[bold red]Error:[/] {e}")
|
|
106
|
+
raise typer.Exit(1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def migrate_command() -> None:
|
|
110
|
+
"""Migrate from ChromaDB to sqlite-vec."""
|
|
111
|
+
try:
|
|
112
|
+
from .migrate import migrate_from_chromadb
|
|
113
|
+
|
|
114
|
+
rprint("[bold blue]זיכרון[/] - Migration Tool\n")
|
|
115
|
+
|
|
116
|
+
sqlite_path = Path.home() / ".local" / "share" / "brainlayer" / "brainlayer.db"
|
|
117
|
+
|
|
118
|
+
if sqlite_path.exists():
|
|
119
|
+
response = typer.confirm("sqlite-vec database already exists. Overwrite?")
|
|
120
|
+
if not response:
|
|
121
|
+
rprint("Migration cancelled")
|
|
122
|
+
return
|
|
123
|
+
sqlite_path.unlink()
|
|
124
|
+
|
|
125
|
+
with console.status("[bold green]Migrating data..."):
|
|
126
|
+
success = migrate_from_chromadb()
|
|
127
|
+
|
|
128
|
+
if success:
|
|
129
|
+
rprint("[bold green]✓[/] Migration completed successfully!")
|
|
130
|
+
rprint("You can now use the fast daemon service.")
|
|
131
|
+
else:
|
|
132
|
+
rprint("[bold red]✗[/] Migration failed or skipped")
|
|
133
|
+
raise typer.Exit(1)
|
|
134
|
+
|
|
135
|
+
except Exception as e:
|
|
136
|
+
rprint(f"[bold red]Error:[/] {e}")
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _clean_project_name(project: str) -> str:
|
|
141
|
+
"""Clean project name for display."""
|
|
142
|
+
if not project or project == "unknown":
|
|
143
|
+
return "unknown"
|
|
144
|
+
|
|
145
|
+
# Remove common prefixes
|
|
146
|
+
if project.startswith("/Users/"):
|
|
147
|
+
parts = project.split("/")
|
|
148
|
+
if len(parts) > 4:
|
|
149
|
+
return "/".join(parts[-2:]) # Last two parts
|
|
150
|
+
|
|
151
|
+
return project
|
brainlayer/client.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Client for communicating with brainlayer daemon."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
SOCKET_PATH = Path("/tmp/brainlayer.sock")
|
|
14
|
+
DAEMON_STARTUP_TIMEOUT = 30 # seconds
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DaemonClient:
|
|
18
|
+
"""Client for brainlayer daemon."""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self.base_url = "http://localhost"
|
|
22
|
+
self._client: Optional[httpx.Client] = None
|
|
23
|
+
|
|
24
|
+
def _get_client(self) -> httpx.Client:
|
|
25
|
+
"""Get HTTP client with Unix socket transport."""
|
|
26
|
+
if self._client is None:
|
|
27
|
+
transport = httpx.HTTPTransport(uds=str(SOCKET_PATH))
|
|
28
|
+
self._client = httpx.Client(base_url=self.base_url, transport=transport, timeout=30.0)
|
|
29
|
+
return self._client
|
|
30
|
+
|
|
31
|
+
def _is_daemon_running(self) -> bool:
|
|
32
|
+
"""Check if daemon is running."""
|
|
33
|
+
if not SOCKET_PATH.exists():
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
client = self._get_client()
|
|
38
|
+
response = client.get("/health")
|
|
39
|
+
return response.status_code == 200
|
|
40
|
+
except Exception:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
def _start_daemon(self) -> bool:
|
|
44
|
+
"""Start daemon process."""
|
|
45
|
+
try:
|
|
46
|
+
# Start daemon in background
|
|
47
|
+
subprocess.Popen(
|
|
48
|
+
["brainlayer-daemon"],
|
|
49
|
+
stdout=subprocess.DEVNULL,
|
|
50
|
+
stderr=subprocess.DEVNULL,
|
|
51
|
+
start_new_session=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Wait for daemon to start
|
|
55
|
+
for _ in range(DAEMON_STARTUP_TIMEOUT):
|
|
56
|
+
if self._is_daemon_running():
|
|
57
|
+
return True
|
|
58
|
+
time.sleep(1)
|
|
59
|
+
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
except Exception as e:
|
|
63
|
+
logger.warning("Failed to start daemon: %s", e)
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
def _ensure_daemon(self) -> bool:
|
|
67
|
+
"""Ensure daemon is running."""
|
|
68
|
+
if self._is_daemon_running():
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
logger.info("Starting brainlayer daemon...")
|
|
72
|
+
return self._start_daemon()
|
|
73
|
+
|
|
74
|
+
def search(
|
|
75
|
+
self,
|
|
76
|
+
query: str,
|
|
77
|
+
n_results: int = 10,
|
|
78
|
+
project_filter: Optional[str] = None,
|
|
79
|
+
content_type_filter: Optional[str] = None,
|
|
80
|
+
source_filter: Optional[str] = None,
|
|
81
|
+
use_semantic: bool = True,
|
|
82
|
+
hybrid: bool = True,
|
|
83
|
+
) -> Dict[str, Any]:
|
|
84
|
+
"""Search the knowledge base."""
|
|
85
|
+
if not self._ensure_daemon():
|
|
86
|
+
raise RuntimeError("Failed to start daemon")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
client = self._get_client()
|
|
90
|
+
response = client.post(
|
|
91
|
+
"/search",
|
|
92
|
+
json={
|
|
93
|
+
"query": query,
|
|
94
|
+
"n_results": n_results,
|
|
95
|
+
"project_filter": project_filter,
|
|
96
|
+
"content_type_filter": content_type_filter,
|
|
97
|
+
"source_filter": source_filter,
|
|
98
|
+
"use_semantic": use_semantic,
|
|
99
|
+
"hybrid": hybrid,
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
response.raise_for_status()
|
|
103
|
+
return response.json()
|
|
104
|
+
|
|
105
|
+
except httpx.RequestError as e:
|
|
106
|
+
raise RuntimeError(f"Failed to communicate with daemon: {e}")
|
|
107
|
+
except httpx.HTTPStatusError as e:
|
|
108
|
+
raise RuntimeError(f"Search failed: {e.response.text}")
|
|
109
|
+
|
|
110
|
+
def get_context(self, chunk_id: str, before: int = 3, after: int = 3) -> Dict[str, Any]:
|
|
111
|
+
"""Get surrounding conversation context for a chunk."""
|
|
112
|
+
if not self._ensure_daemon():
|
|
113
|
+
raise RuntimeError("Failed to start daemon")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
client = self._get_client()
|
|
117
|
+
response = client.get(f"/context/{chunk_id}", params={"before": before, "after": after})
|
|
118
|
+
response.raise_for_status()
|
|
119
|
+
return response.json()
|
|
120
|
+
|
|
121
|
+
except httpx.RequestError as e:
|
|
122
|
+
raise RuntimeError(f"Failed to communicate with daemon: {e}")
|
|
123
|
+
except httpx.HTTPStatusError as e:
|
|
124
|
+
raise RuntimeError(f"Context request failed: {e.response.text}")
|
|
125
|
+
|
|
126
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
127
|
+
"""Get collection statistics."""
|
|
128
|
+
if not self._ensure_daemon():
|
|
129
|
+
raise RuntimeError("Failed to start daemon")
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
client = self._get_client()
|
|
133
|
+
response = client.get("/stats")
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
return response.json()
|
|
136
|
+
|
|
137
|
+
except httpx.RequestError as e:
|
|
138
|
+
raise RuntimeError(f"Failed to communicate with daemon: {e}")
|
|
139
|
+
except httpx.HTTPStatusError as e:
|
|
140
|
+
raise RuntimeError(f"Stats request failed: {e.response.text}")
|
|
141
|
+
|
|
142
|
+
def close(self):
|
|
143
|
+
"""Close client connection."""
|
|
144
|
+
if self._client:
|
|
145
|
+
self._client.close()
|
|
146
|
+
self._client = None
|
|
147
|
+
|
|
148
|
+
def __enter__(self):
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
152
|
+
self.close()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Global client instance
|
|
156
|
+
_client: Optional[DaemonClient] = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_client() -> DaemonClient:
|
|
160
|
+
"""Get global daemon client."""
|
|
161
|
+
global _client
|
|
162
|
+
if _client is None:
|
|
163
|
+
_client = DaemonClient()
|
|
164
|
+
return _client
|