package main import ( "context" "encoding/json" "flag" "log" "net/http" "os" "os/signal" "path/filepath" "sort" "strings" "sync" "syscall" "time" "github.com/google/uuid" ) // ============================================ // TASKS // ============================================ type Task struct { ID string `json:"id"` Text string `json:"text,omitempty"` Title string `json:"title,omitempty"` Priority string `json:"priority"` Status string `json:"status,omitempty"` Owner string `json:"owner,omitempty"` // "johan" or "james" Domain string `json:"domain,omitempty"` // Kaseya, inou, Sophia, ClawdNode, Infrastructure, etc. Notes string `json:"notes,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` Updated string `json:"updated,omitempty"` } type TaskStore struct { Tasks []Task `json:"tasks"` mu sync.RWMutex path string } func NewTaskStore(path string) (*TaskStore, error) { store := &TaskStore{path: path, Tasks: []Task{}} dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return store, store.save() } return nil, err } var wrapper struct { Tasks []Task `json:"tasks"` } if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Tasks != nil { store.Tasks = wrapper.Tasks return store, nil } if err := json.Unmarshal(data, &store.Tasks); err != nil { store.Tasks = []Task{} return store, store.save() } return store, nil } func (s *TaskStore) save() error { wrapper := struct { Tasks []Task `json:"tasks"` }{Tasks: s.Tasks} data, err := json.MarshalIndent(wrapper, "", " ") if err != nil { return err } return os.WriteFile(s.path, data, 0644) } func (s *TaskStore) Add(task Task) (Task, error) { s.mu.Lock() defer s.mu.Unlock() task.ID = uuid.New().String()[:8] task.CreatedAt = time.Now() if task.Priority == "" { task.Priority = "medium" } if task.Status == "" { task.Status = "pending" } if task.Owner == "" { task.Owner = "james" } s.Tasks = append(s.Tasks, task) return task, s.save() } func (s *TaskStore) Update(id string, updates Task) (Task, bool) { s.mu.Lock() defer s.mu.Unlock() for i, task := range s.Tasks { if task.ID == id { if updates.Title != "" { s.Tasks[i].Title = updates.Title } if updates.Text != "" { s.Tasks[i].Text = updates.Text } if updates.Priority != "" { s.Tasks[i].Priority = updates.Priority } if updates.Status != "" { s.Tasks[i].Status = updates.Status } if updates.Owner != "" { s.Tasks[i].Owner = updates.Owner } if updates.Domain != "" { s.Tasks[i].Domain = updates.Domain } if updates.Notes != "" { s.Tasks[i].Notes = updates.Notes } s.Tasks[i].Updated = time.Now().Format(time.RFC3339) s.save() return s.Tasks[i], true } } return Task{}, false } func (s *TaskStore) List() []Task { s.mu.RLock() defer s.mu.RUnlock() return s.Tasks } func (s *TaskStore) Delete(id string) bool { s.mu.Lock() defer s.mu.Unlock() for i, task := range s.Tasks { if task.ID == id { s.Tasks = append(s.Tasks[:i], s.Tasks[i+1:]...) s.save() return true } } return false } // ============================================ // NEWS // ============================================ type NewsItem struct { ID string `json:"id"` Title string `json:"title"` Body string `json:"body"` Type string `json:"type"` // info, success, warning, error Source string `json:"source,omitempty"` URL string `json:"url,omitempty"` Timestamp time.Time `json:"timestamp"` } type NewsStore struct { Items []NewsItem `json:"items"` mu sync.RWMutex path string } func NewNewsStore(path string) (*NewsStore, error) { store := &NewsStore{path: path, Items: []NewsItem{}} dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return store, store.save() } return nil, err } var wrapper struct { Items []NewsItem `json:"items"` } if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Items != nil { store.Items = wrapper.Items return store, nil } return store, store.save() } func (s *NewsStore) save() error { wrapper := struct { Items []NewsItem `json:"items"` }{Items: s.Items} data, err := json.MarshalIndent(wrapper, "", " ") if err != nil { return err } return os.WriteFile(s.path, data, 0644) } func (s *NewsStore) Add(item NewsItem) (NewsItem, error) { s.mu.Lock() defer s.mu.Unlock() item.ID = uuid.New().String()[:8] item.Timestamp = time.Now() if item.Type == "" { item.Type = "info" } // Prepend (newest first) s.Items = append([]NewsItem{item}, s.Items...) // Keep only last 50 items if len(s.Items) > 50 { s.Items = s.Items[:50] } return item, s.save() } func (s *NewsStore) List(limit int) []NewsItem { s.mu.RLock() defer s.mu.RUnlock() if limit > 0 && limit < len(s.Items) { return s.Items[:limit] } return s.Items } func (s *NewsStore) Delete(id string) bool { s.mu.Lock() defer s.mu.Unlock() for i, item := range s.Items { if item.ID == id { s.Items = append(s.Items[:i], s.Items[i+1:]...) s.save() return true } } return false } func (s *NewsStore) Clear() error { s.mu.Lock() defer s.mu.Unlock() s.Items = []NewsItem{} return s.save() } // ============================================ // BRIEFINGS // ============================================ type Briefing struct { ID string `json:"id"` Date string `json:"date"` Title string `json:"title"` Weather string `json:"weather,omitempty"` Markets string `json:"markets,omitempty"` News string `json:"news,omitempty"` Tasks string `json:"tasks,omitempty"` Summary string `json:"summary,omitempty"` CreatedAt time.Time `json:"created_at"` } type BriefingStore struct { Briefings []Briefing `json:"briefings"` mu sync.RWMutex path string } func NewBriefingStore(path string) (*BriefingStore, error) { store := &BriefingStore{path: path, Briefings: []Briefing{}} dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return store, store.save() } return nil, err } var wrapper struct { Briefings []Briefing `json:"briefings"` } if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Briefings != nil { store.Briefings = wrapper.Briefings return store, nil } return store, store.save() } func (s *BriefingStore) save() error { wrapper := struct { Briefings []Briefing `json:"briefings"` }{Briefings: s.Briefings} data, err := json.MarshalIndent(wrapper, "", " ") if err != nil { return err } return os.WriteFile(s.path, data, 0644) } func (s *BriefingStore) Add(b Briefing) (Briefing, error) { s.mu.Lock() defer s.mu.Unlock() b.ID = uuid.New().String()[:8] b.CreatedAt = time.Now() if b.Date == "" { b.Date = time.Now().Format("2006-01-02") } if b.Title == "" { b.Title = "Morning Brief" } s.Briefings = append(s.Briefings, b) return b, s.save() } func (s *BriefingStore) List(limit int) []Briefing { s.mu.RLock() defer s.mu.RUnlock() // Sort by date descending sorted := make([]Briefing, len(s.Briefings)) copy(sorted, s.Briefings) sort.Slice(sorted, func(i, j int) bool { return sorted[i].CreatedAt.After(sorted[j].CreatedAt) }) if limit > 0 && limit < len(sorted) { return sorted[:limit] } return sorted } func (s *BriefingStore) Get(id string) (Briefing, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, b := range s.Briefings { if b.ID == id { return b, true } } return Briefing{}, false } func (s *BriefingStore) Delete(id string) bool { s.mu.Lock() defer s.mu.Unlock() for i, b := range s.Briefings { if b.ID == id { s.Briefings = append(s.Briefings[:i], s.Briefings[i+1:]...) s.save() return true } } return false } // ============================================ // DELIVERIES // ============================================ type Delivery struct { ID string `json:"id"` Carrier string `json:"carrier,omitempty"` // UPS, FedEx, USPS, etc. Retailer string `json:"retailer,omitempty"` // Amazon, Nordstrom, etc. Description string `json:"description"` // What's in the package TrackingNumber string `json:"tracking_number,omitempty"` TrackingURL string `json:"tracking_url,omitempty"` ExpectedDate string `json:"expected_date,omitempty"` // "2026-02-03" or "Monday" Status string `json:"status"` // shipped, in_transit, out_for_delivery, delayed, delivered Notes string `json:"notes,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type DeliveryStore struct { Deliveries []Delivery `json:"deliveries"` mu sync.RWMutex path string } func NewDeliveryStore(path string) (*DeliveryStore, error) { store := &DeliveryStore{path: path, Deliveries: []Delivery{}} dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return store, store.save() } return nil, err } var wrapper struct { Deliveries []Delivery `json:"deliveries"` } if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Deliveries != nil { store.Deliveries = wrapper.Deliveries return store, nil } return store, store.save() } func (s *DeliveryStore) save() error { wrapper := struct { Deliveries []Delivery `json:"deliveries"` }{Deliveries: s.Deliveries} data, err := json.MarshalIndent(wrapper, "", " ") if err != nil { return err } return os.WriteFile(s.path, data, 0644) } func (s *DeliveryStore) Add(d Delivery) (Delivery, error) { s.mu.Lock() defer s.mu.Unlock() d.ID = uuid.New().String()[:8] d.CreatedAt = time.Now() d.UpdatedAt = time.Now() if d.Status == "" { d.Status = "shipped" } s.Deliveries = append(s.Deliveries, d) return d, s.save() } func (s *DeliveryStore) Update(id string, updates Delivery) (Delivery, bool) { s.mu.Lock() defer s.mu.Unlock() for i, d := range s.Deliveries { if d.ID == id { if updates.Carrier != "" { s.Deliveries[i].Carrier = updates.Carrier } if updates.Retailer != "" { s.Deliveries[i].Retailer = updates.Retailer } if updates.Description != "" { s.Deliveries[i].Description = updates.Description } if updates.TrackingNumber != "" { s.Deliveries[i].TrackingNumber = updates.TrackingNumber } if updates.TrackingURL != "" { s.Deliveries[i].TrackingURL = updates.TrackingURL } if updates.ExpectedDate != "" { s.Deliveries[i].ExpectedDate = updates.ExpectedDate } if updates.Status != "" { s.Deliveries[i].Status = updates.Status } if updates.Notes != "" { s.Deliveries[i].Notes = updates.Notes } s.Deliveries[i].UpdatedAt = time.Now() s.save() return s.Deliveries[i], true } } return Delivery{}, false } func (s *DeliveryStore) List() []Delivery { s.mu.RLock() defer s.mu.RUnlock() // Return non-delivered items, sorted by expected date var active []Delivery for _, d := range s.Deliveries { if d.Status != "delivered" { active = append(active, d) } } sort.Slice(active, func(i, j int) bool { return active[i].ExpectedDate < active[j].ExpectedDate }) return active } func (s *DeliveryStore) ListAll() []Delivery { s.mu.RLock() defer s.mu.RUnlock() return s.Deliveries } func (s *DeliveryStore) Get(id string) (Delivery, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, d := range s.Deliveries { if d.ID == id { return d, true } } return Delivery{}, false } func (s *DeliveryStore) Delete(id string) bool { s.mu.Lock() defer s.mu.Unlock() for i, d := range s.Deliveries { if d.ID == id { s.Deliveries = append(s.Deliveries[:i], s.Deliveries[i+1:]...) s.save() return true } } return false } // Find by tracking number or description match func (s *DeliveryStore) FindByTracking(tracking string) (Delivery, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, d := range s.Deliveries { if d.TrackingNumber == tracking { return d, true } } return Delivery{}, false } // ============================================ // STATUS (key-value status indicators) // ============================================ type StatusItem struct { Key string `json:"key"` Value string `json:"value"` Type string `json:"type,omitempty"` // info, success, warning, error UpdatedAt time.Time `json:"updated_at"` } type StatusStore struct { Items map[string]StatusItem `json:"items"` mu sync.RWMutex path string } func NewStatusStore(path string) (*StatusStore, error) { store := &StatusStore{path: path, Items: make(map[string]StatusItem)} dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return store, store.save() } return nil, err } var wrapper struct { Items map[string]StatusItem `json:"items"` } if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Items != nil { store.Items = wrapper.Items return store, nil } return store, store.save() } func (s *StatusStore) save() error { wrapper := struct { Items map[string]StatusItem `json:"items"` }{Items: s.Items} data, err := json.MarshalIndent(wrapper, "", " ") if err != nil { return err } return os.WriteFile(s.path, data, 0644) } func (s *StatusStore) Set(key, value, itemType string) StatusItem { s.mu.Lock() defer s.mu.Unlock() item := StatusItem{ Key: key, Value: value, Type: itemType, UpdatedAt: time.Now(), } if item.Type == "" { item.Type = "info" } s.Items[key] = item s.save() return item } func (s *StatusStore) Get(key string) (StatusItem, bool) { s.mu.RLock() defer s.mu.RUnlock() item, ok := s.Items[key] return item, ok } func (s *StatusStore) List() map[string]StatusItem { s.mu.RLock() defer s.mu.RUnlock() return s.Items } func (s *StatusStore) Delete(key string) bool { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.Items[key]; ok { delete(s.Items, key) s.save() return true } return false } // ============================================ // EMAIL TRIAGE // ============================================ type EmailTriageEntry struct { ID string `json:"id"` Account string `json:"account"` // proton, johan From string `json:"from"` Subject string `json:"subject"` Action string `json:"action"` // archived, deleted, flagged, kept Reason string `json:"reason,omitempty"` Timestamp time.Time `json:"timestamp"` } type EmailTriageStore struct { Entries []EmailTriageEntry `json:"entries"` mu sync.RWMutex path string } func NewEmailTriageStore(path string) (*EmailTriageStore, error) { store := &EmailTriageStore{path: path, Entries: []EmailTriageEntry{}} dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return store, store.save() } return nil, err } var wrapper struct { Entries []EmailTriageEntry `json:"entries"` } if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Entries != nil { store.Entries = wrapper.Entries return store, nil } return store, store.save() } func (s *EmailTriageStore) save() error { wrapper := struct { Entries []EmailTriageEntry `json:"entries"` }{Entries: s.Entries} data, err := json.MarshalIndent(wrapper, "", " ") if err != nil { return err } return os.WriteFile(s.path, data, 0644) } func (s *EmailTriageStore) Add(entry EmailTriageEntry) (EmailTriageEntry, error) { s.mu.Lock() defer s.mu.Unlock() entry.ID = uuid.New().String()[:8] entry.Timestamp = time.Now() // Prepend (newest first) s.Entries = append([]EmailTriageEntry{entry}, s.Entries...) // Keep only last 500 entries if len(s.Entries) > 500 { s.Entries = s.Entries[:500] } return entry, s.save() } func (s *EmailTriageStore) List(account string, limit int) []EmailTriageEntry { s.mu.RLock() defer s.mu.RUnlock() if account == "" { if limit > 0 && limit < len(s.Entries) { return s.Entries[:limit] } return s.Entries } // Filter by account var filtered []EmailTriageEntry for _, e := range s.Entries { if e.Account == account { filtered = append(filtered, e) if limit > 0 && len(filtered) >= limit { break } } } return filtered } func (s *EmailTriageStore) Clear(account string) error { s.mu.Lock() defer s.mu.Unlock() if account == "" { s.Entries = []EmailTriageEntry{} } else { var filtered []EmailTriageEntry for _, e := range s.Entries { if e.Account != account { filtered = append(filtered, e) } } s.Entries = filtered } return s.save() } // ============================================ // AGENTS (reads from OpenClaw config) // ============================================ type Agent struct { ID string `json:"id"` Name string `json:"name"` Emoji string `json:"emoji"` URL string `json:"url"` Default bool `json:"default,omitempty"` } func getAgents(gatewayIP string, gatewayPort string, gatewayToken string) []Agent { // Read OpenClaw config home, _ := os.UserHomeDir() configPath := filepath.Join(home, ".openclaw", "openclaw.json") data, err := os.ReadFile(configPath) if err != nil { log.Printf("Failed to read openclaw config: %v", err) return []Agent{} } var config struct { Agents struct { List []struct { ID string `json:"id"` Name string `json:"name"` Identity struct { Name string `json:"name"` Emoji string `json:"emoji"` } `json:"identity"` } `json:"list"` } `json:"agents"` } if err := json.Unmarshal(data, &config); err != nil { log.Printf("Failed to parse openclaw config: %v", err) return []Agent{} } var agents []Agent baseURL := "http://" + gatewayIP + ":" + gatewayPort for _, agentConfig := range config.Agents.List { agent := Agent{ ID: agentConfig.ID, Name: agentConfig.Identity.Name, Emoji: agentConfig.Identity.Emoji, } // Set defaults if missing if agent.Name == "" { agent.Name = agentConfig.Name } if agent.Name == "" { agent.Name = agentConfig.ID } if agent.Emoji == "" { agent.Emoji = "🤖" } // Build URL - use /chat?session= (OpenClaw Control UI format) // Include token for auto-authentication if agentConfig.ID == "main" { agent.Default = true agent.URL = baseURL + "/chat?session=agent:main:main&token=" + gatewayToken } else { // Control UI reads session param on /chat route agent.URL = baseURL + "/chat?session=agent:" + agentConfig.ID + ":main&token=" + gatewayToken } agents = append(agents, agent) } // Sort: default first, then alphabetically sort.Slice(agents, func(i, j int) bool { if agents[i].Default != agents[j].Default { return agents[i].Default } return agents[i].ID < agents[j].ID }) return agents } // ============================================ // MAIN // ============================================ func main() { port := flag.String("port", "9200", "port to listen on") dir := flag.String("dir", ".", "directory to serve") gatewayIP := flag.String("gateway-ip", "192.168.1.16", "OpenClaw gateway IP") gatewayPort := flag.String("gateway-port", "18789", "OpenClaw gateway port") gatewayToken := flag.String("gateway-token", "2dee57cc3ce2947c27ce9e848d5c3e95cc452f25a1477462", "OpenClaw gateway auth token") flag.Parse() // Initialize stores taskStore, err := NewTaskStore(filepath.Join(*dir, "data", "tasks.json")) if err != nil { log.Fatalf("Failed to initialize task store: %v", err) } briefingStore, err := NewBriefingStore(filepath.Join(*dir, "data", "briefings.json")) if err != nil { log.Fatalf("Failed to initialize briefing store: %v", err) } newsStore, err := NewNewsStore(filepath.Join(*dir, "data", "news.json")) if err != nil { log.Fatalf("Failed to initialize news store: %v", err) } deliveryStore, err := NewDeliveryStore(filepath.Join(*dir, "data", "deliveries.json")) if err != nil { log.Fatalf("Failed to initialize delivery store: %v", err) } statusStore, err := NewStatusStore(filepath.Join(*dir, "data", "status.json")) if err != nil { log.Fatalf("Failed to initialize status store: %v", err) } emailTriageStore, err := NewEmailTriageStore(filepath.Join(*dir, "data", "email-triage.json")) if err != nil { log.Fatalf("Failed to initialize email triage store: %v", err) } mux := http.NewServeMux() // CORS middleware helper cors := func(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") } // ========== TASKS API ========== mux.HandleFunc("/api/tasks", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } switch r.Method { case "GET": json.NewEncoder(w).Encode(map[string]interface{}{ "tasks": taskStore.List(), }) case "POST": var task Task if err := json.NewDecoder(r.Body).Decode(&task); err != nil { http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest) return } if task.Text == "" && task.Title == "" { http.Error(w, `{"error": "text or title required"}`, http.StatusBadRequest) return } newTask, err := taskStore.Add(task) if err != nil { http.Error(w, `{"error": "failed to save task"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(newTask) default: http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // Task by ID (DELETE, PATCH) mux.HandleFunc("/api/tasks/", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } id := strings.TrimPrefix(r.URL.Path, "/api/tasks/") if id == "" { http.Error(w, `{"error": "task id required"}`, http.StatusBadRequest) return } switch r.Method { case "DELETE": if taskStore.Delete(id) { json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id}) } else { http.Error(w, `{"error": "task not found"}`, http.StatusNotFound) } case "PATCH", "PUT": var updates Task if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest) return } if updated, ok := taskStore.Update(id, updates); ok { json.NewEncoder(w).Encode(updated) } else { http.Error(w, `{"error": "task not found"}`, http.StatusNotFound) } default: http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // ========== NEWS API ========== mux.HandleFunc("/api/news", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } switch r.Method { case "GET": json.NewEncoder(w).Encode(map[string]interface{}{ "items": newsStore.List(20), }) case "POST": var item NewsItem if err := json.NewDecoder(r.Body).Decode(&item); err != nil { http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest) return } if item.Title == "" { http.Error(w, `{"error": "title required"}`, http.StatusBadRequest) return } newItem, err := newsStore.Add(item) if err != nil { http.Error(w, `{"error": "failed to save news"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(newItem) case "DELETE": // DELETE /api/news clears all news if err := newsStore.Clear(); err != nil { http.Error(w, `{"error": "failed to clear news"}`, http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]string{"status": "cleared"}) default: http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // News by ID mux.HandleFunc("/api/news/", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } id := strings.TrimPrefix(r.URL.Path, "/api/news/") if id == "" { http.Error(w, `{"error": "news id required"}`, http.StatusBadRequest) return } switch r.Method { case "DELETE": if newsStore.Delete(id) { json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id}) } else { http.Error(w, `{"error": "news item not found"}`, http.StatusNotFound) } default: http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // ========== BRIEFINGS API ========== mux.HandleFunc("/api/briefings", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } switch r.Method { case "GET": limit := 30 // default json.NewEncoder(w).Encode(map[string]interface{}{ "briefings": briefingStore.List(limit), }) case "POST": var b Briefing if err := json.NewDecoder(r.Body).Decode(&b); err != nil { http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest) return } newBriefing, err := briefingStore.Add(b) if err != nil { http.Error(w, `{"error": "failed to save briefing"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(newBriefing) default: http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // Briefing by ID mux.HandleFunc("/api/briefings/", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } id := strings.TrimPrefix(r.URL.Path, "/api/briefings/") if id == "" { http.Error(w, `{"error": "briefing id required"}`, http.StatusBadRequest) return } switch r.Method { case "GET": if b, ok := briefingStore.Get(id); ok { json.NewEncoder(w).Encode(b) } else { http.Error(w, `{"error": "briefing not found"}`, http.StatusNotFound) } case "DELETE": if briefingStore.Delete(id) { json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id}) } else { http.Error(w, `{"error": "briefing not found"}`, http.StatusNotFound) } default: http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // ========== DELIVERIES API ========== mux.HandleFunc("/api/deliveries", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } switch r.Method { case "GET": // ?all=true returns everything including delivered if r.URL.Query().Get("all") == "true" { json.NewEncoder(w).Encode(map[string]interface{}{ "deliveries": deliveryStore.ListAll(), }) } else { json.NewEncoder(w).Encode(map[string]interface{}{ "deliveries": deliveryStore.List(), }) } case "POST": var d Delivery if err := json.NewDecoder(r.Body).Decode(&d); err != nil { http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest) return } if d.Description == "" { http.Error(w, `{"error": "description required"}`, http.StatusBadRequest) return } newDelivery, err := deliveryStore.Add(d) if err != nil { http.Error(w, `{"error": "failed to save delivery"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(newDelivery) default: http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // Delivery by ID mux.HandleFunc("/api/deliveries/", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } id := strings.TrimPrefix(r.URL.Path, "/api/deliveries/") if id == "" { http.Error(w, `{"error": "delivery id required"}`, http.StatusBadRequest) return } switch r.Method { case "GET": if d, ok := deliveryStore.Get(id); ok { json.NewEncoder(w).Encode(d) } else { http.Error(w, `{"error": "delivery not found"}`, http.StatusNotFound) } case "PATCH", "PUT": var updates Delivery if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest) return } if updated, ok := deliveryStore.Update(id, updates); ok { json.NewEncoder(w).Encode(updated) } else { http.Error(w, `{"error": "delivery not found"}`, http.StatusNotFound) } case "DELETE": if deliveryStore.Delete(id) { json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id}) } else { http.Error(w, `{"error": "delivery not found"}`, http.StatusNotFound) } default: http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // ========== STATUS API ========== mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } switch r.Method { case "GET": json.NewEncoder(w).Encode(map[string]interface{}{ "status": statusStore.List(), }) case "POST", "PUT": var item struct { Key string `json:"key"` Value string `json:"value"` Type string `json:"type"` } if err := json.NewDecoder(r.Body).Decode(&item); err != nil { http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest) return } if item.Key == "" || item.Value == "" { http.Error(w, `{"error": "key and value required"}`, http.StatusBadRequest) return } result := statusStore.Set(item.Key, item.Value, item.Type) json.NewEncoder(w).Encode(result) default: http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // Status by key mux.HandleFunc("/api/status/", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } key := strings.TrimPrefix(r.URL.Path, "/api/status/") if key == "" { http.Error(w, `{"error": "status key required"}`, http.StatusBadRequest) return } switch r.Method { case "GET": if item, ok := statusStore.Get(key); ok { json.NewEncoder(w).Encode(item) } else { http.Error(w, `{"error": "status not found"}`, http.StatusNotFound) } case "DELETE": if statusStore.Delete(key) { json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "key": key}) } else { http.Error(w, `{"error": "status not found"}`, http.StatusNotFound) } default: http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // ========== EMAIL TRIAGE API ========== mux.HandleFunc("/api/email-triage", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } switch r.Method { case "GET": account := r.URL.Query().Get("account") limit := 100 if l := r.URL.Query().Get("limit"); l != "" { if n, err := json.Number(l).Int64(); err == nil { limit = int(n) } } json.NewEncoder(w).Encode(map[string]interface{}{ "entries": emailTriageStore.List(account, limit), }) case "POST": var entry EmailTriageEntry if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest) return } if entry.Account == "" || entry.Action == "" { http.Error(w, `{"error": "account and action required"}`, http.StatusBadRequest) return } newEntry, err := emailTriageStore.Add(entry) if err != nil { http.Error(w, `{"error": "failed to save entry"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(newEntry) case "DELETE": account := r.URL.Query().Get("account") if err := emailTriageStore.Clear(account); err != nil { http.Error(w, `{"error": "failed to clear"}`, http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]string{"status": "cleared", "account": account}) default: http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // ========== CLAUDE USAGE API ========== claudeUsageFile := "/home/johan/clawd/memory/claude-usage.json" claudeHistoryFile := filepath.Join(*dir, "data", "claude-usage-history.json") mux.HandleFunc("/api/claude-usage", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } if r.Method != "GET" { http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) return } // Read current usage result := make(map[string]interface{}) if data, err := os.ReadFile(claudeUsageFile); err == nil { json.Unmarshal(data, &result) } // Read history var history []map[string]interface{} if data, err := os.ReadFile(claudeHistoryFile); err == nil { json.Unmarshal(data, &history) } result["history"] = history json.NewEncoder(w).Encode(result) }) mux.HandleFunc("/api/claude-usage/record", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } if r.Method != "POST" { http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) return } var entry map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest) return } // Read existing history var history []map[string]interface{} if data, err := os.ReadFile(claudeHistoryFile); err == nil { json.Unmarshal(data, &history) } // Add timestamp if missing if _, ok := entry["timestamp"]; !ok { entry["timestamp"] = time.Now().UTC().Format(time.RFC3339) } history = append(history, entry) // Keep last 2000 entries if len(history) > 2000 { history = history[len(history)-2000:] } data, _ := json.MarshalIndent(history, "", " ") os.WriteFile(claudeHistoryFile, data, 0644) w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"status": "recorded"}) }) // ========== AGENTS API ========== mux.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) { cors(w) if r.Method == "OPTIONS" { return } if r.Method == "GET" { agents := getAgents(*gatewayIP, *gatewayPort, *gatewayToken) json.NewEncoder(w).Encode(map[string]interface{}{ "agents": agents, "gateway_ip": *gatewayIP, "gateway_port": *gatewayPort, }) } else { http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) } }) // Static files (fallback) fs := http.FileServer(http.Dir(*dir)) mux.Handle("/", fs) server := &http.Server{ Addr: "0.0.0.0:" + *port, Handler: mux, } // Graceful shutdown go func() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan log.Println("Shutting down gracefully...") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() server.Shutdown(ctx) }() log.Printf("James Dashboard serving %s on http://0.0.0.0:%s\n", *dir, *port) log.Printf("API endpoints:\n") log.Printf(" Tasks:\n") log.Printf(" GET /api/tasks - list all tasks\n") log.Printf(" POST /api/tasks - add task {title, priority, status, owner, notes}\n") log.Printf(" PATCH /api/tasks/:id - update task\n") log.Printf(" DELETE /api/tasks/:id - remove task\n") log.Printf(" News:\n") log.Printf(" GET /api/news - list news (newest first, max 20)\n") log.Printf(" POST /api/news - add news {title, body, type, source, url}\n") log.Printf(" DELETE /api/news - clear all news\n") log.Printf(" DELETE /api/news/:id - remove news item\n") log.Printf(" Briefings:\n") log.Printf(" GET /api/briefings - list briefings (newest first)\n") log.Printf(" POST /api/briefings - add briefing {title, date, weather, markets, news, summary}\n") log.Printf(" GET /api/briefings/:id - get single briefing\n") log.Printf(" DELETE /api/briefings/:id - remove briefing\n") log.Printf(" Deliveries:\n") log.Printf(" GET /api/deliveries - list active deliveries (excludes delivered)\n") log.Printf(" GET /api/deliveries?all=true - list all deliveries\n") log.Printf(" POST /api/deliveries - add delivery {description, carrier, retailer, tracking_number, expected_date, status}\n") log.Printf(" GET /api/deliveries/:id - get single delivery\n") log.Printf(" PATCH /api/deliveries/:id - update delivery\n") log.Printf(" DELETE /api/deliveries/:id - remove delivery\n") log.Printf(" Email Triage:\n") log.Printf(" GET /api/email-triage - list email triage log (newest first)\n") log.Printf(" GET /api/email-triage?account=X - filter by account (proton, johan)\n") log.Printf(" POST /api/email-triage - add entry {account, from, subject, action, reason}\n") log.Printf(" DELETE /api/email-triage - clear all entries\n") log.Printf(" DELETE /api/email-triage?account=X - clear entries for account\n") log.Printf(" Agents:\n") log.Printf(" GET /api/agents - list OpenClaw agents (reads from config)\n") log.Printf(" Gateway: http://%s:%s\n", *gatewayIP, *gatewayPort) if err := server.ListenAndServe(); err != http.ErrServerClosed { log.Fatal(err) } }