Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions PulseLoop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,7 @@
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "PulseLoop connects to your SMART_RING to sync activity, heart rate, SpO2, and sleep.";
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "PulseLoop connects to your SMART_RING to sync activity, heart rate, SpO2, and sleep.";
INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "PulseLoop keeps recording your workout route in the background while a workout is active.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PulseLoop records your route during outdoor workouts when GPS is enabled.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PulseLoop records your route during outdoor workouts, and — when enabled — uses your approximate location for weather-aware coaching.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
Expand Down Expand Up @@ -689,7 +689,7 @@
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "PulseLoop connects to your SMART_RING to sync activity, heart rate, SpO2, and sleep.";
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "PulseLoop connects to your SMART_RING to sync activity, heart rate, SpO2, and sleep.";
INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "PulseLoop keeps recording your workout route in the background while a workout is active.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PulseLoop records your route during outdoor workouts when GPS is enabled.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PulseLoop records your route during outdoor workouts, and — when enabled — uses your approximate location for weather-aware coaching.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
Expand Down Expand Up @@ -723,6 +723,7 @@
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) -Xfrontend -solver-expression-time-threshold=60";
PRODUCT_BUNDLE_IDENTIFIER = xyz.sakshambhutani.pulseloop2Tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
Expand Down
28 changes: 18 additions & 10 deletions PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,26 @@ final class AppleFoundationModelsClient: ResponsesClient, @unchecked Sendable {
}

/// Renders the running transcript into a single prompt string, trimmed to the
/// on-device character budget (oldest turns dropped first).
/// on-device character budget by dropping whole oldest turns (rather than a
/// mid-sentence `suffix` cut that leaves the model a truncated first line). The
/// newest turn is always kept even if it alone exceeds the budget — the
/// `promptCharBudget` still backstops a runaway single turn.
private func buildPrompt() -> String {
var lines: [String] = []
for turn in turns {
let label = turn.role == "assistant" ? "Assistant" : "User"
lines.append("\(label): \(turn.text)")
}
var prompt = lines.joined(separator: "\n\n")
if prompt.count > promptCharBudget {
prompt = String(prompt.suffix(promptCharBudget))
let lines = turns.map { "\($0.role == "assistant" ? "Assistant" : "User"): \($0.text)" }

// Keep the most recent turns that fit, walking backward from the newest.
var kept: [String] = []
var used = 0
for line in lines.reversed() {
let add = line.count + (kept.isEmpty ? 0 : 2) // account for the "\n\n" join
if !kept.isEmpty, used + add > promptCharBudget { break }
kept.insert(line, at: 0)
used += add
}
return prompt

let prompt = kept.joined(separator: "\n\n")
// Backstop: a single oversized newest turn still gets clamped.
return prompt.count > promptCharBudget ? String(prompt.suffix(promptCharBudget)) : prompt
}

#if canImport(FoundationModels)
Expand Down
14 changes: 14 additions & 0 deletions PulseLoop/Coach/Config/CoachFeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ struct CoachFeatureFlags {
var maxRounds: Int { max(1, settings.maxRounds) }
var model: String { settings.model }

/// The model string actually sent for the active provider — the single place
/// that resolves the per-provider naming (OpenRouter/MiniMax read their own
/// slug fields; on-device has no external model name). Used to label and price
/// a turn's usage. Mirrors `CoachClientResolver`'s per-provider `model` choice.
var effectiveModel: String {
switch settings.providerMode {
case .appleOnDevice: return "apple-on-device"
case .offlineStub: return "offline-stub"
case .userOpenRouterKey: return settings.openRouterModel
case .userMiniMaxKey: return settings.minimaxModel
case .userOpenAIKey, .userGeminiKey, .backendProxy: return settings.model
}
}

/// One-line status for the Settings UI.
var statusLine: String {
if !settings.coachMasterEnabled { return "Off — turn on AI Coach to enable." }
Expand Down
5 changes: 5 additions & 0 deletions PulseLoop/Coach/Config/CoachSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ struct CoachSettings: Codable, Equatable {
/// sleep). On-device only — free/unlimited local inference makes "watch the
/// stream and speak up when something looks off" practical. Off by default.
var proactiveAlertsEnabled: Bool = false
/// Opt-in: share the user's city name + current weather with the coach so it
/// can ground practical advice (outdoor vs indoor, hydration, rain). City-level
/// only — never the precise location. Off by default.
var enableEnvironmentContext: Bool = false

/// The OpenRouter model slug to use. Free-form (the user may type any slug);
/// falls back to the default only when the stored `model` is blank.
Expand Down Expand Up @@ -221,6 +225,7 @@ struct CoachSettings: Codable, Equatable {
middayHour = try c.decodeIfPresent(Int.self, forKey: .middayHour) ?? d.middayHour
eveningHour = try c.decodeIfPresent(Int.self, forKey: .eveningHour) ?? d.eveningHour
proactiveAlertsEnabled = try c.decodeIfPresent(Bool.self, forKey: .proactiveAlertsEnabled) ?? d.proactiveAlertsEnabled
enableEnvironmentContext = try c.decodeIfPresent(Bool.self, forKey: .enableEnvironmentContext) ?? d.enableEnvironmentContext
}
}

Expand Down
13 changes: 13 additions & 0 deletions PulseLoop/Coach/Config/CoachSettingsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ struct CoachSettingsSection: View {

toggleRow("AI actions (set goals, log, edit)", isOn: writeToolsBinding)
toggleRow("Live ring measurements", isOn: liveMeasurementsBinding)
toggleRow("Use location & weather", isOn: environmentContextBinding)
Text("Shares your city name and current weather with the AI provider so coaching can account for conditions. Never shares your precise location.")
.font(.caption).foregroundStyle(PulseColors.textMuted)
.padding(.horizontal, 16)
if showsImageInputToggle {
toggleRow("Image input (attach photos)", isOn: imageInputBinding)
}
Expand Down Expand Up @@ -535,6 +539,15 @@ struct CoachSettingsSection: View {
private var imageInputBinding: Binding<Bool> {
Binding(get: { store.settings.enableImageInput }, set: { store.settings.enableImageInput = $0 })
}
private var environmentContextBinding: Binding<Bool> {
Binding(
get: { store.settings.enableEnvironmentContext },
set: { newValue in
store.settings.enableEnvironmentContext = newValue
if newValue { CoachEnvironmentContextService.shared.requestPermissionIfNeeded() }
}
)
}

// MARK: - Key actions

Expand Down
43 changes: 43 additions & 0 deletions PulseLoop/Coach/Config/CoachVarietyHints.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation

/// Prompt-only variety for the coach's repetitive surfaces (daily check-ins,
/// Today/Sleep cards). Picks a deterministic "coaching angle" from a seed so the
/// same context yields the same angle within a run (idempotent regeneration),
/// while different seeds rotate through fresh framings — avoiding the "every
/// notification opens the same way" fatigue.
///
/// The seed hash is a stable FNV-1a over the UTF-8 bytes — NOT Swift's
/// `hashValue`, which is per-launch randomized and would give a different angle
/// every process for the same seed.
enum CoachVarietyHints {
/// ~8 distinct framings. Order is fixed so `angle(seed:)` is reproducible.
static let angles: [String] = [
"Compare today to yesterday — call out what changed and why it might matter.",
"Notice a streak or milestone worth celebrating (consecutive active days, a personal best).",
"Zoom in on a single metric and go one level deeper than usual (e.g. resting HR, deep sleep).",
"Take a recovery lens — how rested is the body, and what would help it bounce back?",
"Lead with a genuine question that invites the user to reflect, then ground it in a number.",
"Lead with one concrete, immediately useful tip tied to today's data.",
"If weather/location context is available, make the advice weather-aware (outdoor vs indoor, hydration, rain).",
"Zoom out to the week's trend — is the direction improving, flat, or slipping?",
]

/// Deterministic angle for a seed. Stable across launches (FNV-1a), so
/// regenerating the same check-in doesn't reshuffle the framing.
static func angle(seed: String) -> String {
guard !angles.isEmpty else { return "" }
let index = Int(fnv1a(seed) % UInt64(angles.count))
return angles[index]
}

/// FNV-1a 64-bit over UTF-8 bytes — a small, stable, non-cryptographic hash.
static func fnv1a(_ string: String) -> UInt64 {
var hash: UInt64 = 0xcbf2_9ce4_8422_2325
let prime: UInt64 = 0x0000_0100_0000_01b3
for byte in string.utf8 {
hash ^= UInt64(byte)
hash = hash &* prime
}
return hash
}
}
32 changes: 32 additions & 0 deletions PulseLoop/Coach/Context/CoachContextBudget.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

/// How much context to pack into a turn. `.full` is the cloud default; `.compact`
/// trims the packet (and system prompt + history) for the tiny on-device Apple
/// model, whose context window is small and which is tool-less. The packet SHAPE
/// is identical between the two — only the contents are trimmed — so encoders and
/// tools are untouched.
enum CoachContextBudget {
case full
case compact

/// Max durable memories embedded in the packet.
var maxMemories: Int { self == .compact ? 3 : 8 }
/// Max recent workouts embedded in the packet.
var maxWorkouts: Int { self == .compact ? 2 : 8 }
/// Max data-quality warnings embedded in the packet.
var maxWarnings: Int { self == .compact ? 2 : Int.max }
/// Character cap on each memory `value` (long notes are truncated).
var memoryValueCap: Int { self == .compact ? 120 : Int.max }
/// Character cap on the rolling conversation summary.
var conversationSummaryCap: Int { self == .compact ? 400 : Int.max }
/// Max prior conversation turns replayed to the model.
var historyTurns: Int { self == .compact ? 4 : 10 }
}

extension CoachFeatureFlags {
/// Compact on-device, full everywhere else. The tiny local model needs a
/// smaller packet + prompt; cloud models don't.
var contextBudget: CoachContextBudget {
settings.providerMode == .appleOnDevice ? .compact : .full
}
}
24 changes: 17 additions & 7 deletions PulseLoop/Coach/Context/CoachContextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ enum CoachContextBuilder {
static func build(
context: ModelContext,
conversationSummary: String? = nil,
now: Date = Date()
now: Date = Date(),
budget: CoachContextBudget = .full,
environment: CoachContextPacket.EnvironmentContext? = nil
) -> CoachContextPacket {
let summary = MetricsService.buildTodaySummary(context: context)
let profile = ProfileRepository.profile(context: context)
Expand Down Expand Up @@ -104,15 +106,23 @@ enum CoachContextBuilder {
lastSevenDays: week,
latestVitals: vitals,
latestSleep: sleep,
recentWorkouts: recentWorkouts(context: context),
memories: memories(context: context),
conversationSummary: conversationSummary,
dataQualityWarnings: warnings
recentWorkouts: recentWorkouts(context: context, limit: budget.maxWorkouts),
memories: memories(context: context, limit: budget.maxMemories, valueCap: budget.memoryValueCap),
conversationSummary: cap(conversationSummary, to: budget.conversationSummaryCap),
dataQualityWarnings: Array(warnings.prefix(budget.maxWarnings)),
environment: environment
)
}

// MARK: - Helpers

/// Truncates a string to `limit` characters (adding an ellipsis when cut).
/// `Int.max` (the full budget) is a no-op.
private static func cap(_ text: String?, to limit: Int) -> String? {
guard let text, limit != .max, text.count > limit else { return text }
return String(text.prefix(limit)) + "…"
}

private static func recentWorkouts(context: ModelContext, limit: Int = 8) -> [CoachContextPacket.WorkoutContext] {
ActivityRepository.sessions(context: context)
.filter { $0.status == .finished }
Expand All @@ -134,15 +144,15 @@ enum CoachContextBuilder {
}
}

private static func memories(context: ModelContext, limit: Int = 8, now: Date = Date()) -> [CoachContextPacket.MemoryContext] {
private static func memories(context: ModelContext, limit: Int = 8, valueCap: Int = .max, now: Date = Date()) -> [CoachContextPacket.MemoryContext] {
let descriptor = FetchDescriptor<CoachMemory>(
sortBy: [SortDescriptor(\.importance, order: .reverse), SortDescriptor(\.updatedAt, order: .reverse)]
)
let rows = (try? context.fetch(descriptor)) ?? []
return rows
.filter { $0.expiresAt == nil || $0.expiresAt! > now } // drop expired
.prefix(limit)
.map { .init(type: $0.memoryType, key: $0.key, value: $0.value, importance: $0.importance) }
.map { .init(type: $0.memoryType, key: $0.key, value: cap($0.value, to: valueCap) ?? $0.value, importance: $0.importance) }
}

private static func profileCompleteness(_ profile: UserProfile?) -> String {
Expand Down
20 changes: 20 additions & 0 deletions PulseLoop/Coach/Context/CoachContextPacket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ struct CoachContextPacket: Encodable {
var memories: [MemoryContext]
var conversationSummary: String?
var dataQualityWarnings: [String]
/// Opt-in city-level location + current weather. Nil when the toggle is off,
/// permission is denied, or WeatherKit is unavailable. Raw coordinates NEVER
/// appear here — only the reverse-geocoded city/region.
var environment: EnvironmentContext?

struct ProfileContext: Encodable {
var name: String?
Expand Down Expand Up @@ -104,4 +108,20 @@ struct CoachContextPacket: Encodable {
var value: String
var importance: Int
}

/// City-level location + current/forecast weather. City-only privacy: never a
/// street, never coordinates. Any field may be nil when weather degrades to a
/// city-only or stale result.
struct EnvironmentContext: Encodable {
var city: String?
var region: String?
var tempC: Double?
var condition: String?
var highC: Double?
var lowC: Double?
var precipitationChancePct: Int?
var sunrise: String?
var sunset: String?
var asOf: String
}
}
Loading
Loading