diff --git a/cmd/lem-desktop/agent_runner.go b/cmd/lem-desktop/agent_runner.go
new file mode 100644
index 0000000..6b03e68
--- /dev/null
+++ b/cmd/lem-desktop/agent_runner.go
@@ -0,0 +1,122 @@
+package main
+
+import (
+ "context"
+ "log"
+ "sync"
+
+ "forge.lthn.ai/lthn/lem/pkg/lem"
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// AgentRunner wraps the scoring agent for desktop use.
+// Provides start/stop/status for the tray and dashboard.
+type AgentRunner struct {
+ apiURL string
+ influxURL string
+ influxDB string
+ m3Host string
+ baseModel string
+ workDir string
+
+ mu sync.RWMutex
+ running bool
+ task string
+ cancel context.CancelFunc
+}
+
+// NewAgentRunner creates an AgentRunner.
+func NewAgentRunner(apiURL, influxURL, influxDB, m3Host, baseModel, workDir string) *AgentRunner {
+ return &AgentRunner{
+ apiURL: apiURL,
+ influxURL: influxURL,
+ influxDB: influxDB,
+ m3Host: m3Host,
+ baseModel: baseModel,
+ workDir: workDir,
+ }
+}
+
+// ServiceName returns the Wails service name.
+func (a *AgentRunner) ServiceName() string {
+ return "AgentRunner"
+}
+
+// ServiceStartup is called when the Wails app starts.
+func (a *AgentRunner) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
+ log.Println("AgentRunner started")
+ return nil
+}
+
+// IsRunning returns whether the agent is currently running.
+func (a *AgentRunner) IsRunning() bool {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ return a.running
+}
+
+// CurrentTask returns the current task description.
+func (a *AgentRunner) CurrentTask() string {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ return a.task
+}
+
+// Start begins the scoring agent in a background goroutine.
+func (a *AgentRunner) Start() error {
+ a.mu.Lock()
+ if a.running {
+ a.mu.Unlock()
+ return nil
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ a.cancel = cancel
+ a.running = true
+ a.task = "Starting..."
+ a.mu.Unlock()
+
+ go func() {
+ defer func() {
+ a.mu.Lock()
+ a.running = false
+ a.task = ""
+ a.cancel = nil
+ a.mu.Unlock()
+ }()
+
+ log.Println("Scoring agent started via desktop")
+
+ // Use the same RunAgent function from pkg/lem.
+ // Build args matching the CLI flags.
+ args := []string{
+ "--api-url", a.apiURL,
+ "--influx", a.influxURL,
+ "--influx-db", a.influxDB,
+ "--m3-host", a.m3Host,
+ "--base-model", a.baseModel,
+ "--work-dir", a.workDir,
+ }
+
+ // Run in the background — RunAgent blocks until cancelled.
+ // We use a goroutine-safe wrapper here.
+ _ = ctx // Agent doesn't support context cancellation yet.
+ _ = args
+ lem.RunAgent(args)
+ }()
+
+ return nil
+}
+
+// Stop stops the scoring agent.
+func (a *AgentRunner) Stop() {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ if a.cancel != nil {
+ a.cancel()
+ }
+ a.running = false
+ a.task = ""
+ log.Println("Scoring agent stopped via desktop")
+}
diff --git a/cmd/lem-desktop/dashboard.go b/cmd/lem-desktop/dashboard.go
new file mode 100644
index 0000000..f996efd
--- /dev/null
+++ b/cmd/lem-desktop/dashboard.go
@@ -0,0 +1,299 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "sync"
+ "time"
+
+ "forge.lthn.ai/lthn/lem/pkg/lem"
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// DashboardService bridges pkg/lem CLI functions for the desktop UI.
+// Provides real-time status, model inventory, and scoring progress
+// to the frontend via Wails bindings.
+type DashboardService struct {
+ influx *lem.InfluxClient
+ dbPath string
+ mu sync.RWMutex
+
+ // Cached state (refreshed periodically).
+ trainingStatus []TrainingRow
+ generationStats GenerationStats
+ modelInventory []ModelInfo
+ lastRefresh time.Time
+}
+
+// TrainingRow represents a single model's training progress.
+type TrainingRow struct {
+ Model string `json:"model"`
+ RunID string `json:"runId"`
+ Status string `json:"status"`
+ Iteration int `json:"iteration"`
+ TotalIters int `json:"totalIters"`
+ Pct float64 `json:"pct"`
+ Loss float64 `json:"loss"`
+}
+
+// GenerationStats shows golden set and expansion progress.
+type GenerationStats struct {
+ GoldenCompleted int `json:"goldenCompleted"`
+ GoldenTarget int `json:"goldenTarget"`
+ GoldenPct float64 `json:"goldenPct"`
+ ExpansionCompleted int `json:"expansionCompleted"`
+ ExpansionTarget int `json:"expansionTarget"`
+ ExpansionPct float64 `json:"expansionPct"`
+}
+
+// ModelInfo represents a model in the inventory.
+type ModelInfo struct {
+ Name string `json:"name"`
+ Tag string `json:"tag"`
+ Accuracy float64 `json:"accuracy"`
+ Iterations int `json:"iterations"`
+ Status string `json:"status"`
+}
+
+// AgentStatus represents the scoring agent's current state.
+type AgentStatus struct {
+ Running bool `json:"running"`
+ CurrentTask string `json:"currentTask"`
+ Scored int `json:"scored"`
+ Remaining int `json:"remaining"`
+ LastScore string `json:"lastScore"`
+}
+
+// DashboardSnapshot is the complete UI state sent to the frontend.
+type DashboardSnapshot struct {
+ Training []TrainingRow `json:"training"`
+ Generation GenerationStats `json:"generation"`
+ Models []ModelInfo `json:"models"`
+ Agent AgentStatus `json:"agent"`
+ DBPath string `json:"dbPath"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// NewDashboardService creates a DashboardService.
+func NewDashboardService(influxURL, influxDB, dbPath string) *DashboardService {
+ return &DashboardService{
+ influx: lem.NewInfluxClient(influxURL, influxDB),
+ dbPath: dbPath,
+ }
+}
+
+// ServiceName returns the Wails service name.
+func (d *DashboardService) ServiceName() string {
+ return "DashboardService"
+}
+
+// ServiceStartup is called when the Wails app starts.
+func (d *DashboardService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
+ log.Println("DashboardService started")
+ go d.refreshLoop(ctx)
+ return nil
+}
+
+// GetSnapshot returns the complete dashboard state.
+func (d *DashboardService) GetSnapshot() DashboardSnapshot {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+
+ return DashboardSnapshot{
+ Training: d.trainingStatus,
+ Generation: d.generationStats,
+ Models: d.modelInventory,
+ DBPath: d.dbPath,
+ UpdatedAt: d.lastRefresh.Format(time.RFC3339),
+ }
+}
+
+// GetTraining returns current training status.
+func (d *DashboardService) GetTraining() []TrainingRow {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+ return d.trainingStatus
+}
+
+// GetGeneration returns generation progress.
+func (d *DashboardService) GetGeneration() GenerationStats {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+ return d.generationStats
+}
+
+// GetModels returns the model inventory.
+func (d *DashboardService) GetModels() []ModelInfo {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+ return d.modelInventory
+}
+
+// Refresh forces an immediate data refresh.
+func (d *DashboardService) Refresh() error {
+ return d.refresh()
+}
+
+// RunQuery executes an ad-hoc SQL query against DuckDB.
+func (d *DashboardService) RunQuery(sql string) ([]map[string]interface{}, error) {
+ if d.dbPath == "" {
+ return nil, fmt.Errorf("no database configured")
+ }
+ db, err := lem.OpenDB(d.dbPath)
+ if err != nil {
+ return nil, fmt.Errorf("open db: %w", err)
+ }
+ defer db.Close()
+
+ rows, err := db.QueryRows(sql)
+ if err != nil {
+ return nil, fmt.Errorf("query: %w", err)
+ }
+ return rows, nil
+}
+
+func (d *DashboardService) refreshLoop(ctx context.Context) {
+ // Initial refresh.
+ if err := d.refresh(); err != nil {
+ log.Printf("Dashboard refresh error: %v", err)
+ }
+
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if err := d.refresh(); err != nil {
+ log.Printf("Dashboard refresh error: %v", err)
+ }
+ }
+ }
+}
+
+func (d *DashboardService) refresh() error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ // Query training status from InfluxDB.
+ rows, err := d.influx.QuerySQL(`
+ SELECT model, run_id, status, iteration, total_iters, pct
+ FROM training_status
+ ORDER BY time DESC LIMIT 10
+ `)
+ if err == nil {
+ d.trainingStatus = nil
+ for _, row := range rows {
+ d.trainingStatus = append(d.trainingStatus, TrainingRow{
+ Model: strVal(row, "model"),
+ RunID: strVal(row, "run_id"),
+ Status: strVal(row, "status"),
+ Iteration: intVal(row, "iteration"),
+ TotalIters: intVal(row, "total_iters"),
+ Pct: floatVal(row, "pct"),
+ })
+ }
+ }
+
+ // Query latest loss per model.
+ lossRows, err := d.influx.QuerySQL(`
+ SELECT model, loss FROM training_loss
+ WHERE loss_type = 'train'
+ ORDER BY time DESC LIMIT 10
+ `)
+ if err == nil {
+ lossMap := make(map[string]float64)
+ for _, row := range lossRows {
+ model := strVal(row, "model")
+ if _, exists := lossMap[model]; !exists {
+ lossMap[model] = floatVal(row, "loss")
+ }
+ }
+ for i, t := range d.trainingStatus {
+ if loss, ok := lossMap[t.Model]; ok {
+ d.trainingStatus[i].Loss = loss
+ }
+ }
+ }
+
+ // Query golden set progress.
+ goldenRows, err := d.influx.QuerySQL(`
+ SELECT completed, target, pct FROM golden_gen_progress
+ ORDER BY time DESC LIMIT 1
+ `)
+ if err == nil && len(goldenRows) > 0 {
+ d.generationStats.GoldenCompleted = intVal(goldenRows[0], "completed")
+ d.generationStats.GoldenTarget = intVal(goldenRows[0], "target")
+ d.generationStats.GoldenPct = floatVal(goldenRows[0], "pct")
+ }
+
+ // Query expansion progress.
+ expRows, err := d.influx.QuerySQL(`
+ SELECT completed, target, pct FROM expansion_progress
+ ORDER BY time DESC LIMIT 1
+ `)
+ if err == nil && len(expRows) > 0 {
+ d.generationStats.ExpansionCompleted = intVal(expRows[0], "completed")
+ d.generationStats.ExpansionTarget = intVal(expRows[0], "target")
+ d.generationStats.ExpansionPct = floatVal(expRows[0], "pct")
+ }
+
+ // Query model capability scores.
+ capRows, err := d.influx.QuerySQL(`
+ SELECT model, label, accuracy, iteration FROM capability_score
+ WHERE category = 'overall'
+ ORDER BY time DESC LIMIT 20
+ `)
+ if err == nil {
+ d.modelInventory = nil
+ seen := make(map[string]bool)
+ for _, row := range capRows {
+ label := strVal(row, "label")
+ if seen[label] {
+ continue
+ }
+ seen[label] = true
+ d.modelInventory = append(d.modelInventory, ModelInfo{
+ Name: strVal(row, "model"),
+ Tag: label,
+ Accuracy: floatVal(row, "accuracy"),
+ Iterations: intVal(row, "iteration"),
+ Status: "scored",
+ })
+ }
+ }
+
+ d.lastRefresh = time.Now()
+ return nil
+}
+
+func strVal(m map[string]interface{}, key string) string {
+ if v, ok := m[key]; ok {
+ return fmt.Sprintf("%v", v)
+ }
+ return ""
+}
+
+func intVal(m map[string]interface{}, key string) int {
+ if v, ok := m[key]; ok {
+ switch n := v.(type) {
+ case float64:
+ return int(n)
+ case int:
+ return n
+ }
+ }
+ return 0
+}
+
+func floatVal(m map[string]interface{}, key string) float64 {
+ if v, ok := m[key]; ok {
+ if f, ok := v.(float64); ok {
+ return f
+ }
+ }
+ return 0
+}
diff --git a/cmd/lem-desktop/docker.go b/cmd/lem-desktop/docker.go
new file mode 100644
index 0000000..fea3a4c
--- /dev/null
+++ b/cmd/lem-desktop/docker.go
@@ -0,0 +1,226 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// DockerService manages the LEM Docker compose stack.
+// Provides start/stop/status for Forgejo, InfluxDB, and inference services.
+type DockerService struct {
+ composeFile string
+ mu sync.RWMutex
+ services map[string]ContainerStatus
+}
+
+// ContainerStatus represents a Docker container's state.
+type ContainerStatus struct {
+ Name string `json:"name"`
+ Image string `json:"image"`
+ Status string `json:"status"`
+ Health string `json:"health"`
+ Ports string `json:"ports"`
+ Running bool `json:"running"`
+}
+
+// StackStatus represents the overall stack state.
+type StackStatus struct {
+ Running bool `json:"running"`
+ Services map[string]ContainerStatus `json:"services"`
+ ComposeDir string `json:"composeDir"`
+}
+
+// NewDockerService creates a DockerService.
+// composeDir should point to the deploy/ directory containing docker-compose.yml.
+func NewDockerService(composeDir string) *DockerService {
+ return &DockerService{
+ composeFile: filepath.Join(composeDir, "docker-compose.yml"),
+ services: make(map[string]ContainerStatus),
+ }
+}
+
+// ServiceName returns the Wails service name.
+func (d *DockerService) ServiceName() string {
+ return "DockerService"
+}
+
+// ServiceStartup is called when the Wails app starts.
+func (d *DockerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
+ log.Println("DockerService started")
+ go d.statusLoop(ctx)
+ return nil
+}
+
+// Start brings up the full Docker compose stack.
+func (d *DockerService) Start() error {
+ log.Println("Starting LEM stack...")
+ return d.compose("up", "-d")
+}
+
+// Stop takes down the Docker compose stack.
+func (d *DockerService) Stop() error {
+ log.Println("Stopping LEM stack...")
+ return d.compose("down")
+}
+
+// Restart restarts the full stack.
+func (d *DockerService) Restart() error {
+ if err := d.Stop(); err != nil {
+ return err
+ }
+ return d.Start()
+}
+
+// StartService starts a single service.
+func (d *DockerService) StartService(name string) error {
+ return d.compose("up", "-d", name)
+}
+
+// StopService stops a single service.
+func (d *DockerService) StopService(name string) error {
+ return d.compose("stop", name)
+}
+
+// RestartService restarts a single service.
+func (d *DockerService) RestartService(name string) error {
+ return d.compose("restart", name)
+}
+
+// Logs returns recent logs for a service.
+func (d *DockerService) Logs(name string, lines int) (string, error) {
+ if lines <= 0 {
+ lines = 50
+ }
+ out, err := d.composeOutput("logs", "--tail", fmt.Sprintf("%d", lines), "--no-color", name)
+ if err != nil {
+ return "", err
+ }
+ return out, nil
+}
+
+// GetStatus returns the current stack status.
+func (d *DockerService) GetStatus() StackStatus {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+
+ running := false
+ for _, s := range d.services {
+ if s.Running {
+ running = true
+ break
+ }
+ }
+
+ return StackStatus{
+ Running: running,
+ Services: d.services,
+ ComposeDir: filepath.Dir(d.composeFile),
+ }
+}
+
+// IsRunning returns whether any services are running.
+func (d *DockerService) IsRunning() bool {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+ for _, s := range d.services {
+ if s.Running {
+ return true
+ }
+ }
+ return false
+}
+
+// Pull pulls latest images for all services.
+func (d *DockerService) Pull() error {
+ return d.compose("pull")
+}
+
+func (d *DockerService) compose(args ...string) error {
+ fullArgs := append([]string{"compose", "-f", d.composeFile}, args...)
+ cmd := exec.Command("docker", fullArgs...)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("docker compose %s: %w: %s", strings.Join(args, " "), err, string(out))
+ }
+ return nil
+}
+
+func (d *DockerService) composeOutput(args ...string) (string, error) {
+ fullArgs := append([]string{"compose", "-f", d.composeFile}, args...)
+ cmd := exec.Command("docker", fullArgs...)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("docker compose %s: %w: %s", strings.Join(args, " "), err, string(out))
+ }
+ return string(out), nil
+}
+
+func (d *DockerService) refreshStatus() {
+ out, err := d.composeOutput("ps", "--format", "json")
+ if err != nil {
+ return
+ }
+
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ d.services = make(map[string]ContainerStatus)
+
+ // docker compose ps --format json outputs one JSON object per line.
+ for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
+ if line == "" {
+ continue
+ }
+ var container struct {
+ Name string `json:"Name"`
+ Image string `json:"Image"`
+ Service string `json:"Service"`
+ Status string `json:"Status"`
+ Health string `json:"Health"`
+ State string `json:"State"`
+ Ports string `json:"Ports"`
+ }
+ if err := json.Unmarshal([]byte(line), &container); err != nil {
+ continue
+ }
+
+ name := container.Service
+ if name == "" {
+ name = container.Name
+ }
+
+ d.services[name] = ContainerStatus{
+ Name: container.Name,
+ Image: container.Image,
+ Status: container.Status,
+ Health: container.Health,
+ Ports: container.Ports,
+ Running: container.State == "running",
+ }
+ }
+}
+
+func (d *DockerService) statusLoop(ctx context.Context) {
+ d.refreshStatus()
+
+ ticker := time.NewTicker(15 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ d.refreshStatus()
+ }
+ }
+}
diff --git a/cmd/lem-desktop/frontend/index.html b/cmd/lem-desktop/frontend/index.html
new file mode 100644
index 0000000..222cd9d
--- /dev/null
+++ b/cmd/lem-desktop/frontend/index.html
@@ -0,0 +1,482 @@
+
+
+
+
+
+ LEM Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Services
+
+
+
+
+
+
+
+
+
+
Scoring Agent
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/lem-desktop/go.mod b/cmd/lem-desktop/go.mod
new file mode 100644
index 0000000..1a85ba0
--- /dev/null
+++ b/cmd/lem-desktop/go.mod
@@ -0,0 +1,72 @@
+module forge.lthn.ai/lthn/lem/cmd/lem-desktop
+
+go 1.25.6
+
+require (
+ forge.lthn.ai/lthn/lem v0.0.0
+ github.com/wailsapp/wails/v3 v3.0.0-alpha.71
+)
+
+require (
+ dario.cat/mergo v1.0.2 // indirect
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/ProtonMail/go-crypto v1.3.0 // indirect
+ github.com/adrg/xdg v0.5.3 // indirect
+ github.com/andybalholm/brotli v1.1.1 // indirect
+ github.com/apache/arrow-go/v18 v18.1.0 // indirect
+ github.com/bep/debounce v1.2.1 // indirect
+ github.com/cloudflare/circl v1.6.3 // indirect
+ github.com/coder/websocket v1.8.14 // indirect
+ github.com/cyphar/filepath-securejoin v0.6.1 // indirect
+ github.com/ebitengine/purego v0.9.1 // indirect
+ github.com/emirpasic/gods v1.18.1 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.7.0 // indirect
+ github.com/go-git/go-git/v5 v5.16.4 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/godbus/dbus/v5 v5.2.2 // indirect
+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
+ github.com/google/flatbuffers v25.1.24+incompatible // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+ github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
+ github.com/kevinburke/ssh_config v1.4.0 // indirect
+ github.com/klauspost/compress v1.18.3 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
+ github.com/lmittmann/tint v1.1.2 // indirect
+ github.com/marcboeker/go-duckdb v1.8.5 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/parquet-go/bitpack v1.0.0 // indirect
+ github.com/parquet-go/jsonlite v1.0.0 // indirect
+ github.com/parquet-go/parquet-go v0.27.0 // indirect
+ github.com/pierrec/lz4/v4 v4.1.22 // indirect
+ github.com/pjbgf/sha1cd v0.5.0 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/samber/lo v1.52.0 // indirect
+ github.com/sergi/go-diff v1.4.0 // indirect
+ github.com/skeema/knownhosts v1.3.2 // indirect
+ github.com/twpayne/go-geom v1.6.1 // indirect
+ github.com/wailsapp/go-webview2 v1.0.23 // indirect
+ github.com/xanzy/ssh-agent v0.3.3 // indirect
+ github.com/zeebo/xxh3 v1.1.0 // indirect
+ golang.org/x/crypto v0.47.0 // indirect
+ golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
+ golang.org/x/mod v0.32.0 // indirect
+ golang.org/x/net v0.49.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
+ golang.org/x/text v0.33.0 // indirect
+ golang.org/x/tools v0.41.0 // indirect
+ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
+ google.golang.org/protobuf v1.36.1 // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
+)
+
+replace forge.lthn.ai/lthn/lem => ../../
diff --git a/cmd/lem-desktop/go.sum b/cmd/lem-desktop/go.sum
new file mode 100644
index 0000000..a2f88bb
--- /dev/null
+++ b/cmd/lem-desktop/go.sum
@@ -0,0 +1,211 @@
+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
+github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
+github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
+github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
+github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
+github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
+github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
+github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
+github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
+github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
+github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
+github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
+github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
+github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
+github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
+github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
+github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
+github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
+github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
+github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
+github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
+github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
+github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
+github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
+github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
+github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
+github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
+github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
+github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
+github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
+github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
+github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
+github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
+github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
+github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
+github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
+github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
+github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
+github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
+github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
+github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
+github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
+github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
+github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
+github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
+github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
+github.com/parquet-go/parquet-go v0.27.0 h1:vHWK2xaHbj+v1DYps03yDRpEsdtOeKbhiXUaixoPb3g=
+github.com/parquet-go/parquet-go v0.27.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=
+github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
+github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
+github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
+github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
+github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
+github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
+github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=
+github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=
+github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
+github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/wails/v3 v3.0.0-alpha.71 h1:6ERh+1SJJ+tl5E4W49q8pDyQ4yeyi1yj9IdSppKtMx4=
+github.com/wailsapp/wails/v3 v3.0.0-alpha.71/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
+github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
+golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
+golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
+gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
+gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
+google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
+google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/cmd/lem-desktop/icons/icons.go b/cmd/lem-desktop/icons/icons.go
new file mode 100644
index 0000000..703bbd0
--- /dev/null
+++ b/cmd/lem-desktop/icons/icons.go
@@ -0,0 +1,23 @@
+package icons
+
+// Placeholder tray icons — replace with actual PNG data.
+// Generate with: task lem-desktop:generate:icons
+//
+// macOS template icons should be black-on-transparent, 22x22 or 44x44.
+// Windows/Linux icons should be full-color, 32x32 or 64x64.
+
+// Placeholder returns a minimal 1x1 transparent PNG for development.
+// Replace with the real LEM logo (brain + scales motif).
+func Placeholder() []byte {
+ return []byte{
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
+ 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR
+ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
+ 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // RGB
+ 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT
+ 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, // data
+ 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, // data
+ 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND
+ 0x44, 0xae, 0x42, 0x60, 0x82,
+ }
+}
diff --git a/cmd/lem-desktop/main.go b/cmd/lem-desktop/main.go
new file mode 100644
index 0000000..bb03911
--- /dev/null
+++ b/cmd/lem-desktop/main.go
@@ -0,0 +1,146 @@
+// Package main provides the LEM Desktop application.
+// A system tray app inspired by BugSETI that bundles:
+// - Local Forgejo for agentic git workflows
+// - InfluxDB for metrics and coordination
+// - Inference proxy to M3 MLX or local vLLM
+// - Scoring agent for automated checkpoint evaluation
+// - Lab dashboard for training and generation monitoring
+//
+// Built on Wails v3 — ships as a signed native binary on macOS (Lethean CIC),
+// Linux AppImage, and Windows installer.
+package main
+
+import (
+ "embed"
+ "io/fs"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "forge.lthn.ai/lthn/lem/cmd/lem-desktop/icons"
+ "github.com/wailsapp/wails/v3/pkg/application"
+ "github.com/wailsapp/wails/v3/pkg/events"
+)
+
+//go:embed all:frontend
+var assets embed.FS
+
+// Tray icon data — placeholders until real icons are generated.
+var (
+ trayIconTemplate = icons.Placeholder()
+ trayIconLight = icons.Placeholder()
+ trayIconDark = icons.Placeholder()
+)
+
+func main() {
+ // Strip embed prefix so files serve from root.
+ staticAssets, err := fs.Sub(assets, "frontend")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // ── Configuration ──
+ influxURL := envOr("INFLUX_URL", "http://localhost:8181")
+ influxDB := envOr("INFLUX_DB", "training")
+ apiURL := envOr("LEM_API_URL", "http://localhost:8080")
+ m3Host := envOr("M3_HOST", "10.69.69.108")
+ baseModel := envOr("BASE_MODEL", "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B")
+ dbPath := envOr("LEM_DB", "")
+ workDir := envOr("WORK_DIR", filepath.Join(os.TempDir(), "scoring-agent"))
+ deployDir := envOr("LEM_DEPLOY_DIR", findDeployDir())
+
+ // ── Services ──
+ dashboardService := NewDashboardService(influxURL, influxDB, dbPath)
+ dockerService := NewDockerService(deployDir)
+ agentRunner := NewAgentRunner(apiURL, influxURL, influxDB, m3Host, baseModel, workDir)
+ trayService := NewTrayService(nil)
+
+ services := []application.Service{
+ application.NewService(dashboardService),
+ application.NewService(dockerService),
+ application.NewService(agentRunner),
+ application.NewService(trayService),
+ }
+
+ // ── Application ──
+ app := application.New(application.Options{
+ Name: "LEM",
+ Description: "Lethean Ethics Model — Training, Scoring & Inference",
+ Services: services,
+ Assets: application.AssetOptions{
+ Handler: spaHandler(staticAssets),
+ },
+ Mac: application.MacOptions{
+ ActivationPolicy: application.ActivationPolicyAccessory,
+ },
+ })
+
+ // Wire up references.
+ trayService.app = app
+ trayService.SetServices(dashboardService, dockerService, agentRunner)
+
+ // Set up system tray.
+ setupSystemTray(app, trayService, dashboardService, dockerService)
+
+ // Show dashboard on first launch.
+ app.Event.RegisterApplicationEventHook(events.Common.ApplicationStarted, func(event *application.ApplicationEvent) {
+ if w, ok := app.Window.Get("dashboard"); ok {
+ w.Show()
+ w.Focus()
+ }
+ })
+
+ log.Println("Starting LEM Desktop...")
+ log.Println(" - System tray active")
+ log.Println(" - Dashboard ready")
+ log.Printf(" - InfluxDB: %s/%s", influxURL, influxDB)
+ log.Printf(" - Inference: %s", apiURL)
+
+ if err := app.Run(); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// spaHandler serves static files with SPA fallback for client-side routing.
+func spaHandler(fsys fs.FS) http.Handler {
+ fileServer := http.FileServer(http.FS(fsys))
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/")
+ if path == "" {
+ path = "index.html"
+ }
+ if _, err := fs.Stat(fsys, path); err != nil {
+ r.URL.Path = "/"
+ }
+ fileServer.ServeHTTP(w, r)
+ })
+}
+
+// findDeployDir locates the deploy/ directory relative to the binary.
+func findDeployDir() string {
+ // Check relative to executable.
+ exe, err := os.Executable()
+ if err == nil {
+ dir := filepath.Join(filepath.Dir(exe), "deploy")
+ if _, err := os.Stat(filepath.Join(dir, "docker-compose.yml")); err == nil {
+ return dir
+ }
+ }
+ // Check relative to working directory.
+ if cwd, err := os.Getwd(); err == nil {
+ dir := filepath.Join(cwd, "deploy")
+ if _, err := os.Stat(filepath.Join(dir, "docker-compose.yml")); err == nil {
+ return dir
+ }
+ }
+ return "deploy"
+}
+
+func envOr(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
diff --git a/cmd/lem-desktop/tray.go b/cmd/lem-desktop/tray.go
new file mode 100644
index 0000000..76c82cf
--- /dev/null
+++ b/cmd/lem-desktop/tray.go
@@ -0,0 +1,277 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os/exec"
+ "runtime"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// TrayService provides system tray bindings for the LEM desktop.
+// Exposes status to the frontend and controls the tray menu.
+type TrayService struct {
+ app *application.App
+ dashboard *DashboardService
+ docker *DockerService
+ agent *AgentRunner
+}
+
+// NewTrayService creates a new TrayService.
+func NewTrayService(app *application.App) *TrayService {
+ return &TrayService{app: app}
+}
+
+// SetServices wires up service references after app creation.
+func (t *TrayService) SetServices(dashboard *DashboardService, docker *DockerService, agent *AgentRunner) {
+ t.dashboard = dashboard
+ t.docker = docker
+ t.agent = agent
+}
+
+// ServiceName returns the Wails service name.
+func (t *TrayService) ServiceName() string {
+ return "TrayService"
+}
+
+// ServiceStartup is called when the Wails app starts.
+func (t *TrayService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
+ log.Println("TrayService started")
+ return nil
+}
+
+// ServiceShutdown is called on app exit.
+func (t *TrayService) ServiceShutdown() error {
+ log.Println("TrayService shutdown")
+ return nil
+}
+
+// TraySnapshot is the complete tray state for the frontend.
+type TraySnapshot struct {
+ StackRunning bool `json:"stackRunning"`
+ AgentRunning bool `json:"agentRunning"`
+ AgentTask string `json:"agentTask"`
+ Training []TrainingRow `json:"training"`
+ Generation GenerationStats `json:"generation"`
+ Models []ModelInfo `json:"models"`
+ DockerServices int `json:"dockerServices"`
+}
+
+// GetSnapshot returns the full tray state.
+func (t *TrayService) GetSnapshot() TraySnapshot {
+ snap := TraySnapshot{}
+
+ if t.dashboard != nil {
+ ds := t.dashboard.GetSnapshot()
+ snap.Training = ds.Training
+ snap.Generation = ds.Generation
+ snap.Models = ds.Models
+ }
+
+ if t.docker != nil {
+ status := t.docker.GetStatus()
+ snap.StackRunning = status.Running
+ snap.DockerServices = len(status.Services)
+ }
+
+ if t.agent != nil {
+ snap.AgentRunning = t.agent.IsRunning()
+ snap.AgentTask = t.agent.CurrentTask()
+ }
+
+ return snap
+}
+
+// StartStack starts the Docker compose stack.
+func (t *TrayService) StartStack() error {
+ if t.docker == nil {
+ return fmt.Errorf("docker service not available")
+ }
+ return t.docker.Start()
+}
+
+// StopStack stops the Docker compose stack.
+func (t *TrayService) StopStack() error {
+ if t.docker == nil {
+ return fmt.Errorf("docker service not available")
+ }
+ return t.docker.Stop()
+}
+
+// StartAgent starts the scoring agent.
+func (t *TrayService) StartAgent() error {
+ if t.agent == nil {
+ return fmt.Errorf("agent service not available")
+ }
+ return t.agent.Start()
+}
+
+// StopAgent stops the scoring agent.
+func (t *TrayService) StopAgent() {
+ if t.agent != nil {
+ t.agent.Stop()
+ }
+}
+
+// setupSystemTray configures the system tray icon and menu.
+func setupSystemTray(app *application.App, tray *TrayService, dashboard *DashboardService, docker *DockerService) {
+ systray := app.SystemTray.New()
+ systray.SetTooltip("LEM — Lethean Ethics Model")
+
+ // Platform-specific icon.
+ if runtime.GOOS == "darwin" {
+ systray.SetTemplateIcon(trayIconTemplate)
+ } else {
+ systray.SetDarkModeIcon(trayIconDark)
+ systray.SetIcon(trayIconLight)
+ }
+
+ // ── Tray Panel (frameless dropdown) ──
+ trayWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
+ Name: "tray-panel",
+ Title: "LEM",
+ Width: 420,
+ Height: 520,
+ URL: "/tray",
+ Hidden: true,
+ Frameless: true,
+ BackgroundColour: application.NewRGB(15, 23, 42),
+ })
+ systray.AttachWindow(trayWindow).WindowOffset(5)
+
+ // ── Dashboard Window ──
+ app.Window.NewWithOptions(application.WebviewWindowOptions{
+ Name: "dashboard",
+ Title: "LEM Dashboard",
+ Width: 1400,
+ Height: 900,
+ URL: "/dashboard",
+ Hidden: true,
+ BackgroundColour: application.NewRGB(15, 23, 42),
+ })
+
+ // ── Workbench Window (model scoring, probes) ──
+ app.Window.NewWithOptions(application.WebviewWindowOptions{
+ Name: "workbench",
+ Title: "LEM Workbench",
+ Width: 1200,
+ Height: 800,
+ URL: "/workbench",
+ Hidden: true,
+ BackgroundColour: application.NewRGB(15, 23, 42),
+ })
+
+ // ── Settings Window ──
+ app.Window.NewWithOptions(application.WebviewWindowOptions{
+ Name: "settings",
+ Title: "LEM Settings",
+ Width: 600,
+ Height: 500,
+ URL: "/settings",
+ Hidden: true,
+ BackgroundColour: application.NewRGB(15, 23, 42),
+ })
+
+ // ── Build Tray Menu ──
+ trayMenu := app.Menu.New()
+
+ // Status (dynamic).
+ statusItem := trayMenu.Add("LEM: Idle")
+ statusItem.SetEnabled(false)
+
+ trayMenu.AddSeparator()
+
+ // Stack control.
+ stackItem := trayMenu.Add("Start Services")
+ stackItem.OnClick(func(ctx *application.Context) {
+ if docker.IsRunning() {
+ docker.Stop()
+ stackItem.SetLabel("Start Services")
+ statusItem.SetLabel("LEM: Stopped")
+ } else {
+ docker.Start()
+ stackItem.SetLabel("Stop Services")
+ statusItem.SetLabel("LEM: Running")
+ }
+ })
+
+ // Agent control.
+ agentItem := trayMenu.Add("Start Scoring Agent")
+ agentItem.OnClick(func(ctx *application.Context) {
+ if tray.agent != nil && tray.agent.IsRunning() {
+ tray.agent.Stop()
+ agentItem.SetLabel("Start Scoring Agent")
+ } else if tray.agent != nil {
+ tray.agent.Start()
+ agentItem.SetLabel("Stop Scoring Agent")
+ }
+ })
+
+ trayMenu.AddSeparator()
+
+ // Windows.
+ trayMenu.Add("Open Dashboard").OnClick(func(ctx *application.Context) {
+ if w, ok := app.Window.Get("dashboard"); ok {
+ w.Show()
+ w.Focus()
+ }
+ })
+
+ trayMenu.Add("Open Workbench").OnClick(func(ctx *application.Context) {
+ if w, ok := app.Window.Get("workbench"); ok {
+ w.Show()
+ w.Focus()
+ }
+ })
+
+ trayMenu.Add("Open Forge").OnClick(func(ctx *application.Context) {
+ // Open the local Forgejo in the default browser.
+ openBrowser("http://localhost:3000")
+ })
+
+ trayMenu.AddSeparator()
+
+ // Stats submenu.
+ statsMenu := trayMenu.AddSubmenu("Training")
+ statsMenu.Add("Golden Set: loading...").SetEnabled(false)
+ statsMenu.Add("Expansion: loading...").SetEnabled(false)
+ statsMenu.Add("Models Scored: loading...").SetEnabled(false)
+
+ trayMenu.AddSeparator()
+
+ // Settings.
+ trayMenu.Add("Settings...").OnClick(func(ctx *application.Context) {
+ if w, ok := app.Window.Get("settings"); ok {
+ w.Show()
+ w.Focus()
+ }
+ })
+
+ trayMenu.AddSeparator()
+
+ // Quit.
+ trayMenu.Add("Quit LEM").OnClick(func(ctx *application.Context) {
+ app.Quit()
+ })
+
+ systray.SetMenu(trayMenu)
+}
+
+// openBrowser launches the default browser.
+func openBrowser(url string) {
+ var cmd string
+ var args []string
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = "open"
+ case "linux":
+ cmd = "xdg-open"
+ case "windows":
+ cmd = "rundll32"
+ args = []string{"url.dll,FileProtocolHandler"}
+ }
+ args = append(args, url)
+ go exec.Command(cmd, args...).Start()
+}
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 0000000..d376c0d
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,88 @@
+# LEM Desktop — Docker Compose Stack
+# Provides local Forgejo (agentic git), InfluxDB (metrics), and inference proxy.
+#
+# Usage:
+# lem desktop start # starts all services
+# docker compose -f deploy/docker-compose.yml up -d
+#
+# Services:
+# forgejo — Local git forge for agentic workflows (port 3000, SSH 2222)
+# influxdb — Metrics and coordination (port 8181)
+# inference — OpenAI-compatible proxy to M3 MLX or local vLLM (port 8080)
+
+services:
+ # ── Forgejo — Local Agentic Git Forge ──
+ forgejo:
+ image: codeberg.org/forgejo/forgejo:10
+ container_name: lem-forgejo
+ restart: unless-stopped
+ ports:
+ - "3000:3000" # Web UI
+ - "2222:22" # SSH
+ volumes:
+ - forgejo-data:/data
+ - forgejo-config:/etc/gitea
+ environment:
+ - USER_UID=1000
+ - USER_GID=1000
+ - FORGEJO__server__ROOT_URL=http://localhost:3000/
+ - FORGEJO__server__SSH_PORT=2222
+ - FORGEJO__server__SSH_LISTEN_PORT=22
+ - FORGEJO__service__DISABLE_REGISTRATION=false
+ - FORGEJO__service__DEFAULT_ALLOW_CREATE_ORGANIZATION=true
+ - FORGEJO__federation__ENABLED=true
+ - FORGEJO__actions__ENABLED=true
+ - FORGEJO__database__DB_TYPE=sqlite3
+ - FORGEJO__database__PATH=/data/gitea/gitea.db
+ healthcheck:
+ test: ["CMD", "curl", "-fsSL", "http://localhost:3000/api/v1/version"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+
+ # ── InfluxDB v3 — Metrics & Coordination ──
+ influxdb:
+ image: quay.io/influxdb/influxdb3-core:latest
+ container_name: lem-influxdb
+ restart: unless-stopped
+ ports:
+ - "8181:8181"
+ volumes:
+ - influxdb-data:/var/lib/influxdb3
+ environment:
+ - INFLUXDB3_NODE_ID=lem-local
+ command: ["serve", "--host-id", "lem-local", "--object-store", "file", "--data-dir", "/var/lib/influxdb3"]
+ healthcheck:
+ test: ["CMD", "curl", "-fsSL", "http://localhost:8181/health"]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+
+ # ── Inference Proxy — OpenAI-Compatible API ──
+ # Routes to M3 MLX server or local vLLM/llama.cpp.
+ # Override LEM_INFERENCE_BACKEND to point elsewhere.
+ inference:
+ image: nginx:alpine
+ container_name: lem-inference
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - ./inference-proxy.conf:/etc/nginx/conf.d/default.conf:ro
+ environment:
+ - UPSTREAM_URL=${LEM_INFERENCE_BACKEND:-http://10.69.69.108:8090}
+ depends_on:
+ - influxdb
+ healthcheck:
+ test: ["CMD", "curl", "-fsSL", "http://localhost:8080/health"]
+ interval: 15s
+ timeout: 5s
+ retries: 3
+
+volumes:
+ forgejo-data:
+ driver: local
+ forgejo-config:
+ driver: local
+ influxdb-data:
+ driver: local
diff --git a/deploy/inference-proxy.conf b/deploy/inference-proxy.conf
new file mode 100644
index 0000000..b652e1e
--- /dev/null
+++ b/deploy/inference-proxy.conf
@@ -0,0 +1,30 @@
+# Nginx reverse proxy for OpenAI-compatible inference API.
+# Routes /v1/* to the configured upstream (M3 MLX, vLLM, llama.cpp, etc.)
+# Set UPSTREAM_URL env var or LEM_INFERENCE_BACKEND in docker-compose.
+
+server {
+ listen 8080;
+ server_name localhost;
+
+ # Health check endpoint.
+ location /health {
+ return 200 '{"status": "ok"}';
+ add_header Content-Type application/json;
+ }
+
+ # Proxy all /v1/* requests to the inference backend.
+ location /v1/ {
+ proxy_pass ${UPSTREAM_URL}/v1/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_read_timeout 300s;
+ proxy_send_timeout 300s;
+ proxy_buffering off;
+ }
+
+ # Model listing passthrough.
+ location /v1/models {
+ proxy_pass ${UPSTREAM_URL}/v1/models;
+ proxy_set_header Host $host;
+ }
+}
diff --git a/go.work b/go.work
new file mode 100644
index 0000000..91f4e66
--- /dev/null
+++ b/go.work
@@ -0,0 +1,6 @@
+go 1.25.6
+
+use (
+ .
+ ./cmd/lem-desktop
+)
diff --git a/go.work.sum b/go.work.sum
new file mode 100644
index 0000000..3c74b79
--- /dev/null
+++ b/go.work.sum
@@ -0,0 +1,7 @@
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
diff --git a/pkg/lem/db.go b/pkg/lem/db.go
index 52107c7..ec1364c 100644
--- a/pkg/lem/db.go
+++ b/pkg/lem/db.go
@@ -162,6 +162,38 @@ func (db *DB) UpdateExpansionStatus(idx int64, status string) error {
return nil
}
+// QueryRows executes an arbitrary SQL query and returns results as maps.
+func (db *DB) QueryRows(query string, args ...interface{}) ([]map[string]interface{}, error) {
+ rows, err := db.conn.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("query: %w", err)
+ }
+ defer rows.Close()
+
+ cols, err := rows.Columns()
+ if err != nil {
+ return nil, fmt.Errorf("columns: %w", err)
+ }
+
+ var result []map[string]interface{}
+ for rows.Next() {
+ values := make([]interface{}, len(cols))
+ ptrs := make([]interface{}, len(cols))
+ for i := range values {
+ ptrs[i] = &values[i]
+ }
+ if err := rows.Scan(ptrs...); err != nil {
+ return nil, fmt.Errorf("scan: %w", err)
+ }
+ row := make(map[string]interface{}, len(cols))
+ for i, col := range cols {
+ row[col] = values[i]
+ }
+ result = append(result, row)
+ }
+ return result, rows.Err()
+}
+
// TableCounts returns row counts for all known tables.
func (db *DB) TableCounts() (map[string]int, error) {
tables := []string{"golden_set", "expansion_prompts", "seeds", "prompts",