From 0499eb3df2404f24d7d81cfba0384f3df7424b04 Mon Sep 17 00:00:00 2001 From: Isaac Bowen Date: Sun, 11 Oct 2026 09:32:37 +0900 Subject: [PATCH 1/4] phoropter context comes from phoropter.ai iOS used to carry a hand-copied PhoropterContext.txt, which drifted from the README phoropter.ai serves. Now it fetches that same README once per phoropter session, keeps the last good copy, and falls back to the bundled file only on a cold start without network. One source; nothing to propagate. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 1 + Lightward.xcodeproj/project.pbxproj | 8 +++ Lightward/Phoropter/PhoropterContext.swift | 66 ++++++++++++++++++++ Lightward/Phoropter/PhoropterViewModel.swift | 15 ++--- LightwardTests/PhoropterContextTests.swift | 30 +++++++++ 5 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 Lightward/Phoropter/PhoropterContext.swift create mode 100644 LightwardTests/PhoropterContextTests.swift 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..be05152 100644 --- a/Lightward/Phoropter/PhoropterViewModel.swift +++ b/Lightward/Phoropter/PhoropterViewModel.swift @@ -26,7 +26,8 @@ final class PhoropterViewModel { var aiResponseCount = 0 private let store: Store - private let phoropterContext: String + /// Fetched once per phoropter session; usually settled before the first choice needs it. + private let phoropterContext: Task private var currentTask: Task? private var revealTask: Task? @@ -41,15 +42,7 @@ 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 + self.phoropterContext = Task { await PhoropterContext.load() } } func cycleEntry() { @@ -103,7 +96,7 @@ final class PhoropterViewModel { currentTask = Task { do { let payload = LightwardAPI.buildPhoropterPayload( - context: phoropterContext, + context: await phoropterContext.value, 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())) + } +} From d961ff30e233b6a608ca9d03f518d9805d4811b2 Mon Sep 17 00:00:00 2001 From: Isaac Bowen Date: Sun, 11 Oct 2026 11:59:06 +0900 Subject: [PATCH 2/4] fetch the context at the first choice, not at init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view model is created while the consent screen may still be up, so fetching at init could reach phoropter.ai before consent. Now the fetch starts on first use — the first choice someone makes — which is always after consent, and never for someone who hasn't begun. (Thanks, Copilot.) Co-Authored-By: Claude Opus 5.5 (1M context) --- Lightward/Phoropter/PhoropterViewModel.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Lightward/Phoropter/PhoropterViewModel.swift b/Lightward/Phoropter/PhoropterViewModel.swift index be05152..caa92f1 100644 --- a/Lightward/Phoropter/PhoropterViewModel.swift +++ b/Lightward/Phoropter/PhoropterViewModel.swift @@ -26,8 +26,9 @@ final class PhoropterViewModel { var aiResponseCount = 0 private let store: Store - /// Fetched once per phoropter session; usually settled before the first choice needs it. - private let phoropterContext: Task + /// 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? @@ -42,7 +43,12 @@ final class PhoropterViewModel { init(store: Store) { self.store = store - self.phoropterContext = Task { await PhoropterContext.load() } + } + + private func context() async -> String { + let task = phoropterContext ?? Task { await PhoropterContext.load() } + phoropterContext = task + return await task.value } func cycleEntry() { @@ -96,7 +102,7 @@ final class PhoropterViewModel { currentTask = Task { do { let payload = LightwardAPI.buildPhoropterPayload( - context: await phoropterContext.value, + context: await context(), trajectory: trail ) From 22e25b914aae033ab82d75e4f835db29523f0c3c Mon Sep 17 00:00:00 2001 From: Isaac Bowen Date: Sun, 11 Oct 2026 12:03:11 +0900 Subject: [PATCH 3/4] privacy policy names phoropter.ai The phoropter context is now a runtime dependency on phoropter.ai; say so plainly, where the next one will trip over it. Co-Authored-By: Claude Opus 5.5 (1M context) --- PRIVACY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PRIVACY.md b/PRIVACY.md index 4f9c86a..f6420d2 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. That request contains no conversation content or personal information. + ## 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. From a2ce1d849e296138d0c6b65bbf14d9f0d71932a1 Mon Sep 17 00:00:00 2001 From: Isaac Bowen Date: Sun, 11 Oct 2026 13:59:27 +0900 Subject: [PATCH 4/4] privacy policy: say what a request does reveal "No personal information" overclaimed: any request to GitHub Pages carries the source IP. Narrowed per Copilot's suggestion. Co-Authored-By: Claude Opus 5.5 (1M context) --- PRIVACY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PRIVACY.md b/PRIVACY.md index f6420d2..1ee2fc5 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -15,7 +15,7 @@ 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. That request contains no conversation content or personal information. +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