diff --git a/CLAUDE.md b/CLAUDE.md index 4339e91..ae98976 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ The `.xcodeproj` is checked into git for convenience but is fully reproducible f ## API integration - **Phoropter**: `POST /api/plain` with phoropter context + trajectory as plain text body +- **Phoropter context**: fetched from `https://phoropter.ai/README.md`, the same file phoropter.ai loads at runtime, so edit it there, not here. The last good fetch is cached; `Resources/PhoropterContext.txt` is only the cold-start fallback. See `PhoropterContext.swift`. - **Chat**: `POST /api/stream` with JSON `{ "chat_log": [...] }` — opens with the phoropter handoff (the trail, rebuilt from the saved session on every turn), which carries the one `cache_control` marker the server requires; the conversation follows. The handoff is the conversation frame. See `LightwardAPI.buildChatLog`. ## Build & deploy diff --git a/Lightward.xcodeproj/project.pbxproj b/Lightward.xcodeproj/project.pbxproj index 8bd5ea1..79af040 100644 --- a/Lightward.xcodeproj/project.pbxproj +++ b/Lightward.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1B5EF0980DE84575A3EB37F1 /* SyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2A414C3C7EB85A76C921DF3 /* SyncCoordinator.swift */; }; 1D575BEA1AF711D76AED0661 /* ConsentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B2C4F8EFECE56B55641884 /* ConsentView.swift */; }; 35B05DB4DD32B4BDB27A99CE /* PhoropterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8767EEFD5035434FD48BCF59 /* PhoropterViewModel.swift */; }; + 35E885D645F3CAEEB75A6F09 /* PhoropterContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7655605E96072C691F1E04AE /* PhoropterContext.swift */; }; 47CD50EC4A22C0BF4EEEA0FA /* SessionRecordConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41B8F9961C5EF7F6D0043DE3 /* SessionRecordConverter.swift */; }; 495A2EF2DEE25981084D1FBC /* LightwardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5859B9B47E69E55F27E70894 /* LightwardApp.swift */; }; 54A4D7CB487C97E5BF4D4BE6 /* PhoropterContext.txt in Resources */ = {isa = PBXBuildFile; fileRef = 8A7CC1354CE106FD82E15968 /* PhoropterContext.txt */; }; @@ -25,6 +26,7 @@ C0544685049FA94C5B878D15 /* PhoropterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FA0BC5F7E742BA1DED525D /* PhoropterTests.swift */; }; D72D86358D7F177FA3F11ECC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC6B34171A61336C6EA1FAC /* ChatView.swift */; }; DA58E8D348E1DA8DD9939BE7 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = D409B6F3E1E128C9B48DDF0D /* Log.swift */; }; + DBBF2B4A49A39FB1655C745A /* PhoropterContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C64EF783282FE659F68CDE1 /* PhoropterContextTests.swift */; }; E534B9853A485310DDEE209A /* ErrorReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8DDBCE51B203ED468D4FBC4 /* ErrorReporter.swift */; }; E6FB0D31BE59AF82A4BD4715 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9740F34A497AA30E6D4A7F /* Session.swift */; }; EA756F58258183542CDBF168 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036FA252BC6E70CA922D890C /* ChatViewModel.swift */; }; @@ -53,7 +55,9 @@ 62FA0BC5F7E742BA1DED525D /* PhoropterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoropterTests.swift; sourceTree = ""; }; 6FD39565947A3FD1E3951984 /* AuraArrow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuraArrow.swift; sourceTree = ""; }; 741D20ECF440169D24BB7B42 /* PhoropterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoropterView.swift; sourceTree = ""; }; + 7655605E96072C691F1E04AE /* PhoropterContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoropterContext.swift; sourceTree = ""; }; 7BD9CF5C099920F1576987CF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 7C64EF783282FE659F68CDE1 /* PhoropterContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoropterContextTests.swift; sourceTree = ""; }; 8767EEFD5035434FD48BCF59 /* PhoropterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoropterViewModel.swift; sourceTree = ""; }; 8A7CC1354CE106FD82E15968 /* PhoropterContext.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = PhoropterContext.txt; sourceTree = ""; }; 8A9740F34A497AA30E6D4A7F /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; @@ -144,6 +148,7 @@ isa = PBXGroup; children = ( A23D85D1244F162E1492DB65 /* ChatLogTests.swift */, + 7C64EF783282FE659F68CDE1 /* PhoropterContextTests.swift */, 62FA0BC5F7E742BA1DED525D /* PhoropterTests.swift */, ); path = LightwardTests; @@ -169,6 +174,7 @@ F620436512F79E8E5E729C26 /* Phoropter */ = { isa = PBXGroup; children = ( + 7655605E96072C691F1E04AE /* PhoropterContext.swift */, 741D20ECF440169D24BB7B42 /* PhoropterView.swift */, 8767EEFD5035434FD48BCF59 /* PhoropterViewModel.swift */, ); @@ -277,6 +283,7 @@ 60379A915DB05685903CC59D /* LightwardAPI.swift in Sources */, 495A2EF2DEE25981084D1FBC /* LightwardApp.swift in Sources */, DA58E8D348E1DA8DD9939BE7 /* Log.swift in Sources */, + 35E885D645F3CAEEB75A6F09 /* PhoropterContext.swift in Sources */, A016300F3BADC6B245404148 /* PhoropterView.swift in Sources */, 35B05DB4DD32B4BDB27A99CE /* PhoropterViewModel.swift in Sources */, A30947B4D7A061DB4C90F24F /* RootView.swift in Sources */, @@ -294,6 +301,7 @@ buildActionMask = 2147483647; files = ( 6F9B6B00783E5617FBB54578 /* ChatLogTests.swift in Sources */, + DBBF2B4A49A39FB1655C745A /* PhoropterContextTests.swift in Sources */, C0544685049FA94C5B878D15 /* PhoropterTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Lightward/Phoropter/PhoropterContext.swift b/Lightward/Phoropter/PhoropterContext.swift new file mode 100644 index 0000000..84086fc --- /dev/null +++ b/Lightward/Phoropter/PhoropterContext.swift @@ -0,0 +1,66 @@ +import Foundation + +/// The phoropter's context — the README that phoropter.ai itself loads at runtime. +/// +/// One source for both clients: fetched from where phoropter.ai publishes it, so a README +/// edit reaches iOS when the site deploys, with nothing to copy across repos. The last good +/// fetch is cached; the bundled PhoropterContext.txt is only the cold-start fallback. +enum PhoropterContext { + static let remoteURL = URL(string: "https://phoropter.ai/README.md")! + static let cacheKey = "phoropterContext.cached" + + /// The section the payload's closing instruction points the model back to. + static let requiredSection = "## Response notes" + + /// Fetches the published README, falling back to the last good fetch, then the bundle. + static func load() async -> String { + let fetched = await fetchRemote() + let cached = UserDefaults.standard.string(forKey: cacheKey) + if let fetched, isUsable(fetched) { + UserDefaults.standard.set(fetched, forKey: cacheKey) + } + let context = resolve(fetched: fetched, cached: cached, bundled: bundled()) + Log.phoropter.info("Context: \(source(fetched: fetched, cached: cached), privacy: .public), \(context.count) chars") + return context + } + + /// Fresh beats cached beats bundled; anything that doesn't look like the README is passed over. + static func resolve(fetched: String?, cached: String?, bundled: String) -> String { + if let fetched, isUsable(fetched) { return fetched } + if let cached, isUsable(cached) { return cached } + return bundled + } + + /// A fetch can succeed and still not be the README (an error page, a truncated body). + static func isUsable(_ text: String) -> Bool { + text.contains(requiredSection) + } + + static func bundled() -> String { + guard let url = Bundle.main.url(forResource: "PhoropterContext", withExtension: "txt"), + let text = try? String(contentsOf: url, encoding: .utf8) else { + return "" + } + return text + } + + private static func fetchRemote() async -> String? { + var request = URLRequest(url: remoteURL) + request.timeoutInterval = 10 + request.cachePolicy = .reloadIgnoringLocalCacheData + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { return nil } + return String(data: data, encoding: .utf8) + } catch { + Log.phoropter.error("Context fetch failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + private static func source(fetched: String?, cached: String?) -> String { + if let fetched, isUsable(fetched) { return "phoropter.ai" } + if let cached, isUsable(cached) { return "cache" } + return "bundle" + } +} diff --git a/Lightward/Phoropter/PhoropterViewModel.swift b/Lightward/Phoropter/PhoropterViewModel.swift index 1c5357c..caa92f1 100644 --- a/Lightward/Phoropter/PhoropterViewModel.swift +++ b/Lightward/Phoropter/PhoropterViewModel.swift @@ -26,7 +26,9 @@ final class PhoropterViewModel { var aiResponseCount = 0 private let store: Store - private let phoropterContext: String + /// Fetched once per phoropter session, starting at the first choice — never before + /// consent, and never for someone who hasn't begun. + private var phoropterContext: Task? private var currentTask: Task? private var revealTask: Task? @@ -41,15 +43,12 @@ final class PhoropterViewModel { init(store: Store) { self.store = store - self.phoropterContext = Self.loadContext() } - private static func loadContext() -> String { - guard let url = Bundle.main.url(forResource: "PhoropterContext", withExtension: "txt"), - let text = try? String(contentsOf: url, encoding: .utf8) else { - return "" - } - return text + private func context() async -> String { + let task = phoropterContext ?? Task { await PhoropterContext.load() } + phoropterContext = task + return await task.value } func cycleEntry() { @@ -103,7 +102,7 @@ final class PhoropterViewModel { currentTask = Task { do { let payload = LightwardAPI.buildPhoropterPayload( - context: phoropterContext, + context: await context(), trajectory: trail ) diff --git a/LightwardTests/PhoropterContextTests.swift b/LightwardTests/PhoropterContextTests.swift new file mode 100644 index 0000000..4353cd4 --- /dev/null +++ b/LightwardTests/PhoropterContextTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import Lightward + +struct PhoropterContextTests { + private let readme = "# phoropter.ai\n\n## Response notes\n\nfresh" + private let older = "# phoropter.ai\n\n## Response notes\n\nolder" + private let bundled = "# phoropter.ai\n\n## Response notes\n\nbundled" + + @Test func freshFetchWins() { + #expect(PhoropterContext.resolve(fetched: readme, cached: older, bundled: bundled) == readme) + } + + @Test func cacheWhenFetchFails() { + #expect(PhoropterContext.resolve(fetched: nil, cached: older, bundled: bundled) == older) + } + + @Test func bundleWhenNothingElse() { + #expect(PhoropterContext.resolve(fetched: nil, cached: nil, bundled: bundled) == bundled) + } + + @Test func unusableFetchIsPassedOver() { + let errorPage = "404" + #expect(PhoropterContext.resolve(fetched: errorPage, cached: older, bundled: bundled) == older) + #expect(PhoropterContext.resolve(fetched: "", cached: nil, bundled: bundled) == bundled) + } + + @Test func bundledCopyIsUsable() { + #expect(PhoropterContext.isUsable(PhoropterContext.bundled())) + } +} diff --git a/PRIVACY.md b/PRIVACY.md index 4f9c86a..1ee2fc5 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -15,6 +15,8 @@ Your conversation text is sent to **Lightward AI** (lightward.com) for processin - **Lightward AI** does not store your conversations on its servers. Conversations are processed in real time and not retained. - **Anthropic** processes API requests according to their [usage policy](https://www.anthropic.com/policies). Per Anthropic's API terms, data sent via the API is not used to train models. +The app also downloads the phoropter's instructions from **phoropter.ai** (hosted on GitHub Pages) once you begin. The request contains no conversation content; as with any network request, the host receives network metadata such as your IP address. + ## What stays on your device Your conversation history is stored locally on your device and synced across your own devices via iCloud. Lightward Inc does not have access to your stored conversations. There is no conversation recording or monitoring on our end.