mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Merge branch 'installer'
This commit is contained in:
@@ -97,51 +97,7 @@ def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[V
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def display_validation_errors(issues: List[ValidationIssue]):
|
||||
"""Display validation errors in a rich, organized format."""
|
||||
# Create header
|
||||
console.print()
|
||||
console.print(
|
||||
Panel("[red bold]Error:[/red bold] Invalid frontmatter in knowledge files", expand=False)
|
||||
)
|
||||
console.print()
|
||||
|
||||
# Group issues by directory
|
||||
grouped_issues = group_issues_by_directory(issues)
|
||||
|
||||
# Create tree structure
|
||||
tree = Tree("Knowledge Files")
|
||||
for dir_name, dir_issues in sorted(grouped_issues.items()):
|
||||
# Create branch for directory
|
||||
branch = tree.add(
|
||||
f"[bold blue]{dir_name}/[/bold blue] ([yellow]{len(dir_issues)} files[/yellow])"
|
||||
)
|
||||
|
||||
# Add each file issue
|
||||
for issue in sorted(dir_issues, key=lambda x: x.file_path):
|
||||
file_name = Path(issue.file_path).name
|
||||
branch.add(
|
||||
Text.assemble(("└─ ", "dim"), (file_name, "yellow"), ": ", (issue.error, "red"))
|
||||
)
|
||||
|
||||
# Display tree
|
||||
console.print(Padding(tree, (1, 2)))
|
||||
|
||||
# Add help text
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
Text.assemble(
|
||||
("To fix:", "bold"),
|
||||
"\n1. Add required frontmatter fields to each file",
|
||||
"\n2. Run ",
|
||||
("basic-memory sync", "bold cyan"),
|
||||
" again",
|
||||
),
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def display_sync_summary(knowledge: SyncReport):
|
||||
|
||||
@@ -24,7 +24,6 @@ from basic_memory.schemas.request import (
|
||||
SearchNodesRequest,
|
||||
GetEntitiesRequest,
|
||||
CreateRelationsRequest,
|
||||
UpdateEntityRequest,
|
||||
)
|
||||
|
||||
# Response models
|
||||
@@ -57,7 +56,6 @@ __all__ = [
|
||||
"SearchNodesRequest",
|
||||
"GetEntitiesRequest",
|
||||
"CreateRelationsRequest",
|
||||
"UpdateEntityRequest",
|
||||
# Responses
|
||||
"SQLAlchemyModel",
|
||||
"ObservationResponse",
|
||||
|
||||
@@ -58,15 +58,3 @@ class GetEntitiesRequest(BaseModel):
|
||||
class CreateRelationsRequest(BaseModel):
|
||||
relations: List[Relation]
|
||||
|
||||
|
||||
## update
|
||||
|
||||
|
||||
# TODO remove UpdateEntityRequest
|
||||
class UpdateEntityRequest(BaseModel):
|
||||
"""Request to update an existing entity."""
|
||||
|
||||
title: Optional[str] = None
|
||||
entity_type: Optional[EntityType] = None
|
||||
content: Optional[str] = None
|
||||
entity_metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
@@ -149,7 +149,6 @@ class SearchService:
|
||||
title_variants = self._generate_variants(entity.title)
|
||||
content_parts.extend(title_variants)
|
||||
|
||||
# TODO should we do something to content on indexing?
|
||||
content = await self.file_service.read_entity_content(entity)
|
||||
if content:
|
||||
content_parts.append(content)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from .file_change_scanner import FileChangeScanner
|
||||
from .sync_service import SyncService
|
||||
from .watch_service import WatchService
|
||||
|
||||
__all__ = ["SyncService", "FileChangeScanner", "WatchService"]
|
||||
|
||||
__all__ = ["SyncService", "FileChangeScanner"]
|
||||
|
||||
@@ -130,13 +130,36 @@ class WatchService:
|
||||
|
||||
return table
|
||||
|
||||
async def run(self):
|
||||
async def run(self, console_status: bool = False): # pragma: no cover
|
||||
"""Watch for file changes and sync them"""
|
||||
logger.info("Watching for sync changes")
|
||||
self.state.running = True
|
||||
self.state.start_time = datetime.now()
|
||||
await self.write_status()
|
||||
|
||||
with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live:
|
||||
if console_status:
|
||||
with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live:
|
||||
try:
|
||||
async for changes in awatch(
|
||||
self.config.home,
|
||||
watch_filter=self.filter_changes,
|
||||
debounce=self.config.sync_delay,
|
||||
recursive=True,
|
||||
):
|
||||
# Process changes
|
||||
await self.handle_changes(self.config.home)
|
||||
# Update display
|
||||
live.update(self.generate_table())
|
||||
|
||||
except Exception as e:
|
||||
self.state.record_error(str(e))
|
||||
await self.write_status()
|
||||
raise
|
||||
finally:
|
||||
self.state.running = False
|
||||
await self.write_status()
|
||||
|
||||
else:
|
||||
try:
|
||||
async for changes in awatch(
|
||||
self.config.home,
|
||||
@@ -145,11 +168,10 @@ class WatchService:
|
||||
recursive=True,
|
||||
):
|
||||
# Process changes
|
||||
await self.handle_changes(self.config.home) # pragma: no cover
|
||||
await self.handle_changes(self.config.home)
|
||||
# Update display
|
||||
live.update(self.generate_table()) # pragma: no cover
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
except Exception as e:
|
||||
self.state.record_error(str(e))
|
||||
await self.write_status()
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Basic Memory menubar application package."""
|
||||
from .app import BasicMemoryApp, main
|
||||
|
||||
__all__ = ["BasicMemoryApp", "main"]
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Basic Memory menubar app - process manager for sync service."""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QStyle, QMainWindow, QTextEdit
|
||||
from PyQt6.QtCore import QTimer
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import config
|
||||
|
||||
|
||||
class StatusWindow(QMainWindow):
|
||||
"""Window to show sync process output."""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Basic Memory Status")
|
||||
self.resize(800, 400)
|
||||
|
||||
# Create terminal-like text display
|
||||
self.text_display = QTextEdit()
|
||||
self.text_display.setReadOnly(True)
|
||||
self.text_display.setStyleSheet("""
|
||||
QTextEdit {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
font-family: Menlo, Monaco, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
self.setCentralWidget(self.text_display)
|
||||
|
||||
# Update timer for reading process output
|
||||
self.update_timer = QTimer()
|
||||
self.update_timer.timeout.connect(self.update_output)
|
||||
self.update_timer.start(100) # Check output every 100ms
|
||||
|
||||
def set_process(self, process):
|
||||
"""Set the process to monitor."""
|
||||
self.process = process
|
||||
self.text_display.clear()
|
||||
|
||||
def update_output(self):
|
||||
"""Read and display new output from process."""
|
||||
if hasattr(self, 'process') and self.process:
|
||||
while True:
|
||||
line = self.process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
self.text_display.append(line.strip())
|
||||
|
||||
class BasicMemoryTray(QSystemTrayIcon):
|
||||
"""System tray icon for managing Basic Memory sync."""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# Set icon
|
||||
self.setIcon(QApplication.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon))
|
||||
|
||||
# Create menu
|
||||
menu = QMenu()
|
||||
self.status_item = menu.addAction("Status: Idle")
|
||||
self.status_item.setEnabled(False)
|
||||
|
||||
menu.addSeparator()
|
||||
self.sync_action = menu.addAction("Start Sync")
|
||||
self.sync_action.triggered.connect(self.toggle_sync)
|
||||
|
||||
menu.addAction("Show Status").triggered.connect(self.show_status)
|
||||
|
||||
menu.addSeparator()
|
||||
menu.addAction("Quit").triggered.connect(self.cleanup_and_quit)
|
||||
|
||||
self.setContextMenu(menu)
|
||||
self.show()
|
||||
|
||||
# Initialize state
|
||||
self.process = None
|
||||
self.status_window = None
|
||||
|
||||
# Update timer for status
|
||||
self.status_timer = QTimer()
|
||||
self.status_timer.timeout.connect(self.check_process)
|
||||
self.status_timer.start(1000) # Check every second
|
||||
|
||||
logger.info("Basic Memory tray initialized")
|
||||
|
||||
def show_status(self):
|
||||
"""Show status window."""
|
||||
if not self.status_window:
|
||||
self.status_window = StatusWindow()
|
||||
if self.process:
|
||||
self.status_window.set_process(self.process)
|
||||
self.status_window.show()
|
||||
self.status_window.raise_()
|
||||
|
||||
def toggle_sync(self):
|
||||
"""Start or stop sync process."""
|
||||
if not self.process:
|
||||
self.start_sync()
|
||||
else:
|
||||
self.stop_sync()
|
||||
|
||||
def start_sync(self):
|
||||
"""Start the sync subprocess."""
|
||||
logger.info("Starting sync process")
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
['uvx', ' --directory', '/Users/phernandez/dev/basicmachines/basic-memory', 'basic-memory', 'sync', '--watch'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1 # Line buffered
|
||||
)
|
||||
self.sync_action.setText("Stop Sync")
|
||||
if self.status_window:
|
||||
self.status_window.set_process(self.process)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to start sync process")
|
||||
self.status_item.setText(f"Status: Error - {str(e)}")
|
||||
|
||||
def stop_sync(self):
|
||||
"""Stop the sync subprocess."""
|
||||
logger.info("Stopping sync process")
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=2) # Wait up to 2 seconds
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill() # Force kill if it doesn't terminate
|
||||
self.process = None
|
||||
self.sync_action.setText("Start Sync")
|
||||
|
||||
def check_process(self):
|
||||
"""Check sync process status."""
|
||||
if self.process:
|
||||
if self.process.poll() is not None: # Process has ended
|
||||
self.process = None
|
||||
self.sync_action.setText("Start Sync")
|
||||
self.status_item.setText("Status: Idle")
|
||||
else:
|
||||
self.status_item.setText("Status: Running")
|
||||
else:
|
||||
self.status_item.setText("Status: Idle")
|
||||
|
||||
def cleanup_and_quit(self):
|
||||
"""Clean up before quitting."""
|
||||
logger.info("Cleaning up...")
|
||||
if self.status_window:
|
||||
self.status_window.close()
|
||||
self.stop_sync()
|
||||
QApplication.instance().quit()
|
||||
|
||||
def ensure_single_instance():
|
||||
"""Ensure only one instance of the menubar app is running."""
|
||||
pid_file = config.home / ".basic-memory" / "menubar.pid"
|
||||
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if pid_file.exists():
|
||||
try:
|
||||
pid = int(pid_file.read_text())
|
||||
os.kill(pid, 0) # Check if process exists
|
||||
logger.error(f"Basic Memory menubar is already running (PID: {pid})")
|
||||
sys.exit(1)
|
||||
except (OSError, ValueError):
|
||||
# Process not running or invalid PID
|
||||
pass
|
||||
|
||||
# Write current PID
|
||||
pid = str(os.getpid())
|
||||
pid_file.write_text(pid)
|
||||
logger.debug(f"PID {pid} written to file {pid_file}")
|
||||
return pid_file
|
||||
|
||||
def main():
|
||||
"""Run the menubar app."""
|
||||
# Ensure single instance
|
||||
pid_file = ensure_single_instance()
|
||||
|
||||
try:
|
||||
app = QApplication(sys.argv)
|
||||
tray = BasicMemoryTray()
|
||||
app.exec()
|
||||
finally:
|
||||
# Clean up PID file
|
||||
pid_file.unlink(missing_ok=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user