diff --git a/SimplyTrack/Models/UsageToolRequests.swift b/SimplyTrack/Models/UsageToolRequests.swift new file mode 100644 index 0000000..2413739 --- /dev/null +++ b/SimplyTrack/Models/UsageToolRequests.swift @@ -0,0 +1,27 @@ +// +// UsageToolRequests.swift +// SimplyTrack +// +// Created by Hermes Agent on 06.05.2026. +// + +import Foundation + +struct UsageRangeRequest: Codable, Sendable { + let startTime: String? + let endTime: String? + let typeFilter: String? + let groupBy: String? + let includeActive: Bool? +} + +struct UsageTimelineRequest: Codable, Sendable { + let dateString: String? + let typeFilter: String? +} + +struct UsageDailySummaryRequest: Codable, Sendable { + let dateString: String? + let typeFilter: String? + let limit: Int? +} diff --git a/SimplyTrack/Services/IPCService.swift b/SimplyTrack/Services/IPCService.swift index a74b639..5ffbcba 100644 --- a/SimplyTrack/Services/IPCService.swift +++ b/SimplyTrack/Services/IPCService.swift @@ -16,6 +16,11 @@ import os enum MessageType: UInt8 { case getVersion = 0x01 case getUsageActivity = 0x02 + case getUsageRange = 0x03 + case getRawSessions = 0x04 + case getCurrentActivity = 0x05 + case getHourlyTimeline = 0x06 + case getDailySummary = 0x07 case response = 0x80 case error = 0x81 } @@ -94,6 +99,144 @@ class IPCService: NSObject, @unchecked Sendable { } } + func getUsageRange(request: UsageRangeRequest, completion: @escaping (String?, Error?) -> Void) { + do { + let context = ModelContext(modelContainer) + let end = try parseDateTime(request.endTime) ?? Date() + let start = try parseDateTime(request.startTime) ?? Calendar.current.date(byAdding: .day, value: -1, to: end)! + let summary = try UsageAggregator.usageRange( + start: start, + end: end, + typeFilter: request.typeFilter ?? "all", + groupBy: request.groupBy ?? "name", + includeActive: request.includeActive ?? true, + modelContext: context + ) + completion(try encodeJSON(summary), nil) + } catch { + logger.error("Failed to fetch usage range: \(error.localizedDescription)") + completion(nil, error) + } + } + + func getRawSessions(request: UsageRangeRequest, completion: @escaping (String?, Error?) -> Void) { + do { + let context = ModelContext(modelContainer) + let end = try parseDateTime(request.endTime) ?? Date() + let start = try parseDateTime(request.startTime) ?? Calendar.current.date(byAdding: .day, value: -1, to: end)! + let sessions = try UsageAggregator.rawSessions( + start: start, + end: end, + typeFilter: request.typeFilter ?? "all", + includeActive: request.includeActive ?? true, + modelContext: context + ) + completion(try encodeJSON(sessions), nil) + } catch { + logger.error("Failed to fetch raw sessions: \(error.localizedDescription)") + completion(nil, error) + } + } + + func getCurrentActivity(completion: @escaping (String?, Error?) -> Void) { + let now = Date() + let twelveHoursAgo = Calendar.current.date(byAdding: .hour, value: -12, to: now)! + let startTime = ISO8601DateFormatter().string(from: twelveHoursAgo) + let request = UsageRangeRequest( + startTime: startTime, + endTime: nil, + typeFilter: "all", + groupBy: "name", + includeActive: true + ) + getRawSessions(request: request) { result, error in + if let error { + completion(nil, error) + return + } + + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let sessions = try decoder.decode([UsageSessionSnapshot].self, from: (result ?? "[]").data(using: .utf8) ?? Data()) + let activeSessions = sessions.filter { $0.isActive } + completion(try self.encodeJSON(activeSessions), nil) + } catch { + completion(nil, error) + } + } + } + + func getHourlyTimeline(request: UsageTimelineRequest, completion: @escaping (String?, Error?) -> Void) { + do { + let context = ModelContext(modelContainer) + let date = try parseDateOnly(request.dateString) ?? Date() + let timeline = try UsageAggregator.hourlyTimeline( + for: date, + typeFilter: request.typeFilter ?? "all", + modelContext: context + ) + completion(try encodeJSON(timeline), nil) + } catch { + logger.error("Failed to fetch hourly timeline: \(error.localizedDescription)") + completion(nil, error) + } + } + + func getDailySummary(request: UsageDailySummaryRequest, completion: @escaping (String?, Error?) -> Void) { + do { + let context = ModelContext(modelContainer) + let date = try parseDateOnly(request.dateString) ?? Date() + let calendar = Calendar.current + let start = calendar.startOfDay(for: date) + let end = calendar.date(byAdding: .day, value: 1, to: start)! + let summary = try UsageAggregator.usageRange( + start: start, + end: end, + typeFilter: request.typeFilter ?? "all", + groupBy: "name", + includeActive: true, + modelContext: context + ) + let limit = request.limit ?? 20 + let limitedSummary = UsageRangeSummary( + startTime: summary.startTime, + endTime: summary.endTime, + totalDurationSeconds: summary.totalDurationSeconds, + sessionCount: summary.sessionCount, + items: Array(summary.items.prefix(limit)) + ) + completion(try encodeJSON(limitedSummary), nil) + } catch { + logger.error("Failed to fetch daily summary: \(error.localizedDescription)") + completion(nil, error) + } + } + + private func encodeJSON(_ value: T) throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(value) + return String(data: data, encoding: .utf8) ?? "{}" + } + + private func parseDateOnly(_ value: String?) throws -> Date? { + guard let value, !value.isEmpty else { return nil } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = .current + return formatter.date(from: value) + } + + private func parseDateTime(_ value: String?) throws -> Date? { + guard let value, !value.isEmpty else { return nil } + if let date = ISO8601DateFormatter().date(from: value) { + return date + } + return try parseDateOnly(value) + } + /// Retrieves the current version of the SimplyTrack application /// /// This method returns the app's version string from the main bundle. @@ -268,6 +411,16 @@ private final class IPCChannelHandler: ChannelInboundHandler { switch message.type { case .getUsageActivity: response = await handleGetUsageActivity(body: message.body) + case .getUsageRange: + response = await handleUsageJSONRequest(body: message.body, decodeAs: UsageRangeRequest.self, serviceMethod: service.getUsageRange) + case .getRawSessions: + response = await handleUsageJSONRequest(body: message.body, decodeAs: UsageRangeRequest.self, serviceMethod: service.getRawSessions) + case .getCurrentActivity: + response = await handleGetCurrentActivity() + case .getHourlyTimeline: + response = await handleUsageJSONRequest(body: message.body, decodeAs: UsageTimelineRequest.self, serviceMethod: service.getHourlyTimeline) + case .getDailySummary: + response = await handleUsageJSONRequest(body: message.body, decodeAs: UsageDailySummaryRequest.self, serviceMethod: service.getDailySummary) case .getVersion: response = await handleGetVersion() default: @@ -359,6 +512,39 @@ private final class IPCChannelHandler: ChannelInboundHandler { } return response ?? IPCMessage(type: .error, body: "Failed to get version".data(using: .utf8) ?? Data()) } + + private func handleUsageJSONRequest( + body: Data, + decodeAs _: Request.Type, + serviceMethod: @escaping (Request, @escaping (String?, Error?) -> Void) -> Void + ) async -> IPCMessage { + do { + let request = try JSONDecoder().decode(Request.self, from: body.isEmpty ? Data("{}".utf8) : body) + return await withCheckedContinuation { continuation in + serviceMethod(request) { result, error in + if let error { + continuation.resume(returning: IPCMessage(type: .error, body: error.localizedDescription.data(using: .utf8) ?? Data())) + } else { + continuation.resume(returning: IPCMessage(type: .response, body: (result ?? "{}").data(using: .utf8) ?? Data())) + } + } + } + } catch { + return IPCMessage(type: .error, body: "Invalid JSON request: \(error.localizedDescription)".data(using: .utf8) ?? Data()) + } + } + + private func handleGetCurrentActivity() async -> IPCMessage { + return await withCheckedContinuation { continuation in + service.getCurrentActivity { result, error in + if let error { + continuation.resume(returning: IPCMessage(type: .error, body: error.localizedDescription.data(using: .utf8) ?? Data())) + } else { + continuation.resume(returning: IPCMessage(type: .response, body: (result ?? "[]").data(using: .utf8) ?? Data())) + } + } + } + } } // MARK: - IPC Protocol Codec diff --git a/SimplyTrack/Utils/UsageAggregator.swift b/SimplyTrack/Utils/UsageAggregator.swift index 0f07d7f..80002d8 100644 --- a/SimplyTrack/Utils/UsageAggregator.swift +++ b/SimplyTrack/Utils/UsageAggregator.swift @@ -8,9 +8,55 @@ import Foundation import SwiftData +/// A session returned through the app's local MCP/IPC usage tools. +struct UsageSessionSnapshot: Codable, Sendable { + let type: String + let identifier: String + let name: String + let startTime: Date + let endTime: Date? + let durationSeconds: Int + let isActive: Bool +} + +/// A grouped activity row returned through the app's local MCP/IPC usage tools. +struct UsageActivitySummary: Codable, Sendable { + let key: String + let type: String? + let identifier: String? + let name: String + let durationSeconds: Int + let sessionCount: Int +} + +/// A range summary returned through the app's local MCP/IPC usage tools. +struct UsageRangeSummary: Codable, Sendable { + let startTime: Date + let endTime: Date + let totalDurationSeconds: Int + let sessionCount: Int + let items: [UsageActivitySummary] +} + +/// A single hour bucket returned through the app's local MCP/IPC usage tools. +struct UsageHourlyBucket: Codable, Sendable { + let hour: Int + let startTime: Date + let endTime: Date + let totalDurationSeconds: Int + let items: [UsageActivitySummary] +} + +/// A 24-hour timeline returned through the app's local MCP/IPC usage tools. +struct UsageHourlyTimeline: Codable, Sendable { + let date: Date + let totalDurationSeconds: Int + let buckets: [UsageHourlyBucket] +} + /// Utility for aggregating and formatting usage session data for AI summary generation. /// Provides methods to extract top activities from database and format them for AI prompts. -/// Used by NotificationService to prepare usage data for daily summary notifications. +/// Used by NotificationService to prepare usage data for daily summary notifications and by MCP/IPC tools for direct querying. struct UsageAggregator { /// Aggregates usage data for a given date and returns top X percentage of activities @@ -36,6 +82,159 @@ struct UsageAggregator { return aggregateAndFormat(sessions: sessions, topPercentage: topPercentage) } + /// Returns raw session rows intersecting the requested range, clipped to the range boundaries. + static func rawSessions(start: Date, end: Date, typeFilter: String = "all", includeActive: Bool = true, modelContext: ModelContext) throws -> [UsageSessionSnapshot] { + let sessions = try querySessions(start: start, end: end, typeFilter: typeFilter, includeActive: includeActive, modelContext: modelContext) + return sessions.map { session in + let clippedEnd = min(session.endTime ?? end, end) + let clippedStart = max(session.startTime, start) + return UsageSessionSnapshot( + type: session.type, + identifier: session.identifier, + name: session.name, + startTime: clippedStart, + endTime: session.endTime.map { min($0, end) }, + durationSeconds: max(0, Int(clippedEnd.timeIntervalSince(clippedStart))), + isActive: session.endTime == nil + ) + } + .filter { $0.durationSeconds > 0 || $0.isActive } + .sorted { $0.startTime < $1.startTime } + } + + /// Returns usage grouped by session, name, identifier, type, or hour for a requested range. + static func usageRange( + start: Date, + end: Date, + typeFilter: String = "all", + groupBy: String = "name", + includeActive: Bool = true, + modelContext: ModelContext + ) throws -> UsageRangeSummary { + let snapshots = try rawSessions(start: start, end: end, typeFilter: typeFilter, includeActive: includeActive, modelContext: modelContext) + let items = summarize(snapshots: snapshots, groupBy: groupBy) + return UsageRangeSummary( + startTime: start, + endTime: end, + totalDurationSeconds: snapshots.reduce(0) { $0 + $1.durationSeconds }, + sessionCount: snapshots.count, + items: items + ) + } + + /// Returns one bucket per hour for the supplied day, splitting sessions across bucket boundaries. + static func hourlyTimeline( + for date: Date, + typeFilter: String = "all", + modelContext: ModelContext, + calendar: Calendar = .current + ) throws -> UsageHourlyTimeline { + let dayStart = calendar.startOfDay(for: date) + let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart)! + let snapshots = try rawSessions(start: dayStart, end: dayEnd, typeFilter: typeFilter, includeActive: true, modelContext: modelContext) + + let buckets = (0..<24).map { hour -> UsageHourlyBucket in + let bucketStart = calendar.date(byAdding: .hour, value: hour, to: dayStart)! + let bucketEnd = calendar.date(byAdding: .hour, value: 1, to: bucketStart)! + let bucketSnapshots = snapshots.compactMap { snapshot -> UsageSessionSnapshot? in + let snapshotEnd = min(snapshot.endTime ?? bucketEnd, bucketEnd) + let snapshotStart = max(snapshot.startTime, bucketStart) + let duration = max(0, Int(snapshotEnd.timeIntervalSince(snapshotStart))) + guard duration > 0 else { return nil } + return UsageSessionSnapshot( + type: snapshot.type, + identifier: snapshot.identifier, + name: snapshot.name, + startTime: snapshotStart, + endTime: snapshot.endTime.map { min($0, bucketEnd) }, + durationSeconds: duration, + isActive: snapshot.isActive + ) + } + + return UsageHourlyBucket( + hour: hour, + startTime: bucketStart, + endTime: bucketEnd, + totalDurationSeconds: bucketSnapshots.reduce(0) { $0 + $1.durationSeconds }, + items: summarize(snapshots: bucketSnapshots, groupBy: "name") + ) + } + + return UsageHourlyTimeline( + date: dayStart, + totalDurationSeconds: buckets.reduce(0) { $0 + $1.totalDurationSeconds }, + buckets: buckets + ) + } + + private static func querySessions(start: Date, end: Date, typeFilter: String, includeActive: Bool, modelContext: ModelContext) throws -> [UsageSession] { + let descriptor = FetchDescriptor( + predicate: #Predicate { session in + session.startTime < end + }, + sortBy: [SortDescriptor(\UsageSession.startTime)] + ) + let type = normalizedTypeFilter(typeFilter) + + return try modelContext.fetch(descriptor).filter { session in + if let type, session.type != type.rawValue { + return false + } + + if !includeActive && session.endTime == nil { + return false + } + + let effectiveEnd = session.endTime ?? end + return effectiveEnd > start && session.startTime < end + } + } + + private static func normalizedTypeFilter(_ typeFilter: String) -> UsageType? { + switch typeFilter.lowercased() { + case "app", "apps", UsageType.app.rawValue: + return .app + case "website", "websites", UsageType.website.rawValue: + return .website + default: + return nil + } + } + + private static func summarize(snapshots: [UsageSessionSnapshot], groupBy: String) -> [UsageActivitySummary] { + let grouped = Dictionary(grouping: snapshots) { snapshot -> String in + switch groupBy.lowercased() { + case "session": + return "\(snapshot.type)|\(snapshot.identifier)|\(snapshot.name)|\(snapshot.startTime.timeIntervalSince1970)" + case "identifier": + return "\(snapshot.type)|\(snapshot.identifier)" + case "type": + return snapshot.type + default: + return "\(snapshot.type)|\(snapshot.name)" + } + } + + return grouped.map { key, snapshots in + let first = snapshots[0] + return UsageActivitySummary( + key: key, + type: groupBy.lowercased() == "type" ? first.type : first.type, + identifier: groupBy.lowercased() == "name" ? nil : first.identifier, + name: groupBy.lowercased() == "type" ? first.type : first.name, + durationSeconds: snapshots.reduce(0) { $0 + $1.durationSeconds }, + sessionCount: snapshots.count + ) + } + .sorted { + if $0.durationSeconds == $1.durationSeconds { + return $0.name < $1.name + } + return $0.durationSeconds > $1.durationSeconds + } + } + private static func aggregateAndFormat(sessions: [UsageSession], topPercentage: Double) -> String { var appUsage: [String: TimeInterval] = [:] var websiteUsage: [String: TimeInterval] = [:] diff --git a/SimplyTrackMCP/IPCClient.swift b/SimplyTrackMCP/IPCClient.swift index 6474e90..60bc05c 100644 --- a/SimplyTrackMCP/IPCClient.swift +++ b/SimplyTrackMCP/IPCClient.swift @@ -14,6 +14,11 @@ import NIOPosix enum MessageType: UInt8 { case getVersion = 0x01 case getUsageActivity = 0x02 + case getUsageRange = 0x03 + case getRawSessions = 0x04 + case getCurrentActivity = 0x05 + case getHourlyTimeline = 0x06 + case getDailySummary = 0x07 case response = 0x80 case error = 0x81 } @@ -132,6 +137,57 @@ actor IPCClient { return try await sendMessage(type: .getUsageActivity, body: body) } + func getUsageRange( + startTime: String?, + endTime: String?, + typeFilter: String?, + groupBy: String?, + includeActive: Bool? + ) async throws -> String? { + let request = UsageRangeRequest( + startTime: startTime, + endTime: endTime, + typeFilter: typeFilter, + groupBy: groupBy, + includeActive: includeActive + ) + return try await sendJSONMessage(type: .getUsageRange, request: request) + } + + func getRawSessions( + startTime: String?, + endTime: String?, + typeFilter: String?, + includeActive: Bool? + ) async throws -> String? { + let request = UsageRangeRequest( + startTime: startTime, + endTime: endTime, + typeFilter: typeFilter, + groupBy: nil, + includeActive: includeActive + ) + return try await sendJSONMessage(type: .getRawSessions, request: request) + } + + func getCurrentActivity() async throws -> String? { + return try await sendMessage(type: .getCurrentActivity, body: Data()) + } + + func getHourlyTimeline(dateString: String?, typeFilter: String?) async throws -> String? { + return try await sendJSONMessage(type: .getHourlyTimeline, request: UsageTimelineRequest(dateString: dateString, typeFilter: typeFilter)) + } + + func getDailySummary(dateString: String?, typeFilter: String?, limit: Int?) async throws -> String? { + return try await sendJSONMessage(type: .getDailySummary, request: UsageDailySummaryRequest(dateString: dateString, typeFilter: typeFilter, limit: limit)) + } + + private func sendJSONMessage(type: MessageType, request: T) async throws -> String? { + let encoder = JSONEncoder() + let body = try encoder.encode(request) + return try await sendMessage(type: type, body: body) + } + /// Send message to Swift-NIO server and wait for response private func sendMessage(type: MessageType, body: Data) async throws -> String? { let clientSocketPath = self.socketPath // Capture socket path before closure diff --git a/SimplyTrackMCP/MCPServer.swift b/SimplyTrackMCP/MCPServer.swift index 7ff58f7..0638a12 100644 --- a/SimplyTrackMCP/MCPServer.swift +++ b/SimplyTrackMCP/MCPServer.swift @@ -51,75 +51,7 @@ actor MCPServer { // Register tool list handler await server.withMethodHandler(ListTools.self) { _ in - let tools = [ - Tool( - name: "get_usage_activity", - description: """ - Get user's application or website usage data showing time spent on different activities. - - This tool retrieves detailed usage statistics from SimplyTrack, including: - - Time spent on each application or website - - Percentage of total usage time - - Activity duration and frequency - - Aggregated data for productivity analysis - - The data is formatted as human-readable text suitable for analysis and insights. - Perfect for understanding work patterns, identifying productivity trends, and time management. - - ## Response Format: - - The tool returns data in a simple pipe-separated format: `name:duration|name:duration|...|Total:duration` - - ### Example Responses: - - **Application Usage (typeFilter: "app"):** - ``` - Xcode:3h45m|Safari:2h18m|Terminal:1h20m|Total:7h23m - ``` - - **Website Usage (typeFilter: "website"):** - ``` - github.com:1h35m|stackoverflow.com:58m|docs.swift.org:42m|claude.ai:37m|Total:4h12m - ``` - - **No Data Available:** - ``` - No usage data found - ``` - - **Format Details:** - - Each entry: `activityName:duration` - - Duration format examples: - • `3h45m` = 3 hours and 45 minutes - • `2h0m` = exactly 2 hours (0 minutes) - • `45m` = 45 minutes (less than 1 hour) - • `5m` = 5 minutes - - Activities are ordered by usage time (highest first) - - Final entry is always `Total:duration` showing total tracked time - - Activities included based on topPercentage (default 80% of total usage time) - - Returns: Pipe-separated usage data string as shown above, or "No usage data found" if no data exists. - """, - inputSchema: .object([ - "type": .string("object"), - "properties": .object([ - "topPercentage": .object([ - "type": .string("number"), - "description": .string("Include top activities by usage time - 0.8 means top 80% most used (default: 0.8)"), - ]), - "dateString": .object([ - "type": .string("string"), - "description": .string("Specific date to analyze in YYYY-MM-DD format, or omit for today"), - ]), - "typeFilter": .object([ - "type": .string("string"), - "description": .string("Data type: 'app' for applications or 'website' for web browsing (default: 'app')"), - ]), - ]), - ]) - ) - ] - return .init(tools: tools) + return .init(tools: Self.tools) } // Register tool call handler @@ -153,46 +85,137 @@ actor MCPServer { } } + private static var tools: [Tool] { + [ + Tool( + name: "get_usage_activity", + description: "Legacy compact daily usage summary. Returns pipe-separated name:duration rows for app or website usage.", + inputSchema: usageActivitySchema + ), + Tool( + name: "get_usage_range", + description: "Return JSON summary of tracked usage across a time range. Supports app/website/all filters and grouping by name, identifier, type, or session.", + inputSchema: usageRangeSchema + ), + Tool( + name: "get_raw_sessions", + description: "Return raw tracked sessions as JSON rows clipped to the requested time range. Use this when exact start/end times matter.", + inputSchema: rawSessionsSchema + ), + Tool( + name: "get_current_activity", + description: "Return active in-progress sessions as JSON. Useful for asking what the user appears to be doing right now.", + inputSchema: .object(["type": .string("object"), "properties": .object([:])]) + ), + Tool( + name: "get_hourly_timeline", + description: "Return JSON with 24 hourly buckets for a date, splitting sessions across hour boundaries.", + inputSchema: timelineSchema + ), + Tool( + name: "get_daily_summary", + description: "Return JSON daily summary of top activities for a date, grouped by activity name.", + inputSchema: dailySummarySchema + ), + ] + } + + private static var usageActivitySchema: Value { + objectSchema(properties: [ + "topPercentage": property(type: "number", description: "Include top activities by usage time - 0.8 means top 80% most used (default: 0.8)"), + "dateString": property(type: "string", description: "Specific date to analyze in YYYY-MM-DD format, or omit for today"), + "typeFilter": property(type: "string", description: "Data type: 'app' or 'website' (default: 'app')"), + ]) + } + + private static var usageRangeSchema: Value { + objectSchema(properties: rangeProperties(extra: ["groupBy": property(type: "string", description: "Grouping: 'name', 'identifier', 'type', or 'session' (default: 'name')")])) + } + + private static var rawSessionsSchema: Value { + objectSchema(properties: rangeProperties(extra: [:])) + } + + private static var timelineSchema: Value { + objectSchema(properties: [ + "dateString": property(type: "string", description: "Date in YYYY-MM-DD format, or omit for today"), + "typeFilter": property(type: "string", description: "Data type: 'app', 'website', or 'all' (default: 'all')"), + ]) + } + + private static var dailySummarySchema: Value { + objectSchema(properties: [ + "dateString": property(type: "string", description: "Date in YYYY-MM-DD format, or omit for today"), + "typeFilter": property(type: "string", description: "Data type: 'app', 'website', or 'all' (default: 'all')"), + "limit": property(type: "number", description: "Maximum number of activities to return (default: 20)"), + ]) + } + + private static func rangeProperties(extra: [String: Value]) -> [String: Value] { + var properties = [ + "startTime": property(type: "string", description: "Range start as ISO-8601 date/time or YYYY-MM-DD. Defaults to 24 hours before endTime."), + "endTime": property(type: "string", description: "Range end as ISO-8601 date/time or YYYY-MM-DD. Defaults to now."), + "typeFilter": property(type: "string", description: "Data type: 'app', 'website', or 'all' (default: 'all')"), + "includeActive": property(type: "boolean", description: "Whether to include active in-progress sessions (default: true)"), + ] + properties.merge(extra) { _, new in new } + return properties + } + + private static func objectSchema(properties: [String: Value]) -> Value { + .object(["type": .string("object"), "properties": .object(properties)]) + } + + private static func property(type: String, description: String) -> Value { + .object(["type": .string(type), "description": .string(description)]) + } + /// Handler for tool calls private func handleCallTool(params: CallTool.Parameters) async -> CallTool.Result { - switch params.name { - case "get_usage_activity": - // Extract parameters exactly like IPC service - let topPercentage = params.arguments?["topPercentage"]?.doubleValue ?? 0.8 - let dateString = params.arguments?["dateString"]?.stringValue - let typeFilter = params.arguments?["typeFilter"]?.stringValue ?? "app" - - do { - // Use same logic as IPC service - let usage = try await ipcClient.getUsageActivity( - topPercentage: topPercentage, - dateString: dateString, - typeFilter: typeFilter + do { + let output: String? + switch params.name { + case "get_usage_activity": + output = try await ipcClient.getUsageActivity( + topPercentage: params.arguments?["topPercentage"]?.doubleValue ?? 0.8, + dateString: params.arguments?["dateString"]?.stringValue, + typeFilter: params.arguments?["typeFilter"]?.stringValue ?? "app" ) - - if let usage = usage, !usage.isEmpty { - return .init( - content: [.text(usage)], - isError: false - ) - } else { - return .init( - content: [.text("No usage data found")], - isError: false - ) - } - } catch { - return .init( - content: [.text("Error fetching usage activity: \(error.localizedDescription)")], - isError: true + case "get_usage_range": + output = try await ipcClient.getUsageRange( + startTime: params.arguments?["startTime"]?.stringValue, + endTime: params.arguments?["endTime"]?.stringValue, + typeFilter: params.arguments?["typeFilter"]?.stringValue, + groupBy: params.arguments?["groupBy"]?.stringValue, + includeActive: params.arguments?["includeActive"]?.boolValue + ) + case "get_raw_sessions": + output = try await ipcClient.getRawSessions( + startTime: params.arguments?["startTime"]?.stringValue, + endTime: params.arguments?["endTime"]?.stringValue, + typeFilter: params.arguments?["typeFilter"]?.stringValue, + includeActive: params.arguments?["includeActive"]?.boolValue + ) + case "get_current_activity": + output = try await ipcClient.getCurrentActivity() + case "get_hourly_timeline": + output = try await ipcClient.getHourlyTimeline( + dateString: params.arguments?["dateString"]?.stringValue, + typeFilter: params.arguments?["typeFilter"]?.stringValue ) + case "get_daily_summary": + output = try await ipcClient.getDailySummary( + dateString: params.arguments?["dateString"]?.stringValue, + typeFilter: params.arguments?["typeFilter"]?.stringValue, + limit: params.arguments?["limit"]?.doubleValue.map(Int.init) + ) + default: + return .init(content: [.text("Unknown tool: \(params.name)")], isError: true) } - default: - return .init( - content: [.text("Unknown tool: \(params.name)")], - isError: true - ) + return .init(content: [.text(output ?? "No usage data found")], isError: false) + } catch { + return .init(content: [.text("Error running \(params.name): \(error.localizedDescription)")], isError: true) } } } diff --git a/SimplyTrackMCP/UsageToolRequests.swift b/SimplyTrackMCP/UsageToolRequests.swift new file mode 100644 index 0000000..0eb52e6 --- /dev/null +++ b/SimplyTrackMCP/UsageToolRequests.swift @@ -0,0 +1,27 @@ +// +// UsageToolRequests.swift +// SimplyTrackMCP +// +// Created by Hermes Agent on 06.05.2026. +// + +import Foundation + +struct UsageRangeRequest: Codable, Sendable { + let startTime: String? + let endTime: String? + let typeFilter: String? + let groupBy: String? + let includeActive: Bool? +} + +struct UsageTimelineRequest: Codable, Sendable { + let dateString: String? + let typeFilter: String? +} + +struct UsageDailySummaryRequest: Codable, Sendable { + let dateString: String? + let typeFilter: String? + let limit: Int? +} diff --git a/SimplyTrackTests/UsageAggregatorTests.swift b/SimplyTrackTests/UsageAggregatorTests.swift new file mode 100644 index 0000000..8f3bd51 --- /dev/null +++ b/SimplyTrackTests/UsageAggregatorTests.swift @@ -0,0 +1,165 @@ +// +// UsageAggregatorTests.swift +// SimplyTrackTests +// +// Created by Hermes Agent on 06.05.2026. +// + +import Foundation +import SwiftData +import Testing +@testable import SimplyTrack + +struct UsageAggregatorTests { + + @Test func rawSessionsClipsDurationsToRequestedRange() throws { + let container = try makeContainer() + let context = ModelContext(container) + let rangeStart = try #require(makeDate("2026-05-06T09:00:00Z")) + let rangeEnd = try #require(makeDate("2026-05-06T11:00:00Z")) + + insertSession( + type: .app, + identifier: "com.apple.Safari", + name: "Safari", + start: "2026-05-06T08:30:00Z", + end: "2026-05-06T09:30:00Z", + context: context + ) + insertSession( + type: .website, + identifier: "github.com", + name: "github.com", + start: "2026-05-06T10:15:00Z", + end: "2026-05-06T11:15:00Z", + context: context + ) + + let sessions = try UsageAggregator.rawSessions( + start: rangeStart, + end: rangeEnd, + typeFilter: "all", + includeActive: false, + modelContext: context + ) + + #expect(sessions.count == 2) + #expect(sessions[0].name == "Safari") + #expect(sessions[0].durationSeconds == 1800) + #expect(sessions[1].name == "github.com") + #expect(sessions[1].durationSeconds == 2700) + } + + @Test func usageRangeGroupsByNameAndFiltersType() throws { + let container = try makeContainer() + let context = ModelContext(container) + let rangeStart = try #require(makeDate("2026-05-06T09:00:00Z")) + let rangeEnd = try #require(makeDate("2026-05-06T12:00:00Z")) + + insertSession(type: .app, identifier: "com.apple.Safari", name: "Safari", start: "2026-05-06T09:00:00Z", end: "2026-05-06T10:00:00Z", context: context) + insertSession(type: .app, identifier: "com.apple.Safari", name: "Safari", start: "2026-05-06T10:30:00Z", end: "2026-05-06T11:00:00Z", context: context) + insertSession(type: .website, identifier: "github.com", name: "github.com", start: "2026-05-06T09:00:00Z", end: "2026-05-06T10:00:00Z", context: context) + + let summary = try UsageAggregator.usageRange( + start: rangeStart, + end: rangeEnd, + typeFilter: "app", + groupBy: "name", + includeActive: false, + modelContext: context + ) + + #expect(summary.totalDurationSeconds == 5400) + #expect(summary.items.count == 1) + #expect(summary.items[0].name == "Safari") + #expect(summary.items[0].durationSeconds == 5400) + #expect(summary.items[0].sessionCount == 2) + } + + @Test func hourlyTimelineSplitsSessionsAcrossHourBuckets() throws { + let container = try makeContainer() + let context = ModelContext(container) + let date = try #require(makeDate("2026-05-06T00:00:00Z")) + + insertSession(type: .app, identifier: "com.apple.dt.Xcode", name: "Xcode", start: "2026-05-06T09:30:00Z", end: "2026-05-06T10:30:00Z", context: context) + + let timeline = try UsageAggregator.hourlyTimeline( + for: date, + typeFilter: "app", + modelContext: context, + calendar: utcCalendar + ) + + let nonEmptyBuckets = timeline.buckets.filter { $0.totalDurationSeconds > 0 } + #expect(nonEmptyBuckets.count == 2) + #expect(nonEmptyBuckets[0].hour == 9) + #expect(nonEmptyBuckets[0].totalDurationSeconds == 1800) + #expect(nonEmptyBuckets[1].hour == 10) + #expect(nonEmptyBuckets[1].totalDurationSeconds == 1800) + } + + @Test func ipcUsageToolsReturnJSONPayloads() async throws { + let container = try makeContainer() + let context = ModelContext(container) + insertSession(type: .app, identifier: "com.apple.dt.Xcode", name: "Xcode", start: "2026-05-06T09:30:00Z", end: "2026-05-06T10:30:00Z", context: context) + insertSession(type: .website, identifier: "github.com", name: "github.com", start: "2026-05-06T10:00:00Z", end: "2026-05-06T11:00:00Z", context: context) + try context.save() + + let service = IPCService(modelContainer: container) + let rangeRequest = UsageRangeRequest( + startTime: "2026-05-06T09:00:00Z", + endTime: "2026-05-06T12:00:00Z", + typeFilter: "all", + groupBy: "name", + includeActive: true + ) + + let usageRangeJSON = try await callService { service.getUsageRange(request: rangeRequest, completion: $0) } + #expect(usageRangeJSON.contains("totalDurationSeconds")) + #expect(usageRangeJSON.contains("Xcode")) + + let rawSessionsJSON = try await callService { service.getRawSessions(request: rangeRequest, completion: $0) } + #expect(rawSessionsJSON.contains("github.com")) + #expect(rawSessionsJSON.contains("durationSeconds")) + + let timelineJSON = try await callService { service.getHourlyTimeline(request: UsageTimelineRequest(dateString: "2026-05-06", typeFilter: "all"), completion: $0) } + #expect(timelineJSON.contains("buckets")) + + let dailySummaryJSON = try await callService { service.getDailySummary(request: UsageDailySummaryRequest(dateString: "2026-05-06", typeFilter: "all", limit: 5), completion: $0) } + #expect(dailySummaryJSON.contains("sessionCount")) + } + + private func callService(_ body: (@escaping (String?, Error?) -> Void) -> Void) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + body { result, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: result ?? "") + } + } + } + } + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([UsageSession.self, Icon.self]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [configuration]) + } + + private func insertSession(type: UsageType, identifier: String, name: String, start: String, end: String, context: ModelContext) { + let session = UsageSession(type: type, identifier: identifier, name: name, startTime: makeDate(start)!) + session.endSession(at: makeDate(end)!) + context.insert(session) + } + + private func makeDate(_ string: String) -> Date? { + ISO8601DateFormatter().date(from: string) + } + + private var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +}