@@ -1,124 +1,282 @@
---
created : '2026-05-16'
updated : '2026-05-16 '
updated : '2026-05-19 '
tags :
- project
- dev
- ios
- swift
- android
- skip
---
# Reflect App — Dev Setup & Architecture
> Путь: `~/Developer/reflect-app/`
> Статус: BUILD SUCCEEDED, 30/30 тестов
> **iOS (reflect-app):** `~/Desktop/reflect-app/` — нативный SwiftUI, не трогать
> **Cross-platform (reflect-skip):** `~/Developer/reflect-skip/` — Skip Fuse, iOS + Android
> Gitea: `git.mallexxx.duckdns.org/git_admin/reflect-app`
---
## Структура проекта
## Статус (2026-05-19)
```
reflect-app/
├── project.yml ← XcodeGen spec (генерирует .xcodeproj)
├── proxy_server.py ← Local AI proxy server (Mac → iPhone)
├── ReflectApp/
│ ├── Models/
│ │ ├── CrisisKeywords.swift
│ │ ├── Message.swift
│ │ ├── Session.swift
│ │ └── UserProfile.swift
│ ├── Services/
│ │ ├── ClaudeService.swift ← AI бэкенд (cloud + local proxy)
│ │ ├── AnalystBot.swift ← Бот 2 (Sonnet, CBT вопросы)
│ │ ├── NarratorBot.swift ← Бот 1 (Haiku, 3rd-person reframe)
│ │ ├── CrisisDetector.swift ← Hardcoded SI/SH детекция (RU+EN)
│ │ ├── ProfileManager.swift ← Obsidian-like on-device vault
│ │ └── SessionScheduler.swift ← Напоминания (UNUserNotificationCenter)
│ ├── Views/
│ │ ├── OnboardingView.swift ← 3 слайда + 5 вопросов + инсайт
│ │ ├── HomeView.swift
│ │ ├── SessionView.swift
│ │ ├── CrisisView.swift
│ │ ├── ProfileView.swift ← Obsidian-like markdown viewer
│ │ ├── SettingsView.swift ← Выбор бэкенда + API key + server URL
│ │ ├── CardView.swift
│ │ └── OrbCharacter.swift ← SwiftUI анимированный орб
│ └── ViewModels/
│ ├── HomeViewModel.swift
│ └── SessionViewModel.swift
└── ReflectAppTests/
└── ReflectAppTests.swift ← 30 тестов
```
### Что реализовано
| Компонент | Статус |
|-----------|--------|
| iOS build (reflect-app) | ✅ Build succeeded, 30/30 тестов |
| Android build (reflect-skip) | ✅ APK собирается, запускается на эмуляторе |
| Экран 0: Privacy intro | ✅ Личный текст, GDPR opt-out, дисклеймер |
| Онбординг | ✅ 3 info-слайда + 5 вопросов + AI инсайт |
| Сессия: dynamic intro | ✅ AI-генерированное вступление + max 2 крючка из истории |
| Сессия: one-by-one вопросы | ✅ Вопросы по одному, батч-подбивка агенту |
| Сессия: closing phrase | ✅ Тёплая фраза от Narrator'а |
| Сессия: one last thing | ✅ Маленький input + неакцентная кнопка Завершить |
| Dual-bot pipeline | ✅ Narrator (Haiku) → Analyst (Sonnet) |
| Crisis detection | ✅ Hardcoded RU+EN, CrisisView |
| Profile vault | ✅ profile.md, sessions/, insights.md |
| App Lock (Face ID) | ✅ BiometricAuthentication |
| Settings: API key, proxy | ✅ Keychain + LocalProxy |
| Settings: Сбросить данные | ✅ Алерт + полный сброс + онбординг заново |
| Session scheduler | ✅ UNCalendarNotification |
### Что не реализовано (план)
| Компонент | Приоритет |
|-----------|-----------|
| ProfileView: история сессий | 🟠 Средний |
| Session recovery (незавершённые) | 🟠 Средний |
| Полноценный sessions/YYYY-MM-DD.md | 🟡 Низкий |
| RevenueCat paywall | 🔴 Нужен до публикации |
| Голосовой ввод (iOS) | 🟡 Phase 2 |
| Push-уведомления opt-in | 🟡 Phase 2 |
---
## Генерация проект а
## Android / Skip Fuse — сборк а
### Требования
- Xcode 16+, `xcodebuild`
- Java 21: `/opt/homebrew/opt/openjdk@21`
- Android SDK: `~/Library/Android/sdk`
- Android Emulator: `reflect-android` (Pixel 6, API 35)
- Skip CLI: `skip` (из Homebrew или из Xcode DerivedData)
### Полная сборка (первый раз или после новых Swift файлов)
``` bash
# Установить XcodeGen (один раз)
brew install xcodegen
cd ~/Developer/reflect-skip
# Регенерировать .xcodeproj после изменения project.yml
cd ~/Developer/reflect-app
xcodegen generate
export ANDROID_HOME = ~/Library/Android/sdk
export JAVA_HOME = /opt/homebrew/opt/openjdk@21
export PATH = " $JAVA_HOME /bin: $ANDROID_HOME /platform-tools:/opt/homebrew/bin: $PATH "
# Шаг 1: Xcode генерирует Kotlin стабы (skipstone) + собирает Android
xcodebuild \
-workspace Project.xcworkspace \
-scheme "ReflectApp App" \
-destination "generic/platform=iOS Simulator" \
-configuration Debug \
-skipPackagePluginValidation \
SKIP_ACTION = build
# Шаг 2: Линкуем symlinks субмодулей (один раз после очистки)
# Делается скриптом или вручную — см. раздел ниже
# Шаг 3: Gradle собирает APK
cd Darwin
skip gradle -p ../Android assembleDebug
```
### Быстрая пересборка (инкрементальная)
``` bash
cd ~/Developer/reflect-skip/Darwin
export ANDROID_HOME = ~/Library/Android/sdk JAVA_HOME = /opt/homebrew/opt/openjdk@21
export PATH = " $JAVA_HOME /bin: $ANDROID_HOME /platform-tools:/opt/homebrew/bin: $PATH "
skip gradle -p ../Android assembleDebug
```
### Деплой на эмулятор
``` bash
export ANDROID_HOME = ~/Library/Android/sdk
export PATH = " $ANDROID_HOME /platform-tools: $PATH "
# Запустить эмулятор если не запущен
$ANDROID_HOME /emulator/emulator -avd reflect-android &
APK = ~/Developer/reflect-skip/.build/Android/app/outputs/apk/debug/app-debug.apk
adb -s emulator-5554 install -r " $APK "
adb -s emulator-5554 shell am start -n com.reflect.app/reflect.app.MainActivity
```
---
## Сборка и тесты
## ⚠️ Критические особенности Skip Fuse
### 1. Новые Swift View файлы требуют ручного Kotlin стаба
При добавлении нового `*.swift` файла с View в `Sources/ReflectApp/Views/` :
skipstone **не всегда ** автоматически генерирует `.kt` стаб. Нужно создать вручную.
**Шаблон ** (копируем `OnboardingView.kt` , меняем класс):
``` kotlin
package reflect.app
import skip.lib.*
import skip.ui.*
import skip.foundation.*
import skip.model.*
@androidx . annotation . Keep
internal class MyNewView : skip . ui . View , skip . bridge . SwiftPeerBridged , skip . lib . SwiftProjecting {
var Swift _peer : skip . bridge . SwiftObjectPointer = skip . bridge . SwiftObjectNil
constructor ( Swift _peer : skip . bridge . SwiftObjectPointer , marker : skip . bridge . SwiftPeerMarker ? ) {
this . Swift _peer = Swift _peer
}
fun finalize ( ) { Swift _release ( Swift _peer ) ; Swift _peer = skip . bridge . SwiftObjectNil }
private external fun Swift _release ( Swift _peer : skip . bridge . SwiftObjectPointer )
override fun Swift _peer ( ) : skip . bridge . SwiftObjectPointer = Swift _peer
override fun equals ( other : Any ? ) : Boolean {
if ( other !is skip . bridge . SwiftPeerBridged ) return false
return Swift _peer == other . Swift _peer ( )
}
override fun hashCode ( ) : Int = Swift_peer . hashCode ( )
override fun body ( ) : skip . ui . View {
return skip . ui . ComposeBuilder { ctx -> Swift _composableBody ( Swift _peer ) ?. Compose ( ctx ) ?: skip . ui . ComposeResult . ok }
}
private external fun Swift _composableBody ( Swift _peer : skip . bridge . SwiftObjectPointer ) : skip . ui . View ?
constructor ( /* params */ ) { Swift _peer = Swift _constructor _0 ( /* params */ ) }
private external fun Swift _constructor _0 ( /* params */ ) : skip . bridge . SwiftObjectPointer
override fun Swift _projection ( options : Int ) : ( ) -> Any = Swift _projectionImpl ( options )
private external fun Swift _projectionImpl ( options : Int ) : ( ) -> Any
@androidx . annotation . Keep companion object { }
}
```
**Файл кладём в оба места: **
```
~/Library/Developer/Xcode/DerivedData/Project-bpfpf.../Build/Intermediates.noindex/BuildToolPluginIntermediates/reflect-skip.output/ReflectApp/skipstone/ReflectApp/src/main/kotlin/reflect/app/
~/Developer/reflect-skip/.build/plugins/outputs/reflect-skip/ReflectApp/destination/skipstone/ReflectApp/src/main/kotlin/reflect/app/
```
### 2. Kotlin субмодули — symlinks
После очистки кеша/DerivedData Kotlin исходники субмодулей (SkipLib, SkipBridge, etc.) перестают быть видны Gradle. Нужно перелинковать:
``` bash
# Узнать ID симулятора
xcrun simctl list devices | grep "iPhone 16 Pro "
DERIVED = " $HOME /Library/Developer/Xcode/DerivedData/Project-bpfpf... "
SKIPSTONE = " $HOME /Developer/reflect-skip/.build/plugins/outputs/reflect-skip/ReflectApp/destination/skipstone "
# Сборка
xcodebuild -project ReflectApp.xcodeproj -scheme ReflectApp \
-destination "id=9FF2D6A8-E0DF-4157-9FA5-463E45BB5FAD" build
for MODULE in SkipLib SkipBridge SkipFoundation SkipUI SkipAndroidBridge SkipModel SkipSwiftUI SkipUnit; do
case " $MODULE " in
SkipLib) PKG = "skip-lib" ; ;
SkipBridge) PKG = "skip-bridge" ; ;
SkipFoundation) PKG = "skip-foundation" ; ;
SkipUI) PKG = "skip-ui" ; ;
SkipAndroidBridge) PKG = "skip-android-bridge" ; ;
SkipModel) PKG = "skip-model" ; ;
SkipSwiftUI) PKG = "skip-fuse-ui" ; ;
SkipUnit) PKG = "skip-unit" ; ;
esac
REAL_KT = " $DERIVED /Build/Intermediates.noindex/BuildToolPluginIntermediates/ ${ PKG } .output/ ${ MODULE } /skipstone/ ${ MODULE } /src/main/kotlin "
mv " $SKIPSTONE / $MODULE /src/main/kotlin " " $SKIPSTONE / $MODULE /src/main/kotlin.bak " 2>/dev/null || true
ln -sfn " $REAL_KT " " $SKIPSTONE / $MODULE /src/main/kotlin "
done
```
# Тесты (30 тестов, ~0.1 сек)
xcodebuild test -project ReflectApp.xcodeproj -scheme ReflectApp \
-destination "id=9FF2D6A8-E0DF-4157-9FA5-463E45BB5FAD"
### 3. AndroidBridge.initBridge — обязательно до первого JNI вызова
В `Android/app/src/main/kotlin/Main.kt` порядок в `onCreate` :
``` kotlin
ProcessInfo . launch ( applicationContext )
AndroidBridge . initBridge ( " ReflectApp " ) // ← ДО AppDelegate.shared.onInit()
AppDelegate . shared . onInit ( )
```
---
## Локальный AI сервер (режим без ключа )
## Структура проекта (reflect-skip )
### Запуск
```
reflect-skip/
├── Package.swift ← SPM, SkipFuseUI зависимость
├── Skip.env ← ANDROID_PACKAGE_NAME=reflect.app
├── Project.xcworkspace/ ← Workspace для xcodebuild
├── Darwin/ ← Xcode проект (iOS таргет + Skip build phases)
│ ├── ReflectApp.xcodeproj/
│ └── Sources/Main.swift
├── Android/ ← Gradle Android проект
│ ├── app/src/main/
│ │ ├── AndroidManifest.xml
│ │ └── kotlin/Main.kt ← Entry point, AndroidBridge.initBridge
│ └── settings.gradle.kts
└── Sources/ReflectApp/
├── ReflectAppMain.swift ← App root + PrivacyIntroView → Onboarding → Home
├── Models/
├── Services/
│ ├── ClaudeService.swift ← Claude API (cloud + local proxy)
│ ├── AnalystBot.swift ← Бот 2 (Sonnet, CBT вопросы + tools)
│ ├── NarratorBot.swift ← Бот 1 (Haiku, 3rd-person reframe)
│ ├── CrisisDetector.swift ← Hardcoded SI/SH
│ ├── ProfileManager.swift ← On-device vault (profile.md, sessions/)
│ ├── SessionManager.swift ← Оркестратор: intro/closing/pipeline
│ └── ScheduleNegotiationService.swift
└── Views/
├── PrivacyIntroView.swift ← Экран 0: личный текст + GDPR
├── OnboardingView.swift ← Онбординг
├── HomeView.swift
├── SessionView.swift ← Фазы: loading/intro/dialogue/closing/oneLastThing
├── CardView.swift
├── SettingsView.swift ← AI backend + Сбросить данные
└── OrbCharacter.swift
```
---
## Архитектура флоу онбординга
```
Первый запуск
→ PrivacyIntroView (privacy_accepted)
→ OnboardingView (5 вопросов + AI инсайт → profile.md)
→ HomeView
→ SessionView (loading → intro → dialogue → closing → oneLastThing → done)
```
---
## On-device хранилище
```
Documents/UserProfile/
├── profile.md ← паттерны, наблюдения, счётчик сессий
├── insights.md ← кристаллизованные инсайты
└── sessions/
├── index.md ← таблица дат/тем для быстрого поиска
└── 2026-05-19.md
```
**Сброс: ** Settings → Данные → Сбросить все данные → удаляет `UserProfile/` , сбрасывает `privacy_accepted` , `onboarding_completed` , API key.
---
## Локальный AI прокси
``` bash
# claude-code — OAuth через Hermes (нет API ключа, читает CLAUDE_CODE_OAUTH_TOKEN из ~/.hermes/.env)
cd ~/Developer/reflect-app
python3 proxy_server.py --backend claude-code
# Ollama — локальная модель
cd ~/Developer/reflect-app # iOS проект
python3 proxy_server.py --backend claude-code # OAuth через Hermes
# или
python3 proxy_server.py --backend ollama --ollama-model qwen2.5:7b
# Claude API key
export ANTHROPIC_API_KEY = sk-ant-...
python3 proxy_server.py --backend claude
```
**claude-code бэкенд ** — приоритет источников токена:
1. `CLAUDE_CODE_OAUTH_TOKEN` из `~/.hermes/.env` (Hermes-managed, наиболее надёжный)
2. macOS Keychain (`Claude Code-credentials` )
3. `~/.claude/.credentials.json` (с авто-обновлением если есть refreshToken)
### Настройка на iPhone
1. Открыть приложение → шестерёнка (Settings)
2. AI Backend → "Локальный сервер (Mac)"
3. URL сервера: скопировать из вывода `proxy_server.py`
4. Нажать "Проверить" — должно показать "Доступен · qwen2.5:7b"
### Архитектура proxy
```
iPhone (ClaudeService)
→ POST /v1/messages → proxy_server.py (8765)
→ Anthropic API format → Ollama (11434)
← response ← Ollama response (конвертируется в Anthropic формат)
← response ← proxy_server.py
```
В Settings приложения: AI Backend → Локальный сервер → URL сервера.
---
@@ -131,106 +289,13 @@ CrisisDetector.detect() — hardcoded, <5ms
↓ (если .critical → CrisisView)
NarratorBot (Haiku) — переформулировка в 3-е лицо
↓
AnalystBot (Sonnet) — читает profile.md + вопросы
AnalystBot (Sonnet) — читает profile.md + инструменты (search_sessions, read_profile...)
↓
CardView с вопросами
1 вопрос из батча → ответ → следующий вопрос → нумерованная подбивка → AnalystBot
```
---
## On-device хранилище (Obsidian-like)
```
Documents/UserProfile/
├── profile.md ← паттерны, наблюдения, прогресс
├── insights.md ← кристаллизованные инсайты
└── sessions/
├── 2026-05-16.md
└── ...
```
---
## App Lock (Face ID / Touch ID / Passcode)
**Файлы: **
- `Services/AppLockService.swift` — `@MainActor ObservableObject` , `LAContext` , состояние
- `Views/LockView.swift` — экран блокировки, автоматически запускает биометрию при появлении
- `ReflectApp.swift` — `checkOnLaunch()` при старте, `scenePhase.background → lock()`
- `SettingsView.swift` — секция "Безопасность" с Toggle
**Поведение: **
- Включение/выключение требует подтверждения биометрией
- При уходе в фон (`scenePhase == .background` ) — блокируется
- При возврате — LockView показывается поверх всего, сразу запрашивает биометрию
- Симулятор: биометрия эмулируется через Features → Face ID → Enrolled
---
## Голосовой ввод
* * `Services/VoiceInputService.swift` ** — `@MainActor ObservableObject` :
- `AVAudioEngine` + `SFSpeechRecognizer` (locale: ru-RU с fallback на системный)
- Push-to-talk: `startRecording()` / `stopRecording()`
- `onTranscript: ((String) -> Void)?` — коллбэк с финальным текстом
- `audioLevel: Float` — уровень звука в реальном времени (→ орб анимация)
- `requestPermissions()` — запрашивает mic + speech recognition
---
## Адаптивное расписание (ScheduleNegotiationService)
* * `Services/ScheduleNegotiationService.swift` **:
- `nextSessionDate` — следующая запланированная сессия (UserDefaults)
- `buildSuggestion()` → предлагает время на основе паттерна прошлых встреч
- `acceptSchedule(date)` → сохраняет и ставит разовое `UNCalendarNotificationTrigger`
- `postpone(by: PostponeOption)` → переносит на +1ч / +3ч / завтра / skip
- Уведомление содержит action-кнопки прямо из шторы (без открытия приложения)
**Флоу в конце сессии: **
1. Агент завершает сессию → `SessionManager.endSession()` → `clearSchedule()`
2. `SessionViewModel.endSession()` → показывает `CardData` с предложением времени
3. Пользователь: "Да, подходит" / "Другое время" / "Без расписания"
4. При выборе времени → `ScheduleNegotiationService.acceptSchedule(date)` → уведомление
**HomeView: **
- Если есть расписание → показывает badge "Следующая: пятница 20:00" + кнопка "Перенести"
- Если нет расписания → "Назначить следующую встречу" (кнопка открывает `PostponeSheetView` )
---
## Тулинг агента (AnalystBot)
Аналитик использует tool-use loop (`ClaudeService.completeWithTools` ).
**4 инструмента: **
| Tool | Описание |
|------|----------|
| `get_index` | Индекс сессий — таблица дат/тем/тегов, < 1K токенов |
| `search_sessions` | Полнотекстовый поиск по архиву |
| `read_session` | Прочитать полную сессию по дате |
| `read_profile` | Прочитать profile.md |
Агент сам решает когда обращаться к хранилищу — когда видит паттерн или нужен контекст прошлых встреч.
---
## Session Index
* * `ProfileManager.updateSessionIndex()` ** обновляет `sessions/index.md` :
```
| Дата | Темы | Теги | Содержание |
|------------|------------------------|-------------------|-------------------|
| 2026-05-16 | работа, усталость | #сессия #инсайт | Говорит что устал |
```
Заполняется автоматически при `endSession()` . Агент читает через `get_index` — без засорения контекста полными файлами.
---
## Тест-результаты (2026-05-16)
- ClaudeServiceTests: 4/4
- CrisisDetectorTests: 17/17 (включая RU+EN, нормализация)
- ProfileManagerTests: 6/6 (включая onboarding init)
- SessionSchedulerTests: 3/3
- **ИТОГО: 30/30, 0 failures**
**Закрытие сессии: **
1. `endSession()` → AnalystBot генерирует наблюдение (3-е лицо)
2. NarratorBot генерирует тёплую closing phrase
3. One last thing: маленький input
4. Если нет расписания → предложить дату следующей сессии