diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 239448a1c..f62c58a1e 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -1034,6 +1034,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) { return channel, ok } +// HandleFunc registers a custom HTTP handler on the shared gateway mux. +func (m *Manager) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) { + if m.mux != nil { + m.mux.HandleFunc(pattern, handler) + } +} + func (m *Manager) GetStatus() map[string]any { m.mu.RLock() defer m.mu.RUnlock() diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 59cf8fa6c..3a887600a 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -483,6 +483,32 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag return nil, nil } +// SendStatus sends a text message to the WhatsApp Status broadcast (status@broadcast). +func (c *WhatsAppNativeChannel) SendStatus(ctx context.Context, text string) error { + c.mu.Lock() + client := c.client + c.mu.Unlock() + if client == nil || !client.IsConnected() { + return fmt.Errorf("whatsapp not connected") + } + if client.Store.ID == nil { + return fmt.Errorf("whatsapp not paired") + } + black := uint32(0xFF000000) + white := uint32(0xFFFFFFFF) + font := waE2E.ExtendedTextMessage_SYSTEM_BOLD + msg := &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: proto.String(text), + BackgroundArgb: &black, + TextArgb: &white, + Font: &font, + }, + } + _, err := client.SendMessage(ctx, types.StatusBroadcastJID, msg) + return err +} + // SendMedia implements the channels.MediaSender interface. func (c *WhatsAppNativeChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 8065a0795..659377a50 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -2,7 +2,9 @@ package gateway import ( "context" + "encoding/json" "fmt" + "net/http" "os" "os/signal" "path/filepath" @@ -32,7 +34,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/weixin" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" - _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native" + whatsappnative "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/devices" @@ -382,6 +384,37 @@ func setupAndStartServices( runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, authToken) runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer) + // API: send text to WhatsApp Status broadcast + runningServices.ChannelManager.HandleFunc("/api/send-status", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Text string `json:"text"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Text == "" { + http.Error(w, "bad request: text required", http.StatusBadRequest) + return + } + ch, ok := runningServices.ChannelManager.GetChannel("whatsapp_native") + if !ok { + http.Error(w, "whatsapp not available", http.StatusServiceUnavailable) + return + } + waCh, ok := ch.(*whatsappnative.WhatsAppNativeChannel) + if !ok { + http.Error(w, "whatsapp channel type mismatch", http.StatusInternalServerError) + return + } + if err := waCh.SendStatus(r.Context(), body.Text); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil { return nil, fmt.Errorf("error starting channels: %w", err) } diff --git a/tools/PicoWatch/PicoWatch/AppDelegate.swift b/tools/PicoWatch/PicoWatch/AppDelegate.swift new file mode 100644 index 000000000..2905491d8 --- /dev/null +++ b/tools/PicoWatch/PicoWatch/AppDelegate.swift @@ -0,0 +1,89 @@ +import AppKit +import SwiftUI + +@MainActor +class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem! + private var popover: NSPopover! + private var engine: MonitorEngine! + private var eventMonitor: Any? + private var pulseTimer: Timer? + + func applicationDidFinishLaunching(_ notification: Notification) { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem.button { + button.image = NSImage(systemSymbolName: "brain.head.profile", + accessibilityDescription: "PicoWatch") + button.action = #selector(togglePopover) + button.target = self + } + + engine = MonitorEngine() + + popover = NSPopover() + popover.contentSize = NSSize(width: 380, height: 720) + popover.behavior = .transient + popover.contentViewController = NSHostingController( + rootView: PopoverView(engine: engine) + ) + + NSUserNotificationCenter.default.delegate = self + + engine.start() + + // Pulse the status bar icon to show activity + pulseTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateStatusIcon() + } + } + + eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in + if let popover = self?.popover, popover.isShown { + popover.performClose(nil) + } + } + } + + private func updateStatusIcon() { + guard let button = statusItem.button else { return } + let symbolName: String + if engine.gatewayUp { + symbolName = engine.recentSkillActivity ? "brain.head.profile.fill" : "brain.head.profile" + } else { + symbolName = "brain.head.profile" + } + button.image = NSImage(systemSymbolName: symbolName, + accessibilityDescription: "PicoWatch") + // Show skill count as badge + let skillCount = engine.totalSkills + button.title = skillCount > 0 ? " \(skillCount)" : "" + } + + @objc private func togglePopover() { + if let button = statusItem.button { + if popover.isShown { + popover.performClose(nil) + } else { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + NSApp.activate(ignoringOtherApps: true) + } + } + } + + func applicationWillTerminate(_ notification: Notification) { + pulseTimer?.invalidate() + engine.stop() + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + } + } +} + +extension AppDelegate: NSUserNotificationCenterDelegate { + func userNotificationCenter(_ center: NSUserNotificationCenter, + shouldPresent notification: NSUserNotification) -> Bool { + return true + } +} diff --git a/tools/PicoWatch/PicoWatch/Info.plist b/tools/PicoWatch/PicoWatch/Info.plist new file mode 100644 index 000000000..c8deb2cfd --- /dev/null +++ b/tools/PicoWatch/PicoWatch/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDisplayName + PicoWatch + CFBundleExecutable + PicoWatch + CFBundleIdentifier + io.picoclaw.PicoWatch + CFBundleName + PicoWatch + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSUIElement + + NSHighResolutionCapable + + + diff --git a/tools/PicoWatch/PicoWatch/MonitorEngine.swift b/tools/PicoWatch/PicoWatch/MonitorEngine.swift new file mode 100644 index 000000000..deb7174c2 --- /dev/null +++ b/tools/PicoWatch/PicoWatch/MonitorEngine.swift @@ -0,0 +1,683 @@ +import AppKit +import Foundation + +// MARK: - Event types + +struct SkillEvent: Codable, Identifiable { + let id: UUID + let timestamp: Date + let operation: String // create, update, patch, delete, block, tool_call + let skillName: String + let detail: String + + init(operation: String, skillName: String, detail: String = "") { + self.id = UUID() + self.timestamp = Date() + self.operation = operation + self.skillName = skillName + self.detail = detail + } +} + +struct DailyStats: Codable, Identifiable { + let date: String // yyyy-MM-dd + var skillsCreated: Int = 0 + var skillsPatched: Int = 0 + var skillsDeleted: Int = 0 + var blockedAttempts: Int = 0 + var totalConversations: Int = 0 + var toolCalls: Int = 0 + var skillManageCalls: Int = 0 + var statusViews: Int = 0 + var trialStarts: Int = 0 + + var id: String { date } + + var totalSkillOps: Int { skillsCreated + skillsPatched + skillsDeleted } +} + +struct StatusInteraction: Codable, Identifiable { + let id: UUID + let timestamp: Date + let phone: String + let content: String + let crmStatus: String // active, inactive, trial, trial_expired, ignored, unknown + let contactName: String + + init(phone: String, content: String, crmStatus: String = "unknown", contactName: String = "") { + self.id = UUID() + self.timestamp = Date() + self.phone = phone + self.content = content + self.crmStatus = crmStatus + self.contactName = contactName + } +} + +// MARK: - Engine + +@MainActor +final class MonitorEngine: ObservableObject { + // Live state + @Published var gatewayUp = false + @Published var gatewayUptime = "" + @Published var totalSkills = 0 + @Published var skillsList: [SkillInfo] = [] + @Published var recentEvents: [SkillEvent] = [] + @Published var recentSkillActivity = false + @Published var weeklyStats: [DailyStats] = [] + @Published var todayStats = DailyStats(date: MonitorEngine.todayString()) + @Published var lastMessage = "" + @Published var activeSessionCount = 0 + @Published var statusInteractions: [StatusInteraction] = [] + @Published var trialInteractions: [StatusInteraction] = [] + + struct SkillInfo: Identifiable { + let name: String + let description: String + let modified: Date + var id: String { name } + } + + private let picoHome = NSHomeDirectory() + "/.picoclaw" + private let skillsDir: String + private let sessionsDir: String + private let logPath: String + private let statsPath: String + + // Session file tracking + private struct TrackedSession { + let path: String + var offset: UInt64 + let sessionId: String + } + private var trackedSessions: [String: TrackedSession] = [:] + + // Status broadcast parsing state + private var pendingStatusContent: String = "" + private var pendingStatusPhone: String = "" + + // Trial detection: sessionId -> phone from check-access.sh + private var pendingSessionPhone: [String: String] = [:] + // Sessions already counted as trial (dedup) + private var countedTrialSessions: Set = [] + + private struct CRMContact { + let name: String + let status: String + } + private var crmCache: [String: CRMContact] = [:] + + // Log file tracking + private var logOffset: UInt64 = 0 + + private var healthTimer: Timer? + private var sessionTimer: Timer? + private var sessionScanTimer: Timer? + private var skillsTimer: Timer? + private var statsTimer: Timer? + private var dirWatcher: DispatchSourceFileSystemObject? + + init() { + skillsDir = picoHome + "/workspace/skills" + sessionsDir = picoHome + "/workspace/sessions" + logPath = picoHome + "/logs/gateway.log" + statsPath = picoHome + "/logs/picowatch_stats.json" + } + + func start() { + loadStats() + loadCRM() + scanSkills() + discoverSessions() + seekLogEnd() + + processAllSessions(initialScan: true) + + healthTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in + Task { @MainActor in self?.checkHealth() } + } + Task { checkHealth() } + + sessionTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in + Task { @MainActor in self?.processAllSessions(initialScan: false) } + } + + sessionScanTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in + Task { @MainActor in self?.discoverSessions() } + } + + skillsTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in + Task { @MainActor in self?.scanSkills() } + } + + Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in + Task { @MainActor in self?.tailLog() } + } + + statsTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + Task { @MainActor in self?.saveStats() } + } + + Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + Task { @MainActor in self?.loadCRM() } + } + + watchSkillsDir() + } + + func stop() { + healthTimer?.invalidate() + sessionTimer?.invalidate() + sessionScanTimer?.invalidate() + skillsTimer?.invalidate() + statsTimer?.invalidate() + dirWatcher?.cancel() + saveStats() + } + + // MARK: - Health + + private func checkHealth() { + let url = URL(string: "http://127.0.0.1:18790/health")! + let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + Task { @MainActor in + guard let self else { return } + if let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let status = json["status"] as? String, status == "ok" { + self.gatewayUp = true + self.gatewayUptime = (json["uptime"] as? String) ?? "" + } else { + self.gatewayUp = false + self.gatewayUptime = "" + } + } + } + task.resume() + } + + // MARK: - Session files (JSONL) + + private func discoverSessions() { + let fm = FileManager.default + guard let files = try? fm.contentsOfDirectory(atPath: sessionsDir) else { return } + + for file in files where file.hasSuffix(".jsonl") { + let path = sessionsDir + "/" + file + guard trackedSessions[path] == nil else { continue } + let sessionId = file.replacingOccurrences(of: ".jsonl", with: "") + trackedSessions[path] = TrackedSession(path: path, offset: 0, sessionId: sessionId) + } + + activeSessionCount = trackedSessions.count + } + + private func processAllSessions(initialScan: Bool) { + let fm = FileManager.default + + for (path, session) in trackedSessions { + guard let attrs = try? fm.attributesOfItem(atPath: path), + let size = (attrs[.size] as? NSNumber)?.uint64Value else { continue } + + if size <= session.offset { continue } + + var offset = session.offset + if initialScan && offset == 0 && size > 20_000 { + offset = size - 20_000 + } + + guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { continue } + defer { try? handle.close() } + + do { try handle.seek(toOffset: offset) } catch { continue } + + let data = handle.readDataToEndOfFile() + guard let text = String(data: data, encoding: .utf8) else { continue } + + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let startIdx = (initialScan && offset > 0) ? 1 : 0 + + for i in startIdx..", with: " ") + lastMessage = String(clean.prefix(100)) + } + } + + // Detect tool_calls + if let toolCalls = json["tool_calls"] as? [[String: Any]] { + for tc in toolCalls { + guard let function = tc["function"] as? [String: Any], + let fnName = function["name"] as? String else { continue } + + todayStats.toolCalls += 1 + + // Track check-access.sh for trial detection + if fnName == "exec", + let argsStr = function["arguments"] as? String, + argsStr.contains("check-access.sh") { + if let range = argsStr.range(of: "check-access.sh ") { + let raw = String(argsStr[range.upperBound...]) + .replacingOccurrences(of: "\"", with: "") + .replacingOccurrences(of: "}", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let digits = raw.unicodeScalars + .filter { CharacterSet.decimalDigits.contains($0) } + .map { String($0) }.joined() + if digits.count >= 10 { + pendingSessionPhone[sessionId] = "+" + digits + } + } + } + + if fnName == "skill_manage" { + todayStats.skillManageCalls += 1 + + if let argsStr = function["arguments"] as? String, + let argsData = argsStr.data(using: .utf8), + let args = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] { + + let op = args["operation"] as? String ?? "unknown" + let name = args["name"] as? String ?? "?" + + recentSkillActivity = true + switch op { + case "create": + todayStats.skillsCreated += 1 + addEvent(SkillEvent(operation: "create", skillName: name)) + case "patch": + todayStats.skillsPatched += 1 + addEvent(SkillEvent(operation: "patch", skillName: name)) + case "update": + todayStats.skillsPatched += 1 + addEvent(SkillEvent(operation: "update", skillName: name)) + case "delete": + todayStats.skillsDeleted += 1 + addEvent(SkillEvent(operation: "delete", skillName: name)) + case "list": + addEvent(SkillEvent(operation: "list", skillName: "all", + detail: "Agente listou skills")) + default: + break + } + } + } else if !isInitial { + addEvent(SkillEvent(operation: "tool_call", skillName: fnName, + detail: "via \(sessionId.prefix(20))")) + } + } + } + + // Detect trial starts from check-access.sh tool responses + if role == "tool", let phone = pendingSessionPhone[sessionId] { + let output = (json["content"] as? String) ?? "" + if output.contains("STATUS: TRIAL") && !output.contains("TRIAL_EXPIRED") { + // Dedup: only count once per session + if !countedTrialSessions.contains(sessionId) { + countedTrialSessions.insert(sessionId) + // Extract real phone from TELEFONE: +XXXX in tool output + var realPhone = phone + if let telRange = output.range(of: "TELEFONE: ") { + let after = String(output[telRange.upperBound...]) + let phoneLine = after.components(separatedBy: .newlines).first ?? "" + let cleaned = phoneLine.trimmingCharacters(in: .whitespaces) + if !cleaned.isEmpty { realPhone = cleaned } + } + let contact = crmCache[realPhone] + let name = contact?.name ?? "" + let interaction = StatusInteraction( + phone: realPhone, + content: "Conversa de teste iniciada", + crmStatus: "trial", + contactName: name + ) + trialInteractions.insert(interaction, at: 0) + if trialInteractions.count > 30 { + trialInteractions = Array(trialInteractions.prefix(30)) + } + todayStats.trialStarts += 1 + if !isInitial { + sendTrialNotification(phone: realPhone, name: name) + } + } + pendingSessionPhone.removeValue(forKey: sessionId) + } else if output.contains("STATUS:") { + pendingSessionPhone.removeValue(forKey: sessionId) + } + } + } + + // MARK: - Status broadcast + + private func processStatusLine(_ json: [String: Any], role: String, isInitial: Bool) { + if role == "user" { + pendingStatusContent = (json["content"] as? String) ?? "" + pendingStatusPhone = "" + } + + if role == "assistant", let toolCalls = json["tool_calls"] as? [[String: Any]] { + for tc in toolCalls { + guard let function = tc["function"] as? [String: Any], + let argsStr = function["arguments"] as? String, + argsStr.contains("check-access.sh") else { continue } + + if let range = argsStr.range(of: "check-access.sh ") { + let raw = String(argsStr[range.upperBound...]) + .replacingOccurrences(of: "\"", with: "") + .replacingOccurrences(of: "}", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let digits = raw.unicodeScalars + .filter { CharacterSet.decimalDigits.contains($0) } + .map { String($0) }.joined() + if digits.count >= 10 { + pendingStatusPhone = "+" + digits + } + } + } + } + + if role == "tool", !pendingStatusPhone.isEmpty { + let output = (json["content"] as? String) ?? "" + + var crmStatus = "unknown" + if output.contains("STATUS: ACTIVE") { crmStatus = "active" } + else if output.contains("STATUS: TRIAL_EXPIRED") { crmStatus = "trial_expired" } + else if output.contains("STATUS: TRIAL") { crmStatus = "trial" } + else if output.contains("STATUS: INACTIVE") { crmStatus = "inactive" } + else if output.contains("STATUS: IGNORED") { crmStatus = "ignored" } + + let contact = crmCache[pendingStatusPhone] + let name = contact?.name ?? "" + let finalStatus = contact?.status ?? crmStatus + + let interaction = StatusInteraction( + phone: pendingStatusPhone, + content: pendingStatusContent, + crmStatus: finalStatus, + contactName: name + ) + statusInteractions.insert(interaction, at: 0) + if statusInteractions.count > 30 { + statusInteractions = Array(statusInteractions.prefix(30)) + } + if !isInitial { + todayStats.statusViews += 1 + recentSkillActivity = true + } + + pendingStatusContent = "" + pendingStatusPhone = "" + } + } + + // MARK: - CRM cache + + private func loadCRM() { + let subscribersPath = picoHome + "/workspace/scripts/.subscribers.json" + let trialsPath = picoHome + "/workspace/scripts/.trials.json" + var cache: [String: CRMContact] = [:] + + if let data = try? Data(contentsOf: URL(fileURLWithPath: subscribersPath)), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let subs = json["subscribers"] as? [String: [String: Any]] { + for (phone, info) in subs { + let name = info["name"] as? String ?? "" + let status = info["status"] as? String ?? "unknown" + cache[phone] = CRMContact(name: name, status: status) + } + } + + if let data = try? Data(contentsOf: URL(fileURLWithPath: trialsPath)), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let trials = json["trials"] as? [String: [String: Any]] { + for (phone, info) in trials { + if cache[phone] != nil { continue } + let name = info["name"] as? String ?? "" + let step = info["step"] as? String ?? "" + let status = step == "connected" ? "trial_expired" : "trial" + cache[phone] = CRMContact(name: name, status: status) + } + } + + crmCache = cache + } + + // MARK: - Gateway log (for security blocks) + + private func seekLogEnd() { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: logPath), + let size = (attrs[.size] as? NSNumber)?.uint64Value else { return } + logOffset = size + } + + private func tailLog() { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: logPath), + let size = (attrs[.size] as? NSNumber)?.uint64Value, + size > logOffset else { return } + + guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: logPath)) else { return } + defer { try? handle.close() } + + try? handle.seek(toOffset: logOffset) + let data = handle.readDataToEndOfFile() + logOffset = size + + guard let text = String(data: data, encoding: .utf8) else { return } + + for line in text.split(separator: "\n") { + let s = String(line) + guard s.hasPrefix("{"), + let d = s.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else { continue } + + let message = json["message"] as? String ?? "" + if message.contains("security scan blocked") || message.contains("guard blocked") { + todayStats.blockedAttempts += 1 + let name = json["skill_name"] as? String ?? "unknown" + addEvent(SkillEvent(operation: "block", skillName: name, detail: message)) + recentSkillActivity = true + } + } + } + + // MARK: - Skills directory + + private func scanSkills() { + let fm = FileManager.default + guard let dirs = try? fm.contentsOfDirectory(atPath: skillsDir) else { + totalSkills = 0 + skillsList = [] + return + } + + var skills: [SkillInfo] = [] + for dir in dirs { + let skillMd = skillsDir + "/\(dir)/SKILL.md" + guard fm.fileExists(atPath: skillMd) else { continue } + let attrs = try? fm.attributesOfItem(atPath: skillMd) + let modified = (attrs?[.modificationDate] as? Date) ?? Date.distantPast + let desc = extractDescription(from: skillMd) + skills.append(SkillInfo(name: dir, description: desc, modified: modified)) + } + + skills.sort { $0.modified > $1.modified } + totalSkills = skills.count + skillsList = skills + } + + private func extractDescription(from path: String) -> String { + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return "" } + let lines = content.components(separatedBy: .newlines) + var inFrontmatter = false + for line in lines { + if line.trimmingCharacters(in: .whitespaces) == "---" { + if inFrontmatter { break } + inFrontmatter = true + continue + } + if inFrontmatter && line.hasPrefix("description:") { + return String(line.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces) + } + } + return "" + } + + private func watchSkillsDir() { + let fd = open(skillsDir, O_EVTONLY) + guard fd >= 0 else { return } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .rename, .delete], + queue: .main + ) + source.setEventHandler { [weak self] in + Task { @MainActor in self?.scanSkills() } + } + source.setCancelHandler { close(fd) } + source.resume() + dirWatcher = source + } + + // MARK: - Notifications + + private func sendTrialNotification(phone: String, name: String) { + let notification = NSUserNotification() + notification.title = "Novo Trial no WhatsApp" + notification.subtitle = name.isEmpty ? phone : "\(name) (\(phone))" + notification.informativeText = "Usuario em fase trial iniciou conversa" + notification.soundName = NSUserNotificationDefaultSoundName + NSUserNotificationCenter.default.deliver(notification) + updateWhatsAppStatus() + } + + private func updateWhatsAppStatus() { + let count = todayStats.trialStarts + let f = DateFormatter() + f.dateFormat = "dd/MM" + let text = "\(f.string(from: Date())): \(count)" + let url = URL(string: "http://127.0.0.1:18790/api/send-status")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["text": text]) + URLSession.shared.dataTask(with: request) { _, _, _ in }.resume() + } + + // MARK: - Events + + private func addEvent(_ event: SkillEvent) { + recentEvents.insert(event, at: 0) + if recentEvents.count > 50 { + recentEvents = Array(recentEvents.prefix(50)) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in + self?.recentSkillActivity = false + } + } + + // MARK: - Stats persistence + + static func todayString() -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f.string(from: Date()) + } + + private func loadStats() { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: statsPath)), + var stats = try? JSONDecoder().decode([DailyStats].self, from: data) else { + weeklyStats = [] + return + } + + let cutoff = Calendar.current.date(byAdding: .day, value: -7, to: Date())! + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + stats = stats.filter { stat in + guard let d = f.date(from: stat.date) else { return false } + return d >= cutoff + } + + weeklyStats = stats + + let today = Self.todayString() + if let existing = stats.first(where: { $0.date == today }) { + todayStats = existing + } + } + + private func saveStats() { + let today = Self.todayString() + todayStats = DailyStats( + date: today, + skillsCreated: todayStats.skillsCreated, + skillsPatched: todayStats.skillsPatched, + skillsDeleted: todayStats.skillsDeleted, + blockedAttempts: todayStats.blockedAttempts, + totalConversations: todayStats.totalConversations, + toolCalls: todayStats.toolCalls, + skillManageCalls: todayStats.skillManageCalls, + statusViews: todayStats.statusViews, + trialStarts: todayStats.trialStarts + ) + + var stats = weeklyStats.filter { $0.date != today } + stats.append(todayStats) + stats.sort { $0.date < $1.date } + + if stats.count > 7 { + stats = Array(stats.suffix(7)) + } + + weeklyStats = stats + + if let data = try? JSONEncoder().encode(stats) { + try? data.write(to: URL(fileURLWithPath: statsPath)) + } + } + + // MARK: - Weekly summary + + var weeklySummary: (skills: Int, patches: Int, blocks: Int, conversations: Int, toolCalls: Int, statusViews: Int, trialStarts: Int) { + let all = weeklyStats + [todayStats] + return ( + skills: all.reduce(0) { $0 + $1.skillsCreated }, + patches: all.reduce(0) { $0 + $1.skillsPatched }, + blocks: all.reduce(0) { $0 + $1.blockedAttempts }, + conversations: all.reduce(0) { $0 + $1.totalConversations }, + toolCalls: all.reduce(0) { $0 + $1.toolCalls }, + statusViews: all.reduce(0) { $0 + $1.statusViews }, + trialStarts: all.reduce(0) { $0 + $1.trialStarts } + ) + } +} diff --git a/tools/PicoWatch/PicoWatch/PicoWatchApp.swift b/tools/PicoWatch/PicoWatch/PicoWatchApp.swift new file mode 100644 index 000000000..d766113ba --- /dev/null +++ b/tools/PicoWatch/PicoWatch/PicoWatchApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct PicoWatchApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + Settings { + EmptyView() + } + } +} diff --git a/tools/PicoWatch/PicoWatch/PopoverView.swift b/tools/PicoWatch/PicoWatch/PopoverView.swift new file mode 100644 index 000000000..40f25e330 --- /dev/null +++ b/tools/PicoWatch/PicoWatch/PopoverView.swift @@ -0,0 +1,490 @@ +import SwiftUI + +struct PopoverView: View { + @ObservedObject var engine: MonitorEngine + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + headerView + Divider() + gatewayStatus + Divider() + trialActivity + Divider() + weeklyReport + Divider() + recentActivity + Divider() + footerView + } + .frame(width: 380) + } + + // MARK: - Header + + private var headerView: some View { + HStack(spacing: 8) { + Image(systemName: "brain.head.profile") + .font(.title3) + .foregroundStyle(.purple) + Text("PicoWatch") + .font(.headline) + Spacer() + HStack(spacing: 5) { + Circle() + .fill(engine.gatewayUp ? Color.green : Color.red) + .frame(width: 7, height: 7) + Text(engine.gatewayUp ? "Online" : "Offline") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + // MARK: - Gateway + + private var gatewayStatus: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Gateway") + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + if engine.gatewayUp { + Text("Uptime: \(engine.gatewayUptime)") + .font(.system(.caption, design: .monospaced)) + } else { + Text("Not running") + .font(.caption) + .foregroundStyle(.red) + } + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("Skills") + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Text("\(engine.totalSkills)") + .font(.system(.title2, design: .rounded, weight: .bold)) + .foregroundStyle(.purple) + } + } + if !engine.lastMessage.isEmpty { + HStack(spacing: 5) { + Image(systemName: "bubble.left.fill") + .font(.system(size: 8)) + .foregroundStyle(.green) + Text(engine.lastMessage) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + HStack(spacing: 4) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 8)) + .foregroundStyle(.tertiary) + Text("\(engine.activeSessionCount) sessoes ativas") + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + // MARK: - Trial Activity + + private var trialActivity: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "person.badge.clock") + .font(.system(size: 10)) + .foregroundStyle(.orange) + Text("Conversas de Teste") + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text("\(engine.todayStats.trialStarts) hoje") + .font(.system(size: 9, weight: .semibold, design: .rounded)) + .foregroundStyle(.orange) + } + + if engine.trialInteractions.isEmpty { + Text("Nenhuma conversa de teste ainda") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.vertical, 2) + } else { + ScrollView { + VStack(spacing: 4) { + ForEach(engine.trialInteractions.prefix(6)) { trial in + HStack(spacing: 8) { + Circle() + .fill(Color.orange) + .frame(width: 7, height: 7) + VStack(alignment: .leading, spacing: 1) { + Text(trial.contactName.isEmpty ? trial.phone : trial.contactName) + .font(.system(size: 10, weight: .semibold)) + .lineLimit(1) + Text(trial.content) + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Spacer() + Text(relativeDate(trial.timestamp)) + .font(.system(size: 8, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 5).fill(Color.orange.opacity(0.06))) + } + } + } + .frame(maxHeight: 110) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + // MARK: - Status Broadcast + + private var statusActivity: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.system(size: 10)) + .foregroundStyle(.cyan) + Text("Status Broadcast") + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text("\(engine.todayStats.statusViews) hoje") + .font(.system(size: 9, weight: .semibold, design: .rounded)) + .foregroundStyle(.cyan) + } + + if engine.statusInteractions.isEmpty { + Text("Nenhuma interacao de status ainda") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.vertical, 2) + } else { + ScrollView { + VStack(spacing: 4) { + ForEach(engine.statusInteractions.prefix(6)) { interaction in + statusRow(interaction) + } + } + } + .frame(maxHeight: 110) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func statusRow(_ interaction: StatusInteraction) -> some View { + HStack(spacing: 8) { + Circle() + .fill(crmBadgeColor(interaction.crmStatus)) + .frame(width: 7, height: 7) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Text(interaction.contactName.isEmpty ? interaction.phone : interaction.contactName) + .font(.system(size: 10, weight: .semibold)) + .lineLimit(1) + Text(crmBadgeLabel(interaction.crmStatus)) + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(crmBadgeColor(interaction.crmStatus)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(crmBadgeColor(interaction.crmStatus).opacity(0.12)) + ) + } + if !interaction.content.isEmpty { + Text(interaction.content) + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + Spacer() + Text(relativeDate(interaction.timestamp)) + .font(.system(size: 8, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 5).fill(Color.cyan.opacity(0.04))) + } + + private func crmBadgeColor(_ status: String) -> Color { + switch status { + case "active": return .green + case "trial": return .orange + case "trial_expired": return .red + case "inactive": return .red + case "ignored": return .gray + default: return .secondary + } + } + + private func crmBadgeLabel(_ status: String) -> String { + switch status { + case "active": return "assinante" + case "trial": return "trial" + case "trial_expired": return "expirado" + case "inactive": return "inativo" + case "ignored": return "ignorado" + default: return "novo" + } + } + + // MARK: - Skills + + private var skillsSummary: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Installed Skills") + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + if engine.skillsList.isEmpty { + Text("Nenhuma skill ainda — o agente cria quando completa tarefas complexas") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.vertical, 4) + } else { + ForEach(engine.skillsList) { skill in + HStack(spacing: 8) { + Image(systemName: "doc.text") + .font(.system(size: 10)) + .foregroundStyle(.purple) + VStack(alignment: .leading, spacing: 1) { + Text(skill.name) + .font(.system(.caption, design: .monospaced, weight: .semibold)) + if !skill.description.isEmpty { + Text(skill.description) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + Text(relativeDate(skill.modified)) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(RoundedRectangle(cornerRadius: 5).fill(Color.purple.opacity(0.06))) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + // MARK: - Weekly Report + + private var weeklyReport: some View { + let summary = engine.weeklySummary + return VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Relatorio Semanal") + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text("\(engine.weeklyStats.count + 1) dias") + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + } + + HStack(spacing: 6) { + metricCard(icon: "person.badge.clock", color: .orange, + label: "Trials", value: "\(summary.trialStarts)") + } + + // Mini bar chart for daily activity + if !engine.weeklyStats.isEmpty { + weeklyChart + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func metricCard(icon: String, color: Color, label: String, value: String) -> some View { + VStack(spacing: 3) { + HStack(spacing: 3) { + Image(systemName: icon) + .font(.system(size: 8)) + .foregroundStyle(color) + Text(value) + .font(.system(.callout, design: .rounded, weight: .bold)) + } + Text(label) + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.08))) + } + + private var weeklyChart: some View { + let allDays = engine.weeklyStats + [engine.todayStats] + let maxOps = max(allDays.map(\.totalSkillOps).max() ?? 1, 1) + + return HStack(alignment: .bottom, spacing: 3) { + ForEach(allDays) { day in + VStack(spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.purple.opacity(0.6)) + .frame(height: max(2, CGFloat(day.totalSkillOps) / CGFloat(maxOps) * 30)) + Text(dayLabel(day.date)) + .font(.system(size: 7, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity) + } + } + .frame(height: 45) + } + + // MARK: - Recent Activity + + private var recentActivity: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Atividade Recente") + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + if engine.recentEvents.isEmpty { + Text("Nenhum evento de skill ainda — use o agente no WhatsApp") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.vertical, 4) + } else { + ScrollView { + VStack(spacing: 4) { + ForEach(engine.recentEvents.prefix(8)) { event in + eventRow(event) + } + } + } + .frame(maxHeight: 120) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func eventRow(_ event: SkillEvent) -> some View { + HStack(spacing: 8) { + Image(systemName: eventIcon(event.operation)) + .font(.system(size: 10)) + .foregroundStyle(eventColor(event.operation)) + .frame(width: 16) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Text(eventLabel(event.operation)) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(eventColor(event.operation)) + Text(event.skillName) + .font(.system(size: 10, design: .monospaced)) + } + if !event.detail.isEmpty { + Text(event.detail) + .font(.system(size: 8)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + Spacer() + Text(relativeDate(event.timestamp)) + .font(.system(size: 8, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + } + + private func eventIcon(_ op: String) -> String { + switch op { + case "create": return "plus.circle.fill" + case "patch", "update": return "wrench.fill" + case "delete": return "trash.fill" + case "block": return "shield.slash.fill" + default: return "circle.fill" + } + } + + private func eventColor(_ op: String) -> Color { + switch op { + case "create": return .green + case "patch", "update": return .orange + case "delete": return .red + case "block": return .red + default: return .gray + } + } + + private func eventLabel(_ op: String) -> String { + switch op { + case "create": return "CRIOU" + case "patch": return "PATCH" + case "update": return "UPDATE" + case "delete": return "DELETOU" + case "block": return "BLOQUEOU" + default: return op.uppercased() + } + } + + // MARK: - Footer + + private var footerView: some View { + HStack { + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .font(.caption) + Spacer() + Text("~/.picoclaw/workspace/skills") + .font(.system(size: 8, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + // MARK: - Helpers + + private func relativeDate(_ date: Date) -> String { + let elapsed = Date().timeIntervalSince(date) + if elapsed < 60 { return "agora" } + if elapsed < 3600 { return "\(Int(elapsed / 60))m" } + if elapsed < 86400 { return "\(Int(elapsed / 3600))h" } + return "\(Int(elapsed / 86400))d" + } + + private func dayLabel(_ dateStr: String) -> String { + String(dateStr.suffix(2)) + } +} diff --git a/tools/PicoWatch/build.sh b/tools/PicoWatch/build.sh new file mode 100755 index 000000000..74b5f1f91 --- /dev/null +++ b/tools/PicoWatch/build.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -euo pipefail + +APP_NAME="PicoWatch" +BUILD_DIR="build" +APP_DIR="$BUILD_DIR/$APP_NAME.app" + +echo "Building $APP_NAME..." + +rm -rf "$BUILD_DIR" +mkdir -p "$APP_DIR/Contents/MacOS" +mkdir -p "$APP_DIR/Contents/Resources" + +xcrun swiftc \ + -sdk "$(xcrun --show-sdk-path -sdk macosx)" \ + -target arm64-apple-macosx14.0 \ + -framework AppKit -framework SwiftUI \ + -parse-as-library \ + -O \ + PicoWatch/PicoWatchApp.swift \ + PicoWatch/AppDelegate.swift \ + PicoWatch/MonitorEngine.swift \ + PicoWatch/PopoverView.swift \ + -o "$APP_DIR/Contents/MacOS/$APP_NAME" + +cp PicoWatch/Info.plist "$APP_DIR/Contents/Info.plist" + +echo "Built: $APP_DIR" +echo "Run with: open \"$APP_DIR\""