From d0f10e669cfc78c18b497dcbec5d92cd5ea11166 Mon Sep 17 00:00:00 2001 From: Evan Zheng <88683151+theMobiusStrip@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:31:34 -0700 Subject: [PATCH] Add local detection insights Co-authored-by: Codex --- README.md | 20 +- Sources/Perch/App/AppDelegate.swift | 12 + Sources/Perch/App/Selftest.swift | 238 +++++++++- Sources/Perch/App/ShowcaseRenderer.swift | 2 +- Sources/Perch/Model/DetectionInsights.swift | 442 +++++++++++++++++ Sources/Perch/Model/DetectionStore.swift | 148 ++++++ Sources/Perch/UI/InsightsWindow.swift | 499 ++++++++++++++++++++ Sources/Perch/UI/NotchController.swift | 4 + Sources/Perch/UI/NotchRootView.swift | 7 + Sources/Perch/UI/StatusItemController.swift | 2 + docs/detection-storage.md | 31 +- 11 files changed, 1390 insertions(+), 15 deletions(-) create mode 100644 Sources/Perch/Model/DetectionInsights.swift create mode 100644 Sources/Perch/UI/InsightsWindow.swift diff --git a/README.md b/README.md index a326e12..9aaaea5 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ and what they've **left behind**: | 📡 **Monitoring health** | A separate coverage strip checks the deployed bridge, local event socket, Claude wiring, and Codex hook trust, then waits for a real event from each configured agent before reporting delivery as verified. Its state also colors the collapsed notch and menu-bar bird; guided setup installs or repairs integrations. | | 🔔 **Alerts even when nothing prompts** | Danger fires an OS notification — including calls auto-approved by allow rules or relaxed permission modes. Notification actions jump straight to the exact detection, session, or usage view instead of leaving you to hunt for context. | | 📊 **Explainable security score** | A rolling 0–100 posture score in the notch and menu bar: −25 per danger, −5 per caution over the last hour. Open the strip for the formula and retained recent detections; dismissing an alert card does not erase its history. | +| 🔎 **Local Insights** | A zero-setup, offline view of caution/danger trends on this Mac: 24-hour, 7-day, and 30-day timelines plus findings grouped by code, agent, tool, and session (menu bar → **Insights…**). Perch records what it observed, not whether a request ran. | | 🐦 **Every session at a glance** | Live list of all Claude Code and Codex sessions — running / waiting / idle, last message, context gauge, red badge on any session that just ran something dangerous. | | 🎫 **Token usage** | Today / 7-day / 30-day totals in the notch, rate-limit gauges with reset countdowns, and a full per-day / per-model / per-project dashboard (menu bar → **Token Usage…**). | | 🌳 **Worktree housekeeping** | A read-only cross-project audit of the git worktrees agent sessions leave behind — classified `reclaimable` (clean, merged, stale), `review` (dirty or ahead of the default branch), `active` (a live session or recently touched), or `orphaned` — with disk sizes and a *Copy cleanup commands* button (menu bar → **Worktrees…**). Perch scores and reports; it never deletes. | @@ -239,7 +240,7 @@ danger raises an OS notification and a notch card. In parallel, Perch tails transcript/rollout files and validates liveness against `~/.claude/sessions` pid files, so sessions started before Perch launched are covered too. -Accepted caution/danger detections also write compact metadata to +Deduplicated caution/danger detections also write compact metadata to `~/Library/Application Support/Perch/detections.sqlite3` after the hook reply and live-feed deduplication. The database retains 30 days and restores only the past hour's posture after restart. It never stores commands, tool payloads, @@ -247,6 +248,12 @@ paths, prompts, finding prose, decisions, or outcomes. The versioned, read-only consumer contract is documented in [Detection storage](docs/detection-storage.md). +Menu bar → **Insights…** reads that same database locally for 24-hour, 7-day, +and 30-day timelines plus finding, agent, tool, and session aggregates. It +creates no second database, sends no telemetry, and does not claim that an +observed request was approved, denied, executed, or completed. The detailed +**Recent Detections…** view remains an in-memory past-hour feed. + One caveat: Claude's rate-limit gauges are fed by the statusline payload, which only terminal `claude` sessions render — the Claude desktop app never invokes it. Detection, sessions, and token totals work everywhere. @@ -266,12 +273,13 @@ be audited, not trusted**: fire-and-forget ~10 ms, and if Perch is wedged the hook gives up on its own after 5 s — the agent always proceeds. - **100% local detection, zero telemetry.** No analytics, no cloud detection - service — nothing Perch observes ever leaves your machine. Accepted + service — nothing Perch observes ever leaves your machine. Recorded caution/danger detections retain only minimal metadata in local SQLite for - 30 days; there is no uploader. The one network - call in the codebase is the optional update check: an unauthenticated GET - to the GitHub releases API, on by default, toggleable from the menu bar - (**Check Automatically**), and zero network when off. Verify it yourself: + 30 days; Insights reads that store in-process and there is no uploader. The + one network call in the codebase is the optional update check: an + unauthenticated GET to the GitHub releases API, on by default, toggleable + from the menu bar (**Check Automatically**), and zero network when off. + Verify it yourself: `grep -rn "URLSession\|NWConnection" Sources/` matches only [`UpdateChecker.swift`](Sources/Perch/Model/UpdateChecker.swift). - **The detector doesn't persist what it inspects.** Risk scoring is pure diff --git a/Sources/Perch/App/AppDelegate.swift b/Sources/Perch/App/AppDelegate.swift index 5b442da..367a972 100644 --- a/Sources/Perch/App/AppDelegate.swift +++ b/Sources/Perch/App/AppDelegate.swift @@ -9,6 +9,7 @@ struct AppActions { var openRecentDetections: () -> Void var toggleNotch: () -> Void var openDebugWindow: () -> Void + var openInsights: () -> Void var openUsageHistory: () -> Void var openWorktrees: () -> Void var quit: () -> Void @@ -45,6 +46,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var worktreeWindow: WorktreeWindowController? private var setupWindow: SetupWindowController? private var recentDetectionsWindow: RecentDetectionsWindowController? + private var insightsWindow: InsightsWindowController? private var cancellables = Set() /// True while the notch is showing attention we raised via onAttention. /// Lets the session-publish observer clear notification-driven @@ -138,6 +140,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { worktrees: worktreeModel, openWorktrees: { [weak self] in self?.openWorktreeWindow() }, openUsageHistory: { [weak self] in self?.openUsageHistoryWindow() }, + openInsights: { [weak self] in self?.openInsightsWindow() }, openSetup: { [weak self] in self?.openSetupWindow() }, openRecentDetections: { [weak self] in self?.openRecentDetectionsWindow() }) notchController.show() @@ -297,11 +300,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } self.debugWindow?.show() }, + openInsights: { [weak self] in self?.openInsightsWindow() }, openUsageHistory: { [weak self] in self?.openUsageHistoryWindow() }, openWorktrees: { [weak self] in self?.openWorktreeWindow() }, quit: { NSApp.terminate(nil) }) } + private func openInsightsWindow() { + if insightsWindow == nil { + insightsWindow = InsightsWindowController( + model: InsightsModel(store: detectionStore)) + } + insightsWindow?.show() + } + private func openWorktreeWindow() { if worktreeWindow == nil { worktreeWindow = WorktreeWindowController(model: worktreeModel) diff --git a/Sources/Perch/App/Selftest.swift b/Sources/Perch/App/Selftest.swift index e08493b..ebd5643 100644 --- a/Sources/Perch/App/Selftest.swift +++ b/Sources/Perch/App/Selftest.swift @@ -54,6 +54,8 @@ enum Selftest { detectionStoreTransactionAndDedupe(t) detectionStoreRetentionAndPostureRestore(t) detectionStoreFailureAndPermissions(t) + detectionInsightsAggregatesAndBounds(t) + detectionInsightsCalendarBuckets(t) sessionStorePersistsOnlyAcceptedMetadata(t) monitoringSnapshotSeparatesCoverageFromPosture(t) monitoringHealthSeparatesConfigurationFromVerification(t) @@ -2382,6 +2384,16 @@ private extension Selftest { String(DetectionStore.schemaVersion), "schemaVersion") t.expectEqual(sqliteColumnNames(databaseURL, "SELECT * FROM detection_export_v1 LIMIT 0"), DetectionStore.exportColumns, "exactExportColumns") + t.expectEqual( + sqliteRows(databaseURL, "PRAGMA table_info(detection_events)")? + .compactMap { $0[safe: 1] ?? nil }, + DetectionStore.eventColumns, + "exactEventColumns") + t.expectEqual( + sqliteRows(databaseURL, "PRAGMA table_info(detection_findings)")? + .compactMap { $0[safe: 1] ?? nil }, + DetectionStore.findingColumns, + "exactFindingColumns") store.close() let reopened = detectionStore(at: databaseURL) @@ -2585,6 +2597,223 @@ private extension Selftest { t.expectTrue(line.contains("30-day retention"), "doctorShowsRetention") } + @MainActor + static func detectionInsightsAggregatesAndBounds(_ t: Checker) { + t.suite("DetectionInsights.aggregatesAndBounds") + let root = detectionTempRoot() + defer { try? FileManager.default.removeItem(at: root) } + let databaseURL = root.appendingPathComponent("perch/detections.sqlite3") + let store = detectionStore(at: databaseURL) + + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let now = calendar.date(from: DateComponents( + year: 2026, month: 7, day: 23, hour: 12))! + guard (try? store.startSynchronously(now: now)) != nil else { + t.expectTrue(false, "open") + return + } + + let lowerBound = now.addingTimeInterval(-24 * 60 * 60) + let records = [ + detectionRecord( + id: "lower-bound", + at: lowerBound, + toolUseID: "lower-bound", + risk: .caution, + agent: .claude, + sessionID: "shared-session", + toolName: "Bash", + findings: [ + DetectionFindingRecord(code: "destructive-delete", level: .caution), + ]), + detectionRecord( + id: "multi-finding", + at: now.addingTimeInterval(-2 * 60 * 60), + toolUseID: "multi-finding", + agent: .claude, + sessionID: "shared-session", + toolName: "Bash", + findings: [ + DetectionFindingRecord(code: "destructive-delete", level: .danger), + DetectionFindingRecord(code: "privilege-escalation", level: .danger), + ]), + detectionRecord( + id: "same-raw-session-other-agent", + at: now.addingTimeInterval(-60 * 60), + toolUseID: "other-agent", + agent: .codex, + sessionID: "shared-session", + toolName: "shell", + findings: [ + DetectionFindingRecord(code: "privilege-escalation", level: .danger), + ]), + detectionRecord( + id: "upper-bound", + at: now, + toolUseID: "upper-bound", + risk: .caution, + agent: .claude, + sessionID: "edge-session", + toolName: "Write", + findings: [ + DetectionFindingRecord(code: "memory-pollution", level: .caution), + ]), + detectionRecord( + id: "older-calendar-day", + at: now.addingTimeInterval(-3 * 24 * 60 * 60), + toolUseID: "older", + risk: .caution, + agent: .codex, + sessionID: "older-session", + toolName: "WebFetch", + findings: [ + DetectionFindingRecord(code: "raw-ip", level: .caution), + ]), + detectionRecord( + id: "before-lower-bound", + at: lowerBound.addingTimeInterval(-1), + toolUseID: "before", + agent: .claude, + sessionID: "outside-session", + toolName: "Bash"), + detectionRecord( + id: "future", + at: now.addingTimeInterval(1), + toolUseID: "future", + agent: .claude, + sessionID: "outside-session", + toolName: "Bash"), + ] + for record in records { + t.expectEqual(try? store.insertSynchronously(record), true, + "insert.\(record.eventID)") + } + + guard let day = try? store.loadInsightsSynchronously( + range: .hours24, + now: now, + calendar: calendar) else { + t.expectTrue(false, "load24H") + store.close() + return + } + t.expectEqual(day.timeline.count, 24, "twentyFourHourlyBuckets") + t.expectEqual(day.startAt, lowerBound, "rollingLowerBound") + t.expectEqual(day.endAt, now, "inclusiveUpperBound") + t.expectEqual(day.cautionCount, 2, "cautionEventsCountOnce") + t.expectEqual(day.dangerCount, 2, "dangerEventsCountOnce") + t.expectEqual(day.detectionCount, 4, "multiFindingEventNotDuplicated") + t.expectEqual(day.timeline.reduce(0) { $0 + $1.totalCount }, 4, + "timelineMatchesDetectionTotal") + t.expectEqual(day.timeline.first?.cautionCount, 1, + "lowerBoundFallsInFirstBucket") + t.expectEqual(day.timeline.last?.cautionCount, 1, + "upperBoundFallsInFinalBucket") + t.expectEqual(day.findings, [ + DetectionFindingAggregate( + code: "privilege-escalation", level: .danger, count: 2), + DetectionFindingAggregate( + code: "destructive-delete", level: .danger, count: 1), + DetectionFindingAggregate( + code: "destructive-delete", level: .caution, count: 1), + DetectionFindingAggregate( + code: "memory-pollution", level: .caution, count: 1), + ], "findingCodeAndLevelAreSeparate") + t.expectEqual(day.agents.map { "\($0.agent.rawValue):\($0.detectionCount)" }, + ["claude:3", "codex:1"], "agentTotals") + t.expectEqual(day.tools.map { "\($0.toolName):\($0.detectionCount)" }, + ["Bash:2", "shell:1", "Write:1"], "toolTotals") + t.expectEqual(day.sessions.count, 3, "agentScopesRawSessionID") + t.expectEqual( + day.sessions.first { + $0.agent == .claude && $0.sessionID == "shared-session" + }?.detectionCount, + 2, + "sessionClustersDetections") + t.expectFalse(day.sessions.contains { $0.sessionID == "outside-session" }, + "outOfRangeSessionsExcluded") + + let week = try? store.loadInsightsSynchronously( + range: .days7, + now: now, + calendar: calendar) + t.expectEqual(week?.timeline.count, 7, "sevenCalendarBuckets") + t.expectEqual(week?.detectionCount, 6, "weekIncludesEarlierEvents") + t.expectEqual(week?.timeline.reduce(0) { $0 + $1.totalCount }, 6, + "weekTimelineMatchesTotal") + + let month = try? store.loadInsightsSynchronously( + range: .days30, + now: now, + calendar: calendar) + t.expectEqual(month?.timeline.count, 30, "thirtyCalendarBuckets") + t.expectEqual(month?.detectionCount, 6, "monthExcludesFutureEvent") + store.close() + } + + @MainActor + static func detectionInsightsCalendarBuckets(_ t: Checker) { + t.suite("DetectionInsights.calendarBuckets") + guard let losAngeles = TimeZone(identifier: "America/Los_Angeles"), + let utc = TimeZone(identifier: "UTC") else { + t.expectTrue(false, "timeZonesAvailable") + return + } + + var laCalendar = Calendar(identifier: .gregorian) + laCalendar.locale = Locale(identifier: "en_US_POSIX") + laCalendar.timeZone = losAngeles + + let springNow = laCalendar.date(from: DateComponents( + year: 2024, month: 3, day: 11, hour: 12))! + let spring = InsightsRange.days7.bucketDefinitions( + now: springNow, + calendar: laCalendar) + let springTransition = spring.first { $0.label == "Mar 10" } + t.expectEqual(spring.count, 7, "springHasSevenCalendarDays") + t.expectEqual(springTransition.map { $0.end.timeIntervalSince($0.start) } ?? -1, + 23 * 60 * 60, + accuracy: 0.1, + "springForwardDayIs23Hours") + + let fallNow = laCalendar.date(from: DateComponents( + year: 2024, month: 11, day: 3, hour: 12, minute: 30))! + let fallHours = InsightsRange.hours24.bucketDefinitions( + now: fallNow, + calendar: laCalendar) + let repeatedOneAM = fallHours.map(\.label).filter { + $0.hasPrefix("1 AM ") + } + t.expectEqual(fallHours.count, 24, "fallHasTwentyFourElapsedHours") + t.expectEqual(repeatedOneAM.count, 2, "repeatedHourLabelsIncludeZone") + t.expectEqual(Set(repeatedOneAM).count, 2, "repeatedHourLabelsAreDistinct") + + let fallDays = InsightsRange.days7.bucketDefinitions( + now: laCalendar.date(from: DateComponents( + year: 2024, month: 11, day: 4, hour: 12))!, + calendar: laCalendar) + let fallTransition = fallDays.first { $0.label == "Nov 3" } + t.expectEqual(fallTransition.map { $0.end.timeIntervalSince($0.start) } ?? -1, + 25 * 60 * 60, + accuracy: 0.1, + "fallBackDayIs25Hours") + + var utcCalendar = laCalendar + utcCalendar.timeZone = utc + let laSnapshot = DetectionInsightsSnapshot.aggregate( + rows: [], range: .days7, now: springNow, calendar: laCalendar) + let utcSnapshot = DetectionInsightsSnapshot.aggregate( + rows: [], range: .days7, now: springNow, calendar: utcCalendar) + t.expectEqual(laSnapshot.timeZoneIdentifier, losAngeles.identifier, + "snapshotCapturesLocalZone") + t.expectEqual(utcSnapshot.timeZoneIdentifier, utc.identifier, + "refreshCapturesChangedZone") + t.expectTrue(laSnapshot.startAt != utcSnapshot.startAt, + "changedZoneRecomputesCalendarBounds") + } + @MainActor static func sessionStorePersistsOnlyAcceptedMetadata(_ t: Checker) { t.suite("SessionStore.persistsOnlyAcceptedMetadata") @@ -2715,6 +2944,9 @@ private extension Selftest { at: Date, toolUseID: String?, risk: RiskLevel = .danger, + agent: AgentKind = .claude, + sessionID: String = "session-1", + toolName: String = "Bash", findings: [DetectionFindingRecord] = [ DetectionFindingRecord(code: "destructive-delete", level: .danger), ] @@ -2726,10 +2958,10 @@ private extension Selftest { endpointUser: "test-user", endpointHost: "test-host", producerVersion: "v-test", - agent: .claude, - sessionID: "session-1", + agent: agent, + sessionID: sessionID, toolUseID: toolUseID, - toolName: "Bash", + toolName: toolName, riskLevel: risk, findings: findings) } diff --git a/Sources/Perch/App/ShowcaseRenderer.swift b/Sources/Perch/App/ShowcaseRenderer.swift index 90d3eeb..f089b8f 100644 --- a/Sources/Perch/App/ShowcaseRenderer.swift +++ b/Sources/Perch/App/ShowcaseRenderer.swift @@ -216,7 +216,7 @@ enum ShowcaseRenderer { riskFeed: riskFeed, posture: posture, health: health, usageHistory: usageHistory, integrity: integrity, worktrees: worktrees, openWorktrees: {}, - openUsageHistory: {}, openSetup: {}, + openUsageHistory: {}, openInsights: {}, openSetup: {}, openRecentDetections: {}, renderStatic: true) .frame(width: state.expandedSize.width, height: state.expandedSize.height) // ImageRenderer otherwise lets the shell negotiate up to the diff --git a/Sources/Perch/Model/DetectionInsights.swift b/Sources/Perch/Model/DetectionInsights.swift new file mode 100644 index 0000000..484dbbd --- /dev/null +++ b/Sources/Perch/Model/DetectionInsights.swift @@ -0,0 +1,442 @@ +import Combine +import Foundation +import PerchCore + +enum InsightsRange: String, CaseIterable, Hashable, Identifiable, Sendable { + case hours24 = "24H" + case days7 = "7D" + case days30 = "30D" + + var id: String { rawValue } + + var accessibilityLabel: String { + switch self { + case .hours24: return "Past 24 hours" + case .days7: return "Past 7 calendar days" + case .days30: return "Past 30 calendar days" + } + } + + func bucketDefinitions(now: Date, calendar: Calendar) -> [InsightsBucketDefinition] { + switch self { + case .hours24: + return hourlyDefinitions(now: now, calendar: calendar) + case .days7: + return dailyDefinitions(days: 7, now: now, calendar: calendar) + case .days30: + return dailyDefinitions(days: 30, now: now, calendar: calendar) + } + } + + private func hourlyDefinitions(now: Date, + calendar: Calendar) -> [InsightsBucketDefinition] { + let start = now.addingTimeInterval(-24 * 60 * 60) + let starts = (0..<24).map { + start.addingTimeInterval(TimeInterval($0) * 60 * 60) + } + + let baseFormatter = DateFormatter() + baseFormatter.calendar = calendar + baseFormatter.locale = Locale(identifier: "en_US_POSIX") + baseFormatter.timeZone = calendar.timeZone + baseFormatter.dateFormat = "h a" + let baseLabels = starts.map(baseFormatter.string) + let frequencies = Dictionary(grouping: baseLabels, by: { $0 }) + .mapValues(\.count) + + let zonedFormatter = DateFormatter() + zonedFormatter.calendar = calendar + zonedFormatter.locale = Locale(identifier: "en_US_POSIX") + zonedFormatter.timeZone = calendar.timeZone + zonedFormatter.dateFormat = "h a zzz" + + return starts.enumerated().map { index, bucketStart in + let bucketEnd = index == starts.count - 1 + ? now + : starts[index + 1] + let base = baseLabels[index] + let label = frequencies[base, default: 0] > 1 + ? zonedFormatter.string(from: bucketStart) + : base + return InsightsBucketDefinition( + start: bucketStart, + end: bucketEnd, + label: label, + includesEnd: index == starts.count - 1) + } + } + + private func dailyDefinitions(days: Int, now: Date, + calendar: Calendar) -> [InsightsBucketDefinition] { + let today = calendar.startOfDay(for: now) + guard let first = calendar.date(byAdding: .day, value: -(days - 1), to: today) + else { return [] } + + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "MMM d" + + return (0.. Bool { + date >= start && (date < end || (includesEnd && date <= end)) + } +} + +struct DetectionTimelineBucket: Identifiable, Equatable, Sendable { + let start: Date + let end: Date + let label: String + var cautionCount: Int + var dangerCount: Int + + var id: Date { start } + var totalCount: Int { cautionCount + dangerCount } +} + +struct DetectionFindingAggregate: Identifiable, Equatable, Sendable { + let code: String + let level: RiskLevel + var count: Int + + var id: String { "\(code)|\(level.label)" } +} + +struct DetectionAgentAggregate: Identifiable, Equatable, Sendable { + let agent: AgentKind + var cautionCount: Int + var dangerCount: Int + + var id: String { agent.rawValue } + var detectionCount: Int { cautionCount + dangerCount } +} + +struct DetectionToolAggregate: Identifiable, Equatable, Sendable { + let toolName: String + var cautionCount: Int + var dangerCount: Int + + var id: String { toolName } + var detectionCount: Int { cautionCount + dangerCount } +} + +struct DetectionSessionAggregate: Identifiable, Equatable, Sendable { + let agent: AgentKind + let sessionID: String + let firstObservedAt: Date + let lastObservedAt: Date + let detectionCount: Int + let highestLevel: RiskLevel + let tools: [String] + let findings: [DetectionFindingAggregate] + + var id: String { "\(agent.rawValue)|\(sessionID)" } +} + +struct DetectionInsightsSnapshot: Equatable, Sendable { + let range: InsightsRange + let generatedAt: Date + let startAt: Date + let endAt: Date + let timeZoneIdentifier: String + let timeline: [DetectionTimelineBucket] + let findings: [DetectionFindingAggregate] + let agents: [DetectionAgentAggregate] + let tools: [DetectionToolAggregate] + let sessions: [DetectionSessionAggregate] + + var cautionCount: Int { timeline.reduce(0) { $0 + $1.cautionCount } } + var dangerCount: Int { timeline.reduce(0) { $0 + $1.dangerCount } } + var detectionCount: Int { cautionCount + dangerCount } + var isEmpty: Bool { detectionCount == 0 } + + static func aggregate(rows: [DetectionInsightsSourceRow], + range: InsightsRange, + now: Date, + calendar: Calendar) -> DetectionInsightsSnapshot { + let definitions = range.bucketDefinitions(now: now, calendar: calendar) + var timeline = definitions.map { + DetectionTimelineBucket( + start: $0.start, + end: $0.end, + label: $0.label, + cautionCount: 0, + dangerCount: 0) + } + + var seenEvents: Set = [] + var findingCounts: [String: DetectionFindingAggregate] = [:] + var agentCounts: [String: DetectionAgentAggregate] = [:] + var toolCounts: [String: DetectionToolAggregate] = [:] + var sessionBuilders: [String: DetectionSessionBuilder] = [:] + + for row in rows { + guard let bucketIndex = definitions.firstIndex(where: { + $0.contains(row.observedAt) + }) else { continue } + + let sessionKey = "\(row.agent.rawValue)|\(row.sessionID)" + if seenEvents.insert(row.eventID).inserted { + switch row.riskLevel { + case .caution: + timeline[bucketIndex].cautionCount += 1 + case .danger: + timeline[bucketIndex].dangerCount += 1 + case .safe: + continue + } + + var agent = agentCounts[row.agent.rawValue] + ?? DetectionAgentAggregate( + agent: row.agent, + cautionCount: 0, + dangerCount: 0) + agent.record(row.riskLevel) + agentCounts[row.agent.rawValue] = agent + + var tool = toolCounts[row.toolName] + ?? DetectionToolAggregate( + toolName: row.toolName, + cautionCount: 0, + dangerCount: 0) + tool.record(row.riskLevel) + toolCounts[row.toolName] = tool + + var session = sessionBuilders[sessionKey] + ?? DetectionSessionBuilder( + agent: row.agent, + sessionID: row.sessionID, + observedAt: row.observedAt, + level: row.riskLevel) + session.recordDetection( + observedAt: row.observedAt, + level: row.riskLevel, + toolName: row.toolName) + sessionBuilders[sessionKey] = session + } + + let findingKey = "\(row.findingCode)|\(row.findingLevel.label)" + var finding = findingCounts[findingKey] + ?? DetectionFindingAggregate( + code: row.findingCode, + level: row.findingLevel, + count: 0) + finding.count += 1 + findingCounts[findingKey] = finding + + if var session = sessionBuilders[sessionKey] { + session.recordFinding(code: row.findingCode, level: row.findingLevel) + sessionBuilders[sessionKey] = session + } + } + + let findings = findingCounts.values.sorted(by: Self.findingOrder) + let agents = agentCounts.values.sorted { + if $0.detectionCount != $1.detectionCount { + return $0.detectionCount > $1.detectionCount + } + return $0.agent.insightsDisplayName < $1.agent.insightsDisplayName + } + let tools = toolCounts.values.sorted { + if $0.detectionCount != $1.detectionCount { + return $0.detectionCount > $1.detectionCount + } + return $0.toolName.localizedCaseInsensitiveCompare($1.toolName) == .orderedAscending + } + let sessions = sessionBuilders.values + .map(\.aggregate) + .sorted { + if $0.lastObservedAt != $1.lastObservedAt { + return $0.lastObservedAt > $1.lastObservedAt + } + if $0.agent != $1.agent { + return $0.agent.insightsDisplayName < $1.agent.insightsDisplayName + } + return $0.sessionID < $1.sessionID + } + + return DetectionInsightsSnapshot( + range: range, + generatedAt: now, + startAt: definitions.first?.start ?? now, + endAt: definitions.last?.end ?? now, + timeZoneIdentifier: calendar.timeZone.identifier, + timeline: timeline, + findings: findings, + agents: agents, + tools: tools, + sessions: sessions) + } + + fileprivate static func findingOrder(_ lhs: DetectionFindingAggregate, + _ rhs: DetectionFindingAggregate) -> Bool { + if lhs.count != rhs.count { return lhs.count > rhs.count } + if lhs.code != rhs.code { return lhs.code < rhs.code } + return lhs.level > rhs.level + } +} + +struct DetectionInsightsSourceRow: Equatable, Sendable { + let eventID: String + let observedAt: Date + let agent: AgentKind + let sessionID: String + let toolName: String + let riskLevel: RiskLevel + let findingCode: String + let findingLevel: RiskLevel +} + +private struct DetectionSessionBuilder { + let agent: AgentKind + let sessionID: String + var firstObservedAt: Date + var lastObservedAt: Date + var detectionCount: Int + var highestLevel: RiskLevel + var tools: Set + var findings: [String: DetectionFindingAggregate] + + init(agent: AgentKind, sessionID: String, observedAt: Date, level: RiskLevel) { + self.agent = agent + self.sessionID = sessionID + self.firstObservedAt = observedAt + self.lastObservedAt = observedAt + self.detectionCount = 0 + self.highestLevel = level + self.tools = [] + self.findings = [:] + } + + mutating func recordDetection(observedAt: Date, level: RiskLevel, + toolName: String) { + firstObservedAt = min(firstObservedAt, observedAt) + lastObservedAt = max(lastObservedAt, observedAt) + detectionCount += 1 + highestLevel = max(highestLevel, level) + tools.insert(toolName) + } + + mutating func recordFinding(code: String, level: RiskLevel) { + let key = "\(code)|\(level.label)" + var finding = findings[key] + ?? DetectionFindingAggregate(code: code, level: level, count: 0) + finding.count += 1 + findings[key] = finding + } + + var aggregate: DetectionSessionAggregate { + DetectionSessionAggregate( + agent: agent, + sessionID: sessionID, + firstObservedAt: firstObservedAt, + lastObservedAt: lastObservedAt, + detectionCount: detectionCount, + highestLevel: highestLevel, + tools: tools.sorted { + $0.localizedCaseInsensitiveCompare($1) == .orderedAscending + }, + findings: findings.values.sorted(by: DetectionInsightsSnapshot.findingOrder)) + } +} + +private extension DetectionAgentAggregate { + mutating func record(_ level: RiskLevel) { + switch level { + case .caution: cautionCount += 1 + case .danger: dangerCount += 1 + case .safe: break + } + } +} + +private extension DetectionToolAggregate { + mutating func record(_ level: RiskLevel) { + switch level { + case .caution: cautionCount += 1 + case .danger: dangerCount += 1 + case .safe: break + } + } +} + +extension AgentKind { + var insightsDisplayName: String { + switch self { + case .claude: return "Claude Code" + case .codex: return "Codex" + } + } +} + +@MainActor +final class InsightsModel: ObservableObject { + enum LoadState: Equatable { + case idle + case loading + case loaded + case unavailable + } + + @Published var selectedRange: InsightsRange = .hours24 { + didSet { + if selectedRange != oldValue { refresh() } + } + } + @Published private(set) var state: LoadState = .idle + @Published private(set) var snapshot: DetectionInsightsSnapshot? + + private let store: DetectionStore + private var generation = 0 + + init(store: DetectionStore) { + self.store = store + } + + func refresh(now: Date = Date()) { + generation += 1 + let requestGeneration = generation + let range = selectedRange + state = .loading + + store.loadInsights(range: range, now: now) { [weak self] result in + guard let self, + self.generation == requestGeneration, + self.selectedRange == range else { return } + switch result { + case .success(let snapshot): + self.snapshot = snapshot + self.state = .loaded + case .failure: + self.snapshot = nil + self.state = .unavailable + } + } + } +} diff --git a/Sources/Perch/Model/DetectionStore.swift b/Sources/Perch/Model/DetectionStore.swift index f77fa87..0ef71f2 100644 --- a/Sources/Perch/Model/DetectionStore.swift +++ b/Sources/Perch/Model/DetectionStore.swift @@ -93,6 +93,24 @@ final class DetectionStore { static let recordSchemaVersion = 1 static let retentionDays = 30 static let retentionInterval: TimeInterval = 30 * 24 * 60 * 60 + static let eventColumns = [ + "record_schema_version", + "event_id", + "observed_at_ms", + "endpoint_user", + "endpoint_host", + "producer_version", + "agent", + "session_id", + "tool_use_id", + "tool_name", + "risk_level", + ] + static let findingColumns = [ + "event_id", + "finding_code", + "finding_level", + ] static let exportColumns = [ "record_schema_version", "event_id", @@ -119,6 +137,7 @@ final class DetectionStore { private var insertFindingStatement: OpaquePointer? private var recentPostureStatement: OpaquePointer? private var pruneStatement: OpaquePointer? + private var insightsStatement: OpaquePointer? private var isDisabled = false private var lastPruneAt: Date? @@ -164,6 +183,35 @@ final class DetectionStore { enqueue(record) } + /// Reads the retained metadata needed by the local Insights window. The + /// store queue owns SQLite work; completion returns on the main queue. + func loadInsights( + range: InsightsRange, + now: Date = Date(), + completion: @escaping (Result) -> Void + ) { + let calendar: Calendar = { + var value = Calendar.current + value.timeZone = .current + return value + }() + queue.async { [self] in + let result: Result + do { + result = .success(try loadInsightsOnQueue( + range: range, + now: now, + calendar: calendar)) + } catch { + handleOperationError(error, operation: "insights query") + result = .failure(error) + } + DispatchQueue.main.async { + completion(result) + } + } + } + func close() { queue.sync { closeOnQueue() @@ -186,6 +234,17 @@ final class DetectionStore { } } + func loadInsightsSynchronously( + range: InsightsRange, + now: Date, + calendar: Calendar + ) throws -> DetectionInsightsSnapshot { + try queue.sync { + guard database != nil, !isDisabled else { throw DetectionStoreError.disabled } + return try loadInsightsOnQueue(range: range, now: now, calendar: calendar) + } + } + @discardableResult func pruneSynchronously(now: Date) throws -> Int { try queue.sync { @@ -388,6 +447,25 @@ final class DetectionStore { DELETE FROM detection_events WHERE observed_at_ms < ? """) + insightsStatement = try prepare(""" + SELECT + e.event_id, + e.observed_at_ms, + e.agent, + e.session_id, + e.tool_name, + e.risk_level, + f.finding_code, + f.finding_level + FROM detection_events AS e + JOIN detection_findings AS f USING (event_id) + WHERE e.observed_at_ms >= ? AND e.observed_at_ms <= ? + ORDER BY + e.observed_at_ms, + e.event_id, + f.finding_code, + f.finding_level + """) } private func closeOnQueue() { @@ -396,6 +474,7 @@ final class DetectionStore { insertFindingStatement, recentPostureStatement, pruneStatement, + insightsStatement, ] { if let statement { sqlite3_finalize(statement) } } @@ -403,6 +482,7 @@ final class DetectionStore { insertFindingStatement = nil recentPostureStatement = nil pruneStatement = nil + insightsStatement = nil if let database { sqlite3_close_v2(database) self.database = nil @@ -521,6 +601,65 @@ final class DetectionStore { } } + private func loadInsightsOnQueue( + range: InsightsRange, + now: Date, + calendar: Calendar + ) throws -> DetectionInsightsSnapshot { + guard let statement = insightsStatement else { + throw DetectionStoreError.disabled + } + let buckets = range.bucketDefinitions(now: now, calendar: calendar) + guard let start = buckets.first?.start, + let end = buckets.last?.end else { + throw DetectionStoreError.invalidRecord + } + + reset(statement) + defer { reset(statement) } + try bind(Self.milliseconds(start), at: 1, in: statement) + try bind(Self.milliseconds(end), at: 2, in: statement) + + var rows: [DetectionInsightsSourceRow] = [] + while true { + let code = sqlite3_step(statement) + if code == SQLITE_DONE { + return DetectionInsightsSnapshot.aggregate( + rows: rows, + range: range, + now: now, + calendar: calendar) + } + guard code == SQLITE_ROW else { + throw sqliteError("insights query", code: code) + } + + let agentRaw = Self.textColumn(statement, at: 2) + guard let agent = AgentKind(rawValue: agentRaw), + let riskLevel = Self.riskLevel( + label: Self.textColumn(statement, at: 5)), + let findingLevel = Self.riskLevel( + label: Self.textColumn(statement, at: 7)), + riskLevel != .safe, + findingLevel != .safe else { + throw DetectionStoreError.sqlite( + operation: "insights decoding", + code: SQLITE_MISMATCH) + } + + rows.append(DetectionInsightsSourceRow( + eventID: Self.textColumn(statement, at: 0), + observedAt: Date( + timeIntervalSince1970: Double(sqlite3_column_int64(statement, 1)) / 1000), + agent: agent, + sessionID: Self.textColumn(statement, at: 3), + toolName: Self.textColumn(statement, at: 4), + riskLevel: riskLevel, + findingCode: Self.textColumn(statement, at: 6), + findingLevel: findingLevel)) + } + } + private func pruneOnQueue(now: Date) throws -> Int { guard let database, let statement = pruneStatement else { throw DetectionStoreError.disabled @@ -667,6 +806,15 @@ final class DetectionStore { return String(cString: UnsafeRawPointer(bytes).assumingMemoryBound(to: CChar.self)) } + private static func riskLevel(label: String) -> RiskLevel? { + switch label { + case "safe": return .safe + case "caution": return .caution + case "danger": return .danger + default: return nil + } + } + private static func scalarInt(_ sql: String, database: OpaquePointer) -> Int64? { var statement: OpaquePointer? let prepareCode = sql.withCString { diff --git a/Sources/Perch/UI/InsightsWindow.swift b/Sources/Perch/UI/InsightsWindow.swift new file mode 100644 index 0000000..3b80128 --- /dev/null +++ b/Sources/Perch/UI/InsightsWindow.swift @@ -0,0 +1,499 @@ +import AppKit +import Charts +import PerchCore +import SwiftUI + +@MainActor +final class InsightsWindowController { + private let model: InsightsModel + private var window: NSWindow? + + init(model: InsightsModel) { + self.model = model + } + + func show() { + if window == nil { + let value = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 760, height: 640), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false) + value.title = "Perch — Insights" + value.isReleasedWhenClosed = false + value.minSize = NSSize(width: 650, height: 500) + value.center() + value.contentViewController = NSHostingController( + rootView: InsightsView(model: model)) + window = value + } + model.refresh() + window?.makeKeyAndOrderFront(nil) + NSApp.activate() + } +} + +private struct InsightsTimelinePoint: Identifiable { + let id: String + let bucket: String + let severity: String + let count: Int +} + +struct InsightsView: View { + @ObservedObject var model: InsightsModel + + var body: some View { + VStack(spacing: 0) { + header + .padding(16) + Divider() + content + Divider() + disclosure + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .frame(minWidth: 650, minHeight: 500) + } + + private var header: some View { + HStack(spacing: 12) { + Text("Insights") + .font(.title3.weight(.semibold)) + if let snapshot = model.snapshot { + let refreshed = formattedTime( + snapshot.generatedAt, + timeZoneID: snapshot.timeZoneIdentifier) + Text("refreshed \(refreshed)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if model.state == .loading { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading Insights") + } + Picker("Time range", selection: $model.selectedRange) { + ForEach(InsightsRange.allCases) { range in + Text(range.rawValue) + .tag(range) + .accessibilityLabel(range.accessibilityLabel) + } + } + .pickerStyle(.segmented) + .frame(width: 180) + Button("Refresh") { model.refresh() } + .disabled(model.state == .loading) + } + } + + @ViewBuilder + private var content: some View { + if model.state == .unavailable { + unavailableState + } else if let snapshot = model.snapshot { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + summary(snapshot) + timeline(snapshot) + if snapshot.isEmpty { + emptyState + } else { + findings(snapshot) + HStack(alignment: .top, spacing: 14) { + agents(snapshot) + tools(snapshot) + } + sessions(snapshot) + } + } + .padding(16) + } + } else { + centeredState( + icon: "chart.xyaxis.line", + title: "Loading local detections…", + detail: "Reading retained metadata from Perch’s local SQLite store.") + } + } + + private func summary(_ snapshot: DetectionInsightsSnapshot) -> some View { + HStack(spacing: 12) { + summaryTile( + title: "Caution detections", + value: snapshot.cautionCount, + color: .orange) + summaryTile( + title: "Danger detections", + value: snapshot.dangerCount, + color: .red) + } + } + + private func summaryTile(title: String, value: Int, color: Color) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text("\(value)") + .font(.title2.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background( + color.opacity(0.07), + in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(color.opacity(0.16), lineWidth: 1)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(title): \(value)") + } + + private func timeline(_ snapshot: DetectionInsightsSnapshot) -> some View { + let rows = snapshot.timeline.flatMap { bucket in + [ + InsightsTimelinePoint( + id: "\(bucket.start.timeIntervalSince1970)-caution", + bucket: bucket.label, + severity: "Caution", + count: bucket.cautionCount), + InsightsTimelinePoint( + id: "\(bucket.start.timeIntervalSince1970)-danger", + bucket: bucket.label, + severity: "Danger", + count: bucket.dangerCount), + ] + } + return insightsCard(title: "Observed detections") { + Chart(rows) { row in + BarMark( + x: .value("Time", row.bucket), + y: .value("Detections", row.count), + stacking: .standard) + .foregroundStyle(by: .value("Severity", row.severity)) + } + .chartForegroundStyleScale([ + "Caution": Color.orange, + "Danger": Color.red, + ]) + .chartXAxis { + AxisMarks(values: axisLabels(snapshot)) { + AxisGridLine() + AxisTick() + AxisValueLabel() + } + } + .frame(minHeight: 170) + .accessibilityLabel( + "Detection timeline, \(snapshot.cautionCount) caution and " + + "\(snapshot.dangerCount) danger detections") + } + } + + private func findings(_ snapshot: DetectionInsightsSnapshot) -> some View { + insightsCard(title: "Findings") { + VStack(spacing: 0) { + ForEach(Array(snapshot.findings.enumerated()), id: \.element.id) { + index, finding in + HStack(spacing: 8) { + severityBadge(finding.level) + Text(finding.code) + .font(.system(size: 12, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text("\(finding.count)") + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + } + .padding(.vertical, 7) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "\(finding.code), \(finding.level.label), " + + "\(finding.count) findings") + if index < snapshot.findings.count - 1 { + Divider() + } + } + } + } + } + + private func agents(_ snapshot: DetectionInsightsSnapshot) -> some View { + insightsCard(title: "By agent") { + VStack(spacing: 0) { + ForEach(Array(snapshot.agents.enumerated()), id: \.element.id) { + index, agent in + aggregateRow( + name: agent.agent.insightsDisplayName, + caution: agent.cautionCount, + danger: agent.dangerCount) + if index < snapshot.agents.count - 1 { + Divider() + } + } + } + } + .frame(maxWidth: .infinity, alignment: .top) + } + + private func tools(_ snapshot: DetectionInsightsSnapshot) -> some View { + insightsCard(title: "By tool") { + VStack(spacing: 0) { + ForEach(Array(snapshot.tools.enumerated()), id: \.element.id) { + index, tool in + aggregateRow( + name: tool.toolName, + caution: tool.cautionCount, + danger: tool.dangerCount, + monospaced: true) + if index < snapshot.tools.count - 1 { + Divider() + } + } + } + } + .frame(maxWidth: .infinity, alignment: .top) + } + + private func aggregateRow(name: String, caution: Int, danger: Int, + monospaced: Bool = false) -> some View { + HStack(spacing: 8) { + Text(name) + .font(monospaced + ? .system(size: 12, design: .monospaced) + : .callout) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + if caution > 0 { + Text("\(caution) caution") + .foregroundStyle(.orange) + } + if danger > 0 { + Text("\(danger) danger") + .foregroundStyle(.red) + } + } + .font(.caption.monospacedDigit()) + .padding(.vertical, 7) + .accessibilityElement(children: .combine) + } + + private func sessions(_ snapshot: DetectionInsightsSnapshot) -> some View { + insightsCard(title: "Sessions with findings") { + VStack(spacing: 0) { + ForEach(Array(snapshot.sessions.enumerated()), id: \.element.id) { + index, session in + sessionRow(session, snapshot: snapshot) + if index < snapshot.sessions.count - 1 { + Divider() + } + } + } + } + } + + private func sessionRow(_ session: DetectionSessionAggregate, + snapshot: DetectionInsightsSnapshot) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(session.agent.insightsDisplayName) + .font(.caption.weight(.semibold)) + Text(shortSessionID(session.sessionID)) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(session.sessionID) + Spacer() + severityBadge(session.highestLevel) + Text("\(session.detectionCount) detection" + + (session.detectionCount == 1 ? "" : "s")) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + Text(sessionTimeRange(session, snapshot: snapshot)) + .font(.caption2) + .foregroundStyle(.secondary) + Text(session.tools.joined(separator: " · ")) + .font(.system(size: 11, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 5) { + ForEach(session.findings) { finding in + Text("\(finding.code) ×\(finding.count)") + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(severityColor(finding.level)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + Capsule().fill(severityColor(finding.level).opacity(0.10))) + } + } + } + } + .padding(.vertical, 9) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "\(session.agent.insightsDisplayName) session \(session.sessionID), " + + "\(session.detectionCount) detections, " + + "highest severity \(session.highestLevel.label)") + } + + private var emptyState: some View { + HStack(spacing: 10) { + Image(systemName: "checkmark.shield") + .font(.title2) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 3) { + Text("No retained detections in this range") + .font(.headline) + Text("A quiet history is meaningful only when Monitoring Setup shows active coverage.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(14) + .background( + Color.secondary.opacity(0.06), + in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + private var unavailableState: some View { + centeredState( + icon: "exclamationmark.triangle", + title: "Insights unavailable", + detail: "Perch could not read its local detection store. " + + "Live monitoring and notifications continue to work.", + retry: true) + } + + private func centeredState(icon: String, title: String, detail: String, + retry: Bool = false) -> some View { + VStack(spacing: 10) { + Spacer() + Image(systemName: icon) + .font(.system(size: 32, weight: .light)) + .foregroundStyle(.secondary) + Text(title) + .font(.headline) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 390) + if retry { + Button("Retry") { model.refresh() } + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } + + private var disclosure: some View { + HStack(spacing: 7) { + Image(systemName: "eye") + .foregroundStyle(.secondary) + Text("Perch observed these requests. It does not know whether they were " + + "approved, denied, executed, or completed.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .accessibilityElement(children: .combine) + } + + private func insightsCard( + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background( + Color.primary.opacity(0.035), + in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)) + } + + private func severityBadge(_ level: RiskLevel) -> some View { + Text(level.label) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(severityColor(level)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(severityColor(level).opacity(0.12))) + } + + private func severityColor(_ level: RiskLevel) -> Color { + switch level { + case .safe: return .green + case .caution: return .orange + case .danger: return .red + } + } + + private func axisLabels(_ snapshot: DetectionInsightsSnapshot) -> [String] { + let indices: [Int] + switch snapshot.range { + case .hours24: + indices = [0, 6, 12, 18, 23] + case .days7: + indices = Array(snapshot.timeline.indices) + case .days30: + indices = [0, 6, 12, 18, 24, 29] + } + return indices.compactMap { snapshot.timeline.indices.contains($0) + ? snapshot.timeline[$0].label + : nil + } + } + + private func shortSessionID(_ id: String) -> String { + guard id.count > 18 else { return id } + return "\(id.prefix(8))…\(id.suffix(6))" + } + + private func sessionTimeRange(_ session: DetectionSessionAggregate, + snapshot: DetectionInsightsSnapshot) -> String { + let first = formattedDateTime( + session.firstObservedAt, + timeZoneID: snapshot.timeZoneIdentifier) + let last = formattedDateTime( + session.lastObservedAt, + timeZoneID: snapshot.timeZoneIdentifier) + return first == last ? first : "\(first) – \(last)" + } + + private func formattedTime(_ date: Date, timeZoneID: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeZone = TimeZone(identifier: timeZoneID) + formatter.timeStyle = .short + return formatter.string(from: date) + } + + private func formattedDateTime(_ date: Date, timeZoneID: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeZone = TimeZone(identifier: timeZoneID) + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } +} diff --git a/Sources/Perch/UI/NotchController.swift b/Sources/Perch/UI/NotchController.swift index 534249c..2f1f5b4 100644 --- a/Sources/Perch/UI/NotchController.swift +++ b/Sources/Perch/UI/NotchController.swift @@ -36,6 +36,7 @@ final class NotchController { private let worktrees: WorktreeModel private let openWorktrees: () -> Void private let openUsageHistory: () -> Void + private let openInsights: () -> Void private let openSetup: () -> Void private let openRecentDetections: () -> Void @@ -58,6 +59,7 @@ final class NotchController { integrity: IntegrityModel, worktrees: WorktreeModel, openWorktrees: @escaping () -> Void, openUsageHistory: @escaping () -> Void, + openInsights: @escaping () -> Void, openSetup: @escaping () -> Void, openRecentDetections: @escaping () -> Void) { self.sessions = sessions @@ -70,6 +72,7 @@ final class NotchController { self.worktrees = worktrees self.openWorktrees = openWorktrees self.openUsageHistory = openUsageHistory + self.openInsights = openInsights self.openSetup = openSetup self.openRecentDetections = openRecentDetections state.controller = self @@ -204,6 +207,7 @@ final class NotchController { usageHistory: usageHistory, integrity: integrity, worktrees: worktrees, openWorktrees: openWorktrees, openUsageHistory: openUsageHistory, + openInsights: openInsights, openSetup: openSetup, openRecentDetections: openRecentDetections) let hosting = NotchHostingView(rootView: root) diff --git a/Sources/Perch/UI/NotchRootView.swift b/Sources/Perch/UI/NotchRootView.swift index 048e1c5..99b40e1 100644 --- a/Sources/Perch/UI/NotchRootView.swift +++ b/Sources/Perch/UI/NotchRootView.swift @@ -19,6 +19,7 @@ struct NotchRootView: View { @ObservedObject var worktrees: WorktreeModel let openWorktrees: () -> Void let openUsageHistory: () -> Void + let openInsights: () -> Void let openSetup: () -> Void let openRecentDetections: () -> Void /// Showcase renders swap the ScrollView for a plain stack: ImageRenderer @@ -138,6 +139,12 @@ struct NotchRootView: View { .fill(Color.white.opacity(0.08)) .frame(height: 1) .padding(.bottom, 2) + Button(action: openInsights) { + NotchGlanceRow(icon: "chart.bar.xaxis", tint: PerchTheme.attention, + label: "Insights", summary: "24h · 7d · 30d") + } + .buttonStyle(.plain) + .accessibilityLabel("Open Insights") WorktreeGlanceRow(model: worktrees, onOpen: openWorktrees) UsageOverviewRow(history: usageHistory, onOpen: openUsageHistory) UsageGaugeStrip(usage: usage, claudeDataMissing: claudeGaugesMissing) diff --git a/Sources/Perch/UI/StatusItemController.swift b/Sources/Perch/UI/StatusItemController.swift index c2c8428..7160eaa 100644 --- a/Sources/Perch/UI/StatusItemController.swift +++ b/Sources/Perch/UI/StatusItemController.swift @@ -133,6 +133,7 @@ final class StatusItemController: NSObject, NSMenuDelegate { } menu.addItem(actionItem("Show/Hide Notch Panel", #selector(toggleNotch), key: "n")) + menu.addItem(actionItem("Insights…", #selector(openInsights), key: "i")) menu.addItem(actionItem("Token Usage…", #selector(openUsageHistory), key: "t")) menu.addItem(actionItem("Worktrees…", #selector(openWorktrees), key: "w")) menu.addItem(actionItem("Debug Window", #selector(openDebugWindow), key: "d")) @@ -342,6 +343,7 @@ final class StatusItemController: NSObject, NSMenuDelegate { @objc private func toggleNotch() { actions.toggleNotch() } @objc private func openDebugWindow() { actions.openDebugWindow() } + @objc private func openInsights() { actions.openInsights() } @objc private func openUsageHistory() { actions.openUsageHistory() } @objc private func openWorktrees() { actions.openWorktrees() } @objc private func quit() { actions.quit() } diff --git a/docs/detection-storage.md b/docs/detection-storage.md index 0f2c194..7bd34df 100644 --- a/docs/detection-storage.md +++ b/docs/detection-storage.md @@ -1,9 +1,9 @@ # Detection storage -Perch keeps a compact local SQLite record of accepted `caution` and `danger` +Perch keeps a compact local SQLite record of deduplicated `caution` and `danger` detections. This preserves the rolling posture across app restarts and provides -a stable source contract for a future Crowsnest adapter. It is not a detailed -history database or a tamper-evident compliance log. +a stable source contract for local Insights and a future Crowsnest adapter. It +is not a detailed history database or a tamper-evident compliance log. ## Location and retention @@ -20,8 +20,7 @@ timeout. Rows are retained for 30 days and pruned without a foreground `VACUUM`. Perch restores only risk levels from the previous hour to rebuild the posture -score. It does not replay old cards or notifications and does not expose a -local history browser. +score. It does not replay old cards or notifications. ## What is stored @@ -43,6 +42,28 @@ A row means only that Perch observed a tool request and emitted the listed findings. It does not claim that the request was approved, denied, executed, or completed. +## Local Insights + +Menu bar → **Insights…** queries the same SQLite database in-process. It does +not create a second database, add stored fields, or make network requests. + +The window provides: + +- an exact rolling 24-hour view with fixed one-hour buckets; +- today plus the previous 6 or 29 local calendar days for 7-day and 30-day + views, with daylight-saving-aware day boundaries; +- caution/danger timelines; +- findings grouped by `(finding_code, finding_level)`; +- detection counts grouped by agent and tool; +- sessions grouped by `(agent, session_id)`, with tool and finding clusters. + +Insights displays only the metadata listed above. The separate **Recent +Detections…** window is a detailed, in-memory past-hour feed; old cards and +their command summaries are not reconstructed from SQLite. + +Both surfaces describe observed requests only. Perch does not know whether a +request was approved, denied, executed, or completed. + ## Consumer contract `detection_export_v1` is the supported read contract: