Initial release: Poche 1.0.0 (1) — on-device personal agent
SwiftUI + Apple Foundation Models + EventKit + SwiftData, 100% on-device. Streaming chat with availability gating, 4096-token context budget with invisible condensation and session recycling, non-bypassable Confirm layer, 7-tool catalog, local semantic search (NLEmbedding), on-device dictation, App Intents, Share Extension. 21 tests across 5 suites, including a network isolation tripwire. Build 1.0.0 (1) uploaded to TestFlight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 62 changed files with +4,088 and −0
added
.gitignore
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +# .gitignore — Poche | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +# Generated by XcodeGen — project.yml is the source of truth | |
| 6 | +Poche.xcodeproj/ | |
| 7 | + | |
| 8 | +# Build products & archives | |
| 9 | +build/ | |
| 10 | +DerivedData/ | |
| 11 | + | |
| 12 | +# Xcode user state | |
| 13 | +xcuserdata/ | |
| 14 | +*.xcuserstate | |
| 15 | + | |
| 16 | +# macOS | |
| 17 | +.DS_Store | |
added
CLAUDE.md
+270 −0
@@ -0,0 +1,270 @@ | ||
| 1 | +# CLAUDE.md — Poche | |
| 2 | + | |
| 3 | +Agent personnel local. SwiftUI + Foundation Models + EventKit + SwiftData. | |
| 4 | +100 % on-device. Aucune API LLM externe. | |
| 5 | +Ce fichier est la source de vérité du projet. Le lire avant toute modification. | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. Règle fondatrice | |
| 10 | + | |
| 11 | +**Le seul moteur génératif de l'application est Apple Foundation Models, sur l'appareil.** | |
| 12 | + | |
| 13 | +Interdit, sans exception et sans mode caché : | |
| 14 | + | |
| 15 | +- OpenAI, Anthropic, Google, Mistral, ou tout autre fournisseur d'API LLM | |
| 16 | +- Tout serveur d'inférence, y compris auto-hébergé | |
| 17 | +- Tout modèle tiers embarqué (Hugging Face, GGUF, MLX avec poids externes) | |
| 18 | +- Toute clé API pour le fonctionnement principal | |
| 19 | +- Tout abonnement obligatoire pour utiliser l'IA | |
| 20 | + | |
| 21 | +Si une fonctionnalité ne peut pas être faite on-device, **elle n'est pas faite**. On ne dégrade pas la promesse pour ajouter une capacité. | |
| 22 | + | |
| 23 | +### Décision à trancher avant la v1 : Private Cloud Compute | |
| 24 | + | |
| 25 | +PCC est un serveur d'Apple. Il est gratuit pour le développeur, sans clé API, sans compte, et privé par conception — mais **ce n'est pas on-device**. | |
| 26 | + | |
| 27 | +**Position par défaut du projet : PCC désactivé.** La promesse « ton iPhone, rien d'autre » est plus forte et plus simple à tenir que « c'est privé, promis ». Si PCC est un jour activé, ce doit être : opt-in explicite, visible à chaque appel, jamais par défaut, jamais sur du contenu marqué sensible. Ne pas prendre cette décision implicitement dans un commit. | |
| 28 | + | |
| 29 | +--- | |
| 30 | + | |
| 31 | +## 2. Contraintes — à lire avant de promettre quoi que ce soit | |
| 32 | + | |
| 33 | +### Le contexte : le problème numéro un d'un produit de chat | |
| 34 | + | |
| 35 | +**4096 tokens (iOS 26) / 8192 (iOS 27, appareils récents).** Et dans ce budget tiennent : les instructions système, **les définitions de tous les outils**, tout l'historique de la conversation, et la réponse à venir. | |
| 36 | + | |
| 37 | +Conséquence concrète : une conversation ordinaire sature en quelques dizaines de tours. Sur une app de chat, c'est fatal si ce n'est pas géré dès le premier jour. Voir §5 — c'est le chantier central du projet, pas un détail d'optimisation. | |
| 38 | + | |
| 39 | +Preflight obligatoire avant chaque appel : | |
| 40 | + | |
| 41 | +```swift | |
| 42 | +let model = SystemLanguageModel.default | |
| 43 | +let budget = try await model.contextSize | |
| 44 | +let cost = try await model.tokenCount(for: prompt) | |
| 45 | +guard cost < budget - responseReserve else { try await condense() } | |
| 46 | +``` | |
| 47 | + | |
| 48 | +Réserver au moins 30 % pour la réponse. Un prompt à 4092 tokens échoue quand même : le modèle n'a plus la place de répondre. | |
| 49 | + | |
| 50 | +### La capacité réelle du modèle | |
| 51 | + | |
| 52 | +~3 milliards de paramètres. Environ 100× plus petit qu'un modèle frontière. Il est bon en : extraction, classification, reformulation, sortie structurée, choix d'outil simple. Il est mauvais en : raisonnement multi-étapes, planification longue, connaissance du monde, arithmétique. | |
| 53 | + | |
| 54 | +**Ne jamais concevoir une fonctionnalité qui suppose un raisonnement en chaîne.** Un agent qui échoue une fois sur cinq est pire qu'aucun agent — l'utilisateur perd confiance et n'y revient pas. | |
| 55 | + | |
| 56 | +### Le matériel | |
| 57 | + | |
| 58 | +**A17 Pro minimum.** Sur tout appareil antérieur, `SystemLanguageModel.default.availability` renvoie indisponible. | |
| 59 | + | |
| 60 | +Le brief dit d'afficher un message et de s'arrêter. **C'est correct techniquement, mais c'est le plus gros risque commercial du projet** : une part importante du parc iPhone actif ne peut rien faire avec l'app. Décisions obligatoires : | |
| 61 | + | |
| 62 | +- L'incompatibilité doit être annoncée **sur la fiche App Store**, pas découverte au premier lancement. Un téléchargement qui finit sur un mur = une étoile. | |
| 63 | +- L'écran d'incompatibilité est soigné, explique pourquoi, et ne culpabilise pas. Il ne propose **jamais** un LLM de remplacement. | |
| 64 | +- Vérifier aussi les cas non-matériels : Apple Intelligence désactivé dans les réglages, modèle en cours de téléchargement, appareil en mode économie. Ce sont des états distincts, chacun avec son message et son action. | |
| 65 | + | |
| 66 | +```swift | |
| 67 | +switch SystemLanguageModel.default.availability { | |
| 68 | +case .available: // OK | |
| 69 | +case .unavailable(.deviceNotEligible): // mur, définitif | |
| 70 | +case .unavailable(.appleIntelligenceNotEnabled): // action possible : réglages | |
| 71 | +case .unavailable(.modelNotReady): // temporaire : attendre, réessayer | |
| 72 | +@unknown default: // traiter comme indisponible | |
| 73 | +} | |
| 74 | +``` | |
| 75 | + | |
| 76 | +### Les garde-fous | |
| 77 | + | |
| 78 | +Les garde-fous du modèle produisent des faux positifs sur du contenu légitime (améliorés en iOS 26.4, toujours présents). Un refus doit être un état normal de l'interface : message neutre, conversation intacte, possibilité de reformuler. Jamais un écran d'erreur. | |
| 79 | + | |
| 80 | +--- | |
| 81 | + | |
| 82 | +## 3. Architecture | |
| 83 | + | |
| 84 | +``` | |
| 85 | +Poche/ | |
| 86 | +├─ App/ | |
| 87 | +├─ Chat/ | |
| 88 | +│ ├─ UI/ fil de conversation, saisie, streaming | |
| 89 | +│ └─ State/ conversation, tours, états de chargement | |
| 90 | +├─ Agent/ | |
| 91 | +│ ├─ Session/ cycle de vie LanguageModelSession | |
| 92 | +│ ├─ Budget/ tokens, condensation, recyclage ⚠️ cœur du projet | |
| 93 | +│ ├─ Tools/ un fichier par outil | |
| 94 | +│ ├─ Confirm/ validation des actions à effet de bord ⚠️ non contournable | |
| 95 | +│ └─ Schemas/ types @Generable | |
| 96 | +├─ Data/ | |
| 97 | +│ ├─ Store/ SwiftData — notes, tâches, conversations | |
| 98 | +│ ├─ Search/ index sémantique local (NLEmbedding) | |
| 99 | +│ └─ Bridges/ EventKit, Fichiers, App Intents | |
| 100 | +└─ Design/ | |
| 101 | +``` | |
| 102 | + | |
| 103 | +### Le principe non négociable | |
| 104 | + | |
| 105 | +**Le modèle ne fait jamais d'effet de bord. Il propose un appel d'outil ; l'application décide, valide, et exécute.** | |
| 106 | + | |
| 107 | +Chaque outil déclare s'il est en lecture ou en écriture. Toute écriture passe par la couche `Confirm`. Il n'existe aucun chemin de code qui écrit sans passer par là — pas de mode expert, pas de préférence pour désactiver, pas d'exception. | |
| 108 | + | |
| 109 | +--- | |
| 110 | + | |
| 111 | +## 4. Les outils | |
| 112 | + | |
| 113 | +### Règle de cardinalité | |
| 114 | + | |
| 115 | +**Peu d'outils, bien nommés.** Un modèle 3B choisit mal parmi 15 outils. Chaque définition d'outil consomme aussi du contexte en permanence. | |
| 116 | + | |
| 117 | +Plafond dur : **8 outils exposés simultanément.** Si le catalogue grandit, on charge un sous-ensemble selon le sujet de la conversation, on n'élargit pas la liste. | |
| 118 | + | |
| 119 | +### Catalogue v1 | |
| 120 | + | |
| 121 | +| Outil | Type | Bridge | | |
| 122 | +|---|---|---| | |
| 123 | +| `createReminder` | écriture | EventKit | | |
| 124 | +| `createCalendarEvent` | écriture | EventKit | | |
| 125 | +| `searchMyData` | lecture | SwiftData + embeddings | | |
| 126 | +| `saveNote` | écriture | SwiftData | | |
| 127 | +| `createTask` / `updateTask` | écriture | SwiftData | | |
| 128 | +| `getUpcoming` | lecture | EventKit | | |
| 129 | + | |
| 130 | +### Correction importante au brief : les Notes d'Apple | |
| 131 | + | |
| 132 | +**Il n'existe aucune API publique pour lire ou écrire dans l'app Notes d'Apple.** `searchNotes` / `getNote` / `saveNote` tels qu'imaginés dans le brief ne sont pas réalisables contre Notes. | |
| 133 | + | |
| 134 | +Options réelles, à choisir explicitement : | |
| 135 | + | |
| 136 | +1. **Notes internes à Poche** (SwiftData) — recommandé pour la v1. Contrôle total, recherche sémantique possible, cohérent avec le local-first. | |
| 137 | +2. **Share Extension** — l'utilisateur envoie du contenu vers Poche depuis n'importe quelle app, y compris Notes. Entrée seulement. | |
| 138 | +3. **App Intents** — permet à Raccourcis et à Siri d'atteindre Poche, et à Poche d'être orchestrée. Pas un accès à Notes. | |
| 139 | + | |
| 140 | +Ne pas nommer un outil `searchNotes` s'il ne cherche pas dans Notes. Un nom trompeur induit le modèle en erreur autant que l'utilisateur. | |
| 141 | + | |
| 142 | +### Anatomie d'un outil | |
| 143 | + | |
| 144 | +```swift | |
| 145 | +struct CreateReminderTool: Tool { | |
| 146 | + let name = "createReminder" | |
| 147 | + let description = "Crée un rappel avec un titre et une échéance" | |
| 148 | + | |
| 149 | + @Generable | |
| 150 | + struct Arguments { | |
| 151 | + @Guide(description: "Titre du rappel, court et concret") | |
| 152 | + let title: String | |
| 153 | + @Guide(description: "Date et heure ISO 8601") | |
| 154 | + let dueDate: String | |
| 155 | + @Guide(description: "Liste de destination, si précisée") | |
| 156 | + let list: String? | |
| 157 | + } | |
| 158 | + | |
| 159 | + func call(arguments: Arguments) async throws -> String { | |
| 160 | + // 1. VALIDER : date réelle, dans le futur, titre non vide | |
| 161 | + // 2. NE PAS ÉCRIRE. Retourner une proposition en attente. | |
| 162 | + // 3. L'écriture EventKit se fait après confirmation utilisateur, hors du modèle. | |
| 163 | + } | |
| 164 | +} | |
| 165 | +``` | |
| 166 | + | |
| 167 | +Règles : | |
| 168 | + | |
| 169 | +- **Un outil retourne toujours une sortie courte et budgétée.** Un outil qui renvoie 30 événements de calendrier fait exploser le contexte et tue la session. Plafonner à ~200 tokens par retour. | |
| 170 | +- **Toute date est validée par l'application, pas par le modèle.** Le modèle se trompe sur les dates relatives (« mardi prochain », « dans deux semaines »). Résoudre en Swift avec `Date` et le calendrier local, puis afficher la date résolue en clair dans la confirmation. | |
| 171 | +- **Permissions EventKit** : `requestFullAccessToEvents` / `requestFullAccessToReminders` (iOS 17+). Demander au moment du besoin, jamais au lancement. | |
| 172 | +- Aucun outil de suppression en v1. `deleteTask` attendra que la confiance dans l'agent soit établie. | |
| 173 | + | |
| 174 | +--- | |
| 175 | + | |
| 176 | +## 5. Gestion du contexte — le chantier central | |
| 177 | + | |
| 178 | +Sans ça, l'app casse au bout de quelques minutes de conversation. À traiter comme une fonctionnalité, pas comme une correction de bug. | |
| 179 | + | |
| 180 | +Stratégie, dans l'ordre : | |
| 181 | + | |
| 182 | +1. **Mesurer en continu.** `tokenCount(for:)` sur la transcription après chaque tour. Afficher discrètement l'état à l'utilisateur (une jauge fine, pas un chiffre de tokens). | |
| 183 | +2. **À 70 % du budget : condenser.** Résumer les tours anciens en un bloc dense et fidèle, en un appel séparé. Conserver intacts : les instructions, les 3 derniers tours, et tout ce qui a mené à une action confirmée. | |
| 184 | +3. **Recycler la session.** Nouvelle `LanguageModelSession` réamorcée avec instructions + résumé. **L'utilisateur ne doit rien voir.** Pas de « nouvelle conversation », pas de perte visible du fil. | |
| 185 | +4. **Externaliser la mémoire longue.** Ce qui compte durablement (préférences, faits sur l'utilisateur, projets en cours) vit dans SwiftData, pas dans la transcription. Il est réinjecté à la demande via `searchMyData`, jamais gardé en permanence dans le contexte. | |
| 186 | +5. **Filet de sécurité.** Si `exceededContextWindowSize` survient malgré tout : recycler la session, rejouer le dernier message de l'utilisateur, ne jamais afficher d'erreur technique. | |
| 187 | + | |
| 188 | +**Règle** : perdre du contexte est acceptable, perdre le fil visiblement ne l'est pas. | |
| 189 | + | |
| 190 | +--- | |
| 191 | + | |
| 192 | +## 6. Conversation et interface | |
| 193 | + | |
| 194 | +- **Streaming obligatoire** (`streamResponse`). Le premier token doit apparaître en moins de 400 ms. Sur un modèle local, la vitesse perçue est le principal atout face à un chatbot cloud — il faut la rendre visible. | |
| 195 | +- Écran unique, champ de saisie, fil. Aucun onglet, aucun menu de configuration au premier lancement. | |
| 196 | +- **L'agent pose une question de clarification plutôt que de deviner** quand un paramètre d'outil manque. Une question courte, une seule à la fois. | |
| 197 | +- **Ne jamais annoncer une action au passé avant qu'elle soit confirmée et exécutée.** « J'ai créé le rappel » alors que rien n'est créé est la faute la plus destructrice possible pour ce produit. | |
| 198 | +- Dictée : `SFSpeechRecognizer` en mode on-device (`requiresOnDeviceRecognition = true`), sinon la promesse locale est rompue par la porte de derrière. | |
| 199 | + | |
| 200 | +### La confirmation | |
| 201 | + | |
| 202 | +Chaque action à effet de bord affiche une carte : ce qui va être fait, avec les valeurs résolues (date en clair, liste de destination), et deux choix — confirmer ou modifier. | |
| 203 | + | |
| 204 | +Ce n'est pas une friction à minimiser : c'est ce qui rend un agent 3B utilisable. L'utilisateur accepte qu'un modèle se trompe s'il voit la proposition avant. Il n'accepte pas de découvrir 40 rappels erronés. | |
| 205 | + | |
| 206 | +--- | |
| 207 | + | |
| 208 | +## 7. Données et confidentialité | |
| 209 | + | |
| 210 | +- Tout en local : conversations, notes, tâches, préférences, index de recherche. | |
| 211 | +- Recherche sémantique locale via `NLEmbedding` + SwiftData. Pas de service externe, même pour l'indexation. | |
| 212 | +- **Aucune requête réseau liée à l'IA.** À vérifier par un test automatisé : la suite de tests doit échouer si un appel sortant apparaît dans le chemin de l'agent. | |
| 213 | +- Aucune analytique sur le contenu des conversations. Métriques produit uniquement anonymes et agrégées, ou aucune. | |
| 214 | +- Chiffrement au repos via Data Protection. Verrouillage optionnel par Face ID à l'ouverture. | |
| 215 | +- iCloud : optionnel, désactivé par défaut, chiffré. L'utilisateur choisit. | |
| 216 | + | |
| 217 | +--- | |
| 218 | + | |
| 219 | +## 8. Positionnement — attention au message marketing | |
| 220 | + | |
| 221 | +Le brief propose « Powered entirely by Apple » et « No API. No subscription. Just your iPhone. » | |
| 222 | + | |
| 223 | +**Le second est excellent. Le premier est risqué.** Les règles de l'App Store encadrent strictement l'usage de la marque Apple et tout ce qui suggère une approbation ou un partenariat. Une formulation qui laisse croire que l'app est faite ou endossée par Apple peut être refusée en revue. | |
| 224 | + | |
| 225 | +Formulations à préférer : « Fonctionne sur ton iPhone, hors ligne. » / « Aucune API. Aucun abonnement. Aucun serveur. » / « Ton agent, sur ton appareil. » Décrire la capacité, pas l'affiliation. | |
| 226 | + | |
| 227 | +--- | |
| 228 | + | |
| 229 | +## 9. Performance | |
| 230 | + | |
| 231 | +- Premier token en moins de 400 ms. Métrique principale du produit, mesurée à chaque build. | |
| 232 | +- L'inférence est sérialisée sur le Neural Engine : jamais plus d'une requête en vol. File d'attente avec annulation. | |
| 233 | +- Aucune inférence spéculative en arrière-plan. La batterie est le budget le plus précieux d'une app de chat local. | |
| 234 | +- Surveiller `ProcessInfo.thermalState` ; ralentir avant que le système le fasse à notre place. | |
| 235 | + | |
| 236 | +--- | |
| 237 | + | |
| 238 | +## 10. Conventions de code | |
| 239 | + | |
| 240 | +- SwiftUI, `@Observable`, Swift 6, concurrence stricte. | |
| 241 | +- **Jamais de `String` libre en sortie de modèle.** Toujours `@Generable`. Une réponse à parser au regex est un bug. | |
| 242 | +- Un fichier par outil, dans `Agent/Tools`. Chaque outil a son test unitaire avec arguments invalides, manquants et hostiles. | |
| 243 | +- La couche `Confirm` est traversée par toute écriture — aucun bridge n'est appelé directement ailleurs. | |
| 244 | +- Les instructions système vivent dans un fichier versionné, avec leur coût en tokens documenté en commentaire. | |
| 245 | +- Nommage utilisateur en français ; code, commentaires et commits en anglais. | |
| 246 | + | |
| 247 | +--- | |
| 248 | + | |
| 249 | +## 11. Ordre de construction | |
| 250 | + | |
| 251 | +1. **Chat nu.** Session, streaming, gestion d'état, écrans d'indisponibilité. Zéro outil. Si converser n'est pas déjà agréable et rapide, les outils n'y changeront rien. | |
| 252 | +2. **Gestion du budget de contexte.** Avant tout outil. C'est ce qui casse en premier en usage réel. | |
| 253 | +3. **Un seul outil : `createReminder`**, avec sa confirmation. Le faire parfait. Il définit le patron de tous les autres. | |
| 254 | +4. **Stockage local + `searchMyData`.** La mémoire longue. | |
| 255 | +5. **Le reste du catalogue**, un outil à la fois, chacun avec ses tests. | |
| 256 | +6. **Dictée, App Intents, Share Extension.** | |
| 257 | + | |
| 258 | +--- | |
| 259 | + | |
| 260 | +## 12. Risques connus | |
| 261 | + | |
| 262 | +| Risque | Réalité | | |
| 263 | +|---|---| | |
| 264 | +| A17 Pro requis | Risque commercial principal. À annoncer sur la fiche App Store, pas au lancement | | |
| 265 | +| Contexte 4-8K | Casse une app de chat en quelques minutes si ignoré. §5 est prioritaire sur les fonctionnalités | | |
| 266 | +| Fiabilité d'un modèle 3B en agent | La confirmation systématique est ce qui rend le produit viable. Ne jamais la retirer | | |
| 267 | +| Pas d'API pour Notes d'Apple | Corriger le brief : notes internes en v1 | | |
| 268 | +| Faux positifs des garde-fous | Traiter le refus comme un état normal de l'interface | | |
| 269 | +| « Powered by Apple » en marketing | Risque de refus en revue. Décrire la capacité, pas l'affiliation | | |
| 270 | +| Comparaison inévitable avec ChatGPT | Ne jamais se battre sur l'intelligence brute. Se battre sur : instantané, hors ligne, privé, gratuit, agit sur tes vraies données | | |
added
Poche/Agent/Budget/Condenser.swift
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +// | |
| 2 | +// Condenser.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import FoundationModels | |
| 11 | + | |
| 12 | +/// One exchange in the agent's private log, used for condensation and | |
| 13 | +/// session recycling. | |
| 14 | +struct AgentExchange: Sendable { | |
| 15 | + enum Role: String, Sendable { | |
| 16 | + case user | |
| 17 | + case assistant | |
| 18 | + case event | |
| 19 | + } | |
| 20 | + | |
| 21 | + let role: Role | |
| 22 | + let text: String | |
| 23 | +} | |
| 24 | + | |
| 25 | +/// Summarizes old turns in a separate, tool-free session (CLAUDE.md §5). | |
| 26 | +struct Condenser: Sendable { | |
| 27 | + /// Each turn is clipped before summarizing: the condensation prompt must | |
| 28 | + /// itself fit in a fresh window. | |
| 29 | + private static let perTurnClip = 280 | |
| 30 | + | |
| 31 | + func condense(_ exchanges: [AgentExchange]) async throws -> String { | |
| 32 | + let rendered = exchanges.map { exchange in | |
| 33 | + "\(label(for: exchange.role)) : \(String(exchange.text.prefix(Self.perTurnClip)))" | |
| 34 | + } | |
| 35 | + .joined(separator: "\n") | |
| 36 | + | |
| 37 | + let session = LanguageModelSession( | |
| 38 | + model: .default, | |
| 39 | + instructions: "Tu résumes une conversation entre un utilisateur et son assistant. Sois dense, fidèle, factuel." | |
| 40 | + ) | |
| 41 | + let response = try await session.respond( | |
| 42 | + to: "Résume fidèlement cette conversation :\n\n\(rendered)", | |
| 43 | + generating: CondensedSummary.self | |
| 44 | + ) | |
| 45 | + return response.content.summary | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Last-resort summary when the model itself cannot condense | |
| 49 | + /// (e.g. the condensation call is refused). Losing detail is acceptable; | |
| 50 | + /// visibly losing the thread is not. | |
| 51 | + func fallbackSummary(for exchanges: [AgentExchange]) -> String { | |
| 52 | + exchanges | |
| 53 | + .filter { $0.role == .user || $0.role == .event } | |
| 54 | + .suffix(6) | |
| 55 | + .map { "- \(String($0.text.prefix(120)))" } | |
| 56 | + .joined(separator: "\n") | |
| 57 | + } | |
| 58 | + | |
| 59 | + private func label(for role: AgentExchange.Role) -> String { | |
| 60 | + switch role { | |
| 61 | + case .user: "Utilisateur" | |
| 62 | + case .assistant: "Assistant" | |
| 63 | + case .event: "Événement" | |
| 64 | + } | |
| 65 | + } | |
| 66 | +} | |
added
Poche/Agent/Budget/ContextBudget.swift
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +// | |
| 2 | +// ContextBudget.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Token accounting for one session (CLAUDE.md §5 — the central concern). | |
| 12 | +/// | |
| 13 | +/// The window holds everything at once: instructions, tool schemas, the | |
| 14 | +/// whole transcript, and the response to come. At 70% we condense; at least | |
| 15 | +/// 30% stays reserved for the response — a prompt that "fits" with no room | |
| 16 | +/// to answer still fails. | |
| 17 | +struct ContextBudget: Sendable { | |
| 18 | + /// iOS 26 window. 8192 on newer OS/devices; keep the pessimistic value | |
| 19 | + /// until the SDK exposes the real one. | |
| 20 | + var windowSize = 4096 | |
| 21 | + var responseReserveRatio = 0.30 | |
| 22 | + var condenseThresholdRatio = 0.70 | |
| 23 | + | |
| 24 | + var responseReserve: Int { | |
| 25 | + Int(Double(windowSize) * responseReserveRatio) | |
| 26 | + } | |
| 27 | + | |
| 28 | + var condenseThreshold: Int { | |
| 29 | + Int(Double(windowSize) * condenseThresholdRatio) | |
| 30 | + } | |
| 31 | + | |
| 32 | + /// Largest prompt (fixed cost + transcript + new message) we allow. | |
| 33 | + var sendableLimit: Int { | |
| 34 | + windowSize - responseReserve | |
| 35 | + } | |
| 36 | + | |
| 37 | + func needsCondensation(estimatedTokens: Int) -> Bool { | |
| 38 | + estimatedTokens >= condenseThreshold | |
| 39 | + } | |
| 40 | + | |
| 41 | + func canSend(estimatedTokens: Int) -> Bool { | |
| 42 | + estimatedTokens < sendableLimit | |
| 43 | + } | |
| 44 | + | |
| 45 | + func usageRatio(estimatedTokens: Int) -> Double { | |
| 46 | + min(1, Double(estimatedTokens) / Double(windowSize)) | |
| 47 | + } | |
| 48 | +} | |
added
Poche/Agent/Budget/TokenEstimator.swift
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +// | |
| 2 | +// TokenEstimator.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Conservative token estimation. | |
| 12 | +/// | |
| 13 | +/// Constraint: the iOS 26 SDK exposes no public token-count API for the | |
| 14 | +/// on-device model, so the preflight described in CLAUDE.md §2 cannot call | |
| 15 | +/// `tokenCount(for:)` yet. This estimator deliberately over-counts | |
| 16 | +/// (~3 characters per token, French text averages closer to 3.5–4) so the | |
| 17 | +/// budget errs toward condensing early rather than hitting | |
| 18 | +/// `exceededContextWindowSize`. Replace with the system API the day Apple | |
| 19 | +/// ships one. | |
| 20 | +enum TokenEstimator { | |
| 21 | + static func tokens(in text: String) -> Int { | |
| 22 | + max(1, text.count / 3) | |
| 23 | + } | |
| 24 | + | |
| 25 | + /// Flat per-tool overhead: name + description + generated argument schema | |
| 26 | + /// all live in the context window for the whole session. | |
| 27 | + static let perToolOverhead = 120 | |
| 28 | +} | |
added
Poche/Agent/Confirm/ActionExecutor.swift
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +// | |
| 2 | +// ActionExecutor.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Performs confirmed side effects against the bridges. Constraint: only | |
| 12 | +/// ConfirmCenter may call this type — tools and views never touch bridges | |
| 13 | +/// or the store's write API directly (CLAUDE.md §3, §10). | |
| 14 | +@MainActor | |
| 15 | +struct ActionExecutor { | |
| 16 | + let bridge: EventKitBridge | |
| 17 | + let store: PocheStore | |
| 18 | + | |
| 19 | + /// Returns a short, truthful past-tense summary — safe to show because | |
| 20 | + /// by the time it exists, the write has actually happened. | |
| 21 | + func execute(_ payload: PendingAction.Payload) async throws -> String { | |
| 22 | + switch payload { | |
| 23 | + case .reminder(let draft): | |
| 24 | + try await bridge.createReminder(draft) | |
| 25 | + return "Rappel « \(draft.title) » créé pour \(DateResolver.display(draft.due))" | |
| 26 | + | |
| 27 | + case .event(let draft): | |
| 28 | + try await bridge.createEvent(draft) | |
| 29 | + return "Événement « \(draft.title) » créé le \(DateResolver.display(draft.start))" | |
| 30 | + | |
| 31 | + case .note(let draft): | |
| 32 | + store.addNote(title: draft.title, content: draft.content) | |
| 33 | + return "Note « \(draft.title) » enregistrée" | |
| 34 | + | |
| 35 | + case .newTask(let draft): | |
| 36 | + store.addTask(title: draft.title, details: draft.details, due: draft.due) | |
| 37 | + return "Tâche « \(draft.title) » créée" | |
| 38 | + | |
| 39 | + case .taskUpdate(let draft): | |
| 40 | + try store.updateTask( | |
| 41 | + uuid: draft.taskID, | |
| 42 | + newTitle: draft.newTitle, | |
| 43 | + newDue: draft.newDue, | |
| 44 | + markDone: draft.markDone | |
| 45 | + ) | |
| 46 | + return "Tâche « \(draft.newTitle ?? draft.currentTitle) » mise à jour" | |
| 47 | + } | |
| 48 | + } | |
| 49 | +} | |
added
Poche/Agent/Confirm/ConfirmCenter.swift
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +// | |
| 2 | +// ConfirmCenter.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Observation | |
| 11 | + | |
| 12 | +/// The only gate to side effects. Tools enqueue proposals here; nothing is | |
| 13 | +/// written until the user confirms on the card. There is no code path that | |
| 14 | +/// writes without crossing this layer — no expert mode, no preference to | |
| 15 | +/// disable it, no exception (CLAUDE.md §3). | |
| 16 | +@MainActor | |
| 17 | +@Observable | |
| 18 | +final class ConfirmCenter { | |
| 19 | + private(set) var pending: [PendingAction] = [] | |
| 20 | + | |
| 21 | + private let executor: ActionExecutor | |
| 22 | + | |
| 23 | + var onExecuted: ((String) -> Void)? | |
| 24 | + var onDismissed: ((String) -> Void)? | |
| 25 | + | |
| 26 | + init(executor: ActionExecutor) { | |
| 27 | + self.executor = executor | |
| 28 | + } | |
| 29 | + | |
| 30 | + /// Called by tools. Registers the proposal; the UI shows the card. | |
| 31 | + func propose(_ action: PendingAction) { | |
| 32 | + pending.append(action) | |
| 33 | + } | |
| 34 | + | |
| 35 | + /// Called by the confirmation card only — this is the user's decision. | |
| 36 | + func confirm(_ action: PendingAction) async { | |
| 37 | + guard let index = pending.firstIndex(where: { $0.id == action.id }) else { return } | |
| 38 | + pending[index].status = .executing | |
| 39 | + do { | |
| 40 | + let summary = try await executor.execute(action.payload) | |
| 41 | + pending.removeAll { $0.id == action.id } | |
| 42 | + onExecuted?(summary) | |
| 43 | + } catch { | |
| 44 | + if let idx = pending.firstIndex(where: { $0.id == action.id }) { | |
| 45 | + pending[idx].status = .failed(userMessage(for: error)) | |
| 46 | + } | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + func dismiss(_ action: PendingAction) { | |
| 51 | + guard pending.contains(where: { $0.id == action.id }) else { return } | |
| 52 | + pending.removeAll { $0.id == action.id } | |
| 53 | + onDismissed?(action.summary) | |
| 54 | + } | |
| 55 | + | |
| 56 | + private func userMessage(for error: any Error) -> String { | |
| 57 | + if let bridgeError = error as? BridgeError { | |
| 58 | + return bridgeError.userMessage | |
| 59 | + } | |
| 60 | + return "L'action n'a pas pu être exécutée. Réessaie." | |
| 61 | + } | |
| 62 | +} | |
added
Poche/Agent/Confirm/ConfirmationCardView.swift
+116 −0
@@ -0,0 +1,116 @@ | ||
| 1 | +// | |
| 2 | +// ConfirmationCardView.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// The confirmation card: what will be done, with resolved values in clear | |
| 12 | +/// text, and two choices. This friction is what makes a 3B agent usable — | |
| 13 | +/// never minimize it away (CLAUDE.md §6). | |
| 14 | +struct ConfirmationCardView: View { | |
| 15 | + let action: PendingAction | |
| 16 | + let onConfirm: () -> Void | |
| 17 | + let onModify: () -> Void | |
| 18 | + | |
| 19 | + var body: some View { | |
| 20 | + VStack(alignment: .leading, spacing: Theme.spacingMedium) { | |
| 21 | + HStack(spacing: Theme.spacingMedium) { | |
| 22 | + RoundedRectangle(cornerRadius: 10) | |
| 23 | + .fill(Theme.accentGradient) | |
| 24 | + .frame(width: 36, height: 36) | |
| 25 | + .overlay { | |
| 26 | + Image(systemName: iconName) | |
| 27 | + .font(.system(size: 16, weight: .semibold)) | |
| 28 | + .foregroundStyle(.white) | |
| 29 | + } | |
| 30 | + | |
| 31 | + VStack(alignment: .leading, spacing: 1) { | |
| 32 | + Text(action.title) | |
| 33 | + .font(.headline) | |
| 34 | + Text("En attente de ta confirmation") | |
| 35 | + .font(.caption) | |
| 36 | + .foregroundStyle(.secondary) | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + VStack(alignment: .leading, spacing: Theme.spacingSmall + 2) { | |
| 41 | + ForEach(action.fields, id: \.label) { field in | |
| 42 | + HStack(alignment: .firstTextBaseline, spacing: Theme.spacingMedium) { | |
| 43 | + Text(field.label) | |
| 44 | + .font(.subheadline) | |
| 45 | + .foregroundStyle(.secondary) | |
| 46 | + .frame(width: 90, alignment: .leading) | |
| 47 | + Text(field.value) | |
| 48 | + .font(.subheadline.weight(.medium)) | |
| 49 | + } | |
| 50 | + } | |
| 51 | + } | |
| 52 | + .padding(Theme.spacingMedium) | |
| 53 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 54 | + .background(Theme.background.opacity(0.6)) | |
| 55 | + .clipShape(RoundedRectangle(cornerRadius: Theme.cardCornerRadius - 8)) | |
| 56 | + | |
| 57 | + if case .failed(let message) = action.status { | |
| 58 | + Label(message, systemImage: "exclamationmark.triangle.fill") | |
| 59 | + .font(.footnote) | |
| 60 | + .foregroundStyle(.red) | |
| 61 | + } | |
| 62 | + | |
| 63 | + HStack(spacing: Theme.spacingMedium) { | |
| 64 | + Button(action: onConfirm) { | |
| 65 | + Group { | |
| 66 | + if action.status == .executing { | |
| 67 | + ProgressView() | |
| 68 | + .controlSize(.small) | |
| 69 | + .tint(.white) | |
| 70 | + } else { | |
| 71 | + Text("Confirmer") | |
| 72 | + .font(.subheadline.weight(.semibold)) | |
| 73 | + } | |
| 74 | + } | |
| 75 | + .foregroundStyle(.white) | |
| 76 | + .frame(maxWidth: .infinity) | |
| 77 | + .padding(.vertical, Theme.spacingMedium - 2) | |
| 78 | + .background(Theme.accentGradient) | |
| 79 | + .clipShape(Capsule()) | |
| 80 | + } | |
| 81 | + .buttonStyle(.plain) | |
| 82 | + .disabled(action.status == .executing) | |
| 83 | + | |
| 84 | + Button(action: onModify) { | |
| 85 | + Text("Modifier") | |
| 86 | + .font(.subheadline.weight(.semibold)) | |
| 87 | + .foregroundStyle(.primary) | |
| 88 | + .frame(maxWidth: .infinity) | |
| 89 | + .padding(.vertical, Theme.spacingMedium - 2) | |
| 90 | + .background(Theme.background) | |
| 91 | + .clipShape(Capsule()) | |
| 92 | + } | |
| 93 | + .buttonStyle(.plain) | |
| 94 | + .disabled(action.status == .executing) | |
| 95 | + } | |
| 96 | + } | |
| 97 | + .padding(Theme.spacingMedium + 2) | |
| 98 | + .background(Theme.surface) | |
| 99 | + .clipShape(RoundedRectangle(cornerRadius: Theme.cardCornerRadius)) | |
| 100 | + .overlay { | |
| 101 | + RoundedRectangle(cornerRadius: Theme.cardCornerRadius) | |
| 102 | + .strokeBorder(Theme.accentA.opacity(0.25)) | |
| 103 | + } | |
| 104 | + .shadow(color: .black.opacity(0.10), radius: 14, y: 6) | |
| 105 | + } | |
| 106 | + | |
| 107 | + private var iconName: String { | |
| 108 | + switch action.payload { | |
| 109 | + case .reminder: "bell.fill" | |
| 110 | + case .event: "calendar" | |
| 111 | + case .note: "note.text" | |
| 112 | + case .newTask: "checklist" | |
| 113 | + case .taskUpdate: "pencil" | |
| 114 | + } | |
| 115 | + } | |
| 116 | +} | |
added
Poche/Agent/Confirm/DateResolver.swift
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +// | |
| 2 | +// DateResolver.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Dates are validated by the app, never trusted from the model | |
| 12 | +/// (CLAUDE.md §4). The model supplies ISO 8601; Swift resolves and the | |
| 13 | +/// resolved date is displayed in clear text on the confirmation card. | |
| 14 | +enum DateResolutionError: LocalizedError { | |
| 15 | + case unparseable(String) | |
| 16 | + case inPast(Date) | |
| 17 | + case unreasonablyFar(Date) | |
| 18 | + | |
| 19 | + var errorDescription: String? { | |
| 20 | + switch self { | |
| 21 | + case .unparseable(let raw): | |
| 22 | + "la date « \(raw) » n'est pas au format ISO 8601. Redemande la date à l'utilisateur si nécessaire." | |
| 23 | + case .inPast: | |
| 24 | + "cette date est déjà passée. Demande à l'utilisateur la bonne date." | |
| 25 | + case .unreasonablyFar: | |
| 26 | + "cette date est à plus de cinq ans. Vérifie avec l'utilisateur." | |
| 27 | + } | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +enum DateResolver { | |
| 32 | + private static let maxHorizon: TimeInterval = 5 * 365.25 * 24 * 3600 | |
| 33 | + | |
| 34 | + /// Accepts "2026-08-12T09:00:00" (with or without seconds/timezone) and | |
| 35 | + /// bare dates "2026-08-12" (resolved to 09:00 local — always shown on | |
| 36 | + /// the confirmation card before anything is written). | |
| 37 | + static func resolve(iso raw: String, now: Date = .now, calendar: Calendar = .current) throws -> Date { | |
| 38 | + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 39 | + guard let date = parse(trimmed, calendar: calendar) else { | |
| 40 | + throw DateResolutionError.unparseable(trimmed) | |
| 41 | + } | |
| 42 | + guard date > now else { | |
| 43 | + throw DateResolutionError.inPast(date) | |
| 44 | + } | |
| 45 | + guard date < now.addingTimeInterval(maxHorizon) else { | |
| 46 | + throw DateResolutionError.unreasonablyFar(date) | |
| 47 | + } | |
| 48 | + return date | |
| 49 | + } | |
| 50 | + | |
| 51 | + static func display(_ date: Date) -> String { | |
| 52 | + date.formatted(date: .complete, time: .shortened) | |
| 53 | + } | |
| 54 | + | |
| 55 | + private static func parse(_ text: String, calendar: Calendar) -> Date? { | |
| 56 | + let withTZ = ISO8601DateFormatter() | |
| 57 | + withTZ.formatOptions = [.withInternetDateTime] | |
| 58 | + if let date = withTZ.date(from: text) { return date } | |
| 59 | + | |
| 60 | + // Local wall-clock time, no timezone suffix — the common model output. | |
| 61 | + for format in ["yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm"] { | |
| 62 | + let formatter = DateFormatter() | |
| 63 | + formatter.locale = Locale(identifier: "en_US_POSIX") | |
| 64 | + formatter.calendar = calendar | |
| 65 | + formatter.timeZone = calendar.timeZone | |
| 66 | + formatter.dateFormat = format | |
| 67 | + if let date = formatter.date(from: text) { return date } | |
| 68 | + } | |
| 69 | + | |
| 70 | + // Bare date: default to 09:00 local. | |
| 71 | + let dateOnly = DateFormatter() | |
| 72 | + dateOnly.locale = Locale(identifier: "en_US_POSIX") | |
| 73 | + dateOnly.calendar = calendar | |
| 74 | + dateOnly.timeZone = calendar.timeZone | |
| 75 | + dateOnly.dateFormat = "yyyy-MM-dd" | |
| 76 | + if let day = dateOnly.date(from: text) { | |
| 77 | + return calendar.date(bySettingHour: 9, minute: 0, second: 0, of: day) | |
| 78 | + } | |
| 79 | + return nil | |
| 80 | + } | |
| 81 | +} | |
added
Poche/Agent/Confirm/PendingAction.swift
+118 −0
@@ -0,0 +1,118 @@ | ||
| 1 | +// | |
| 2 | +// PendingAction.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// A side-effect proposed by the model, waiting for the user's explicit | |
| 12 | +/// confirmation. The model never writes; it proposes, the app decides | |
| 13 | +/// (CLAUDE.md §3 — the non-negotiable principle). | |
| 14 | +struct PendingAction: Identifiable, Sendable { | |
| 15 | + enum Status: Sendable, Equatable { | |
| 16 | + case waiting | |
| 17 | + case executing | |
| 18 | + case failed(String) | |
| 19 | + } | |
| 20 | + | |
| 21 | + enum Payload: Sendable { | |
| 22 | + case reminder(ReminderDraft) | |
| 23 | + case event(EventDraft) | |
| 24 | + case note(NoteDraft) | |
| 25 | + case newTask(TaskDraft) | |
| 26 | + case taskUpdate(TaskUpdateDraft) | |
| 27 | + } | |
| 28 | + | |
| 29 | + let id = UUID() | |
| 30 | + let payload: Payload | |
| 31 | + var status: Status = .waiting | |
| 32 | + | |
| 33 | + /// Card headline — always future tense: nothing has happened yet. | |
| 34 | + var title: String { | |
| 35 | + switch payload { | |
| 36 | + case .reminder: "Créer un rappel" | |
| 37 | + case .event: "Créer un événement" | |
| 38 | + case .note: "Enregistrer une note" | |
| 39 | + case .newTask: "Créer une tâche" | |
| 40 | + case .taskUpdate: "Modifier une tâche" | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + /// Resolved values shown in clear text (dates already validated). | |
| 45 | + var fields: [(label: String, value: String)] { | |
| 46 | + switch payload { | |
| 47 | + case .reminder(let draft): | |
| 48 | + [("Titre", draft.title), | |
| 49 | + ("Échéance", DateResolver.display(draft.due))] | |
| 50 | + + (draft.list.map { [("Liste", $0)] } ?? []) | |
| 51 | + case .event(let draft): | |
| 52 | + [("Titre", draft.title), | |
| 53 | + ("Début", DateResolver.display(draft.start)), | |
| 54 | + ("Fin", DateResolver.display(draft.end))] | |
| 55 | + + (draft.location.map { [("Lieu", $0)] } ?? []) | |
| 56 | + case .note(let draft): | |
| 57 | + [("Titre", draft.title), | |
| 58 | + ("Contenu", String(draft.content.prefix(140)))] | |
| 59 | + case .newTask(let draft): | |
| 60 | + [("Titre", draft.title)] | |
| 61 | + + (draft.details.map { [("Détails", String($0.prefix(140)))] } ?? []) | |
| 62 | + + (draft.due.map { [("Échéance", DateResolver.display($0))] } ?? []) | |
| 63 | + case .taskUpdate(let draft): | |
| 64 | + [("Tâche", draft.currentTitle)] | |
| 65 | + + (draft.newTitle.map { [("Nouveau titre", $0)] } ?? []) | |
| 66 | + + (draft.newDue.map { [("Nouvelle échéance", DateResolver.display($0))] } ?? []) | |
| 67 | + + (draft.markDone == true ? [("État", "Marquer comme faite")] : []) | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// One-line summary used for system notices and agent notes. | |
| 72 | + var summary: String { | |
| 73 | + switch payload { | |
| 74 | + case .reminder(let draft): | |
| 75 | + "Rappel « \(draft.title) » pour \(DateResolver.display(draft.due))" | |
| 76 | + case .event(let draft): | |
| 77 | + "Événement « \(draft.title) » le \(DateResolver.display(draft.start))" | |
| 78 | + case .note(let draft): | |
| 79 | + "Note « \(draft.title) »" | |
| 80 | + case .newTask(let draft): | |
| 81 | + "Tâche « \(draft.title) »" | |
| 82 | + case .taskUpdate(let draft): | |
| 83 | + "Modification de la tâche « \(draft.currentTitle) »" | |
| 84 | + } | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +struct ReminderDraft: Sendable { | |
| 89 | + let title: String | |
| 90 | + let due: Date | |
| 91 | + let list: String? | |
| 92 | +} | |
| 93 | + | |
| 94 | +struct EventDraft: Sendable { | |
| 95 | + let title: String | |
| 96 | + let start: Date | |
| 97 | + let end: Date | |
| 98 | + let location: String? | |
| 99 | +} | |
| 100 | + | |
| 101 | +struct NoteDraft: Sendable { | |
| 102 | + let title: String | |
| 103 | + let content: String | |
| 104 | +} | |
| 105 | + | |
| 106 | +struct TaskDraft: Sendable { | |
| 107 | + let title: String | |
| 108 | + let details: String? | |
| 109 | + let due: Date? | |
| 110 | +} | |
| 111 | + | |
| 112 | +struct TaskUpdateDraft: Sendable { | |
| 113 | + let taskID: UUID | |
| 114 | + let currentTitle: String | |
| 115 | + let newTitle: String? | |
| 116 | + let newDue: Date? | |
| 117 | + let markDone: Bool? | |
| 118 | +} | |
added
Poche/Agent/Schemas/CondensedSummary.swift
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +// | |
| 2 | +// CondensedSummary.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import FoundationModels | |
| 10 | + | |
| 11 | +/// Structured output for conversation condensation. Never a free string | |
| 12 | +/// to parse (CLAUDE.md §10). | |
| 13 | +@Generable | |
| 14 | +struct CondensedSummary { | |
| 15 | + @Guide(description: "Résumé dense et fidèle de la conversation, en français, 150 mots maximum. Conserve les faits, décisions, dates et actions confirmées. Aucune invention.") | |
| 16 | + let summary: String | |
| 17 | +} | |
added
Poche/Agent/Session/AgentSession.swift
+225 −0
@@ -0,0 +1,225 @@ | ||
| 1 | +// | |
| 2 | +// AgentSession.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import FoundationModels | |
| 11 | +import Observation | |
| 12 | + | |
| 13 | +/// Owns the LanguageModelSession lifecycle: preflight, streaming, | |
| 14 | +/// condensation, invisible recycling, refusal handling. | |
| 15 | +/// | |
| 16 | +/// Rules enforced here (CLAUDE.md §5, §9): | |
| 17 | +/// - never more than one request in flight (Neural Engine is serialized); | |
| 18 | +/// - condense at 70% of the window, keep the last turns intact; | |
| 19 | +/// - recycling is invisible — the user never sees a "new conversation"; | |
| 20 | +/// - `exceededContextWindowSize` recycles and replays, never surfaces; | |
| 21 | +/// - a guardrail refusal is a normal outcome, never an error screen. | |
| 22 | +@MainActor | |
| 23 | +@Observable | |
| 24 | +final class AgentSession { | |
| 25 | + enum Outcome: Sendable { | |
| 26 | + case completed(String) | |
| 27 | + case refused(String) | |
| 28 | + case failed(String) | |
| 29 | + } | |
| 30 | + | |
| 31 | + private(set) var isResponding = false | |
| 32 | + private(set) var usageRatio: Double = 0 | |
| 33 | + | |
| 34 | + /// Fired after each condensation so the summary can be persisted as | |
| 35 | + /// long-term memory (CLAUDE.md §5). | |
| 36 | + var onSummary: ((String) -> Void)? | |
| 37 | + | |
| 38 | + private let budget = ContextBudget() | |
| 39 | + private let condenser = Condenser() | |
| 40 | + private let thermal = ThermalMonitor() | |
| 41 | + private let tools: [any Tool] | |
| 42 | + | |
| 43 | + private var session: LanguageModelSession | |
| 44 | + /// Private mirror of the transcript, used for token accounting, | |
| 45 | + /// condensation and recycling. Display state lives in ChatViewModel. | |
| 46 | + private var log: [AgentExchange] = [] | |
| 47 | + private var carriedSummary: String? | |
| 48 | + /// App events (confirmed/cancelled actions) injected into the next turn | |
| 49 | + /// so the model knows what actually happened. | |
| 50 | + private var pendingNotes: [String] = [] | |
| 51 | + | |
| 52 | + /// Instructions + tool schemas: paid on every single call. | |
| 53 | + private let fixedCost: Int | |
| 54 | + | |
| 55 | + /// Repeated failures mean the system inference layer is down (e.g. the | |
| 56 | + /// simulator without model assets) — after two in a row, say so honestly | |
| 57 | + /// instead of an eternal "try again". | |
| 58 | + private var consecutiveFailures = 0 | |
| 59 | + | |
| 60 | + /// How many recent exchanges survive a recycle verbatim | |
| 61 | + /// (3 user/assistant turns ≈ 6 entries; CLAUDE.md §5). | |
| 62 | + private static let keptVerbatim = 6 | |
| 63 | + | |
| 64 | + init(tools: [any Tool]) { | |
| 65 | + self.tools = tools | |
| 66 | + self.fixedCost = TokenEstimator.tokens(in: SystemInstructions.current) | |
| 67 | + + tools.count * TokenEstimator.perToolOverhead | |
| 68 | + self.session = Self.makeSession(tools: tools, summary: nil, recent: []) | |
| 69 | + session.prewarm() | |
| 70 | + } | |
| 71 | + | |
| 72 | + func respond( | |
| 73 | + to userText: String, | |
| 74 | + onPartial: @escaping @MainActor (String) -> Void | |
| 75 | + ) async -> Outcome { | |
| 76 | + guard !isResponding, !session.isResponding else { | |
| 77 | + return .failed("Je réponds déjà — un instant.") | |
| 78 | + } | |
| 79 | + if thermal.shouldPauseInference { | |
| 80 | + return .failed("Ton iPhone chauffe beaucoup. Je fais une courte pause, réessaie dans un moment.") | |
| 81 | + } | |
| 82 | + | |
| 83 | + isResponding = true | |
| 84 | + defer { isResponding = false } | |
| 85 | + | |
| 86 | + let prompt = composePrompt(with: userText) | |
| 87 | + | |
| 88 | + // Preflight (CLAUDE.md §2): condense before the window overflows, | |
| 89 | + // and always keep room for the response. | |
| 90 | + let projected = estimatedTokens + TokenEstimator.tokens(in: prompt) | |
| 91 | + if budget.needsCondensation(estimatedTokens: projected) || !budget.canSend(estimatedTokens: projected) { | |
| 92 | + await recycle() | |
| 93 | + } | |
| 94 | + | |
| 95 | + return await performTurn(prompt: prompt, userText: userText, onPartial: onPartial, isRetry: false) | |
| 96 | + } | |
| 97 | + | |
| 98 | + /// Records an app-side event (confirmation, cancellation) to surface to | |
| 99 | + /// the model on its next turn instead of letting it trust its proposal. | |
| 100 | + func noteEvent(_ text: String) { | |
| 101 | + pendingNotes.append(text) | |
| 102 | + log.append(AgentExchange(role: .event, text: text)) | |
| 103 | + refreshUsage() | |
| 104 | + } | |
| 105 | + | |
| 106 | + // MARK: - Turn execution | |
| 107 | + | |
| 108 | + private func performTurn( | |
| 109 | + prompt: String, | |
| 110 | + userText: String, | |
| 111 | + onPartial: @escaping @MainActor (String) -> Void, | |
| 112 | + isRetry: Bool | |
| 113 | + ) async -> Outcome { | |
| 114 | + do { | |
| 115 | + var latest = "" | |
| 116 | + let stream = session.streamResponse(to: prompt) | |
| 117 | + for try await snapshot in stream { | |
| 118 | + latest = snapshot.content | |
| 119 | + onPartial(latest) | |
| 120 | + } | |
| 121 | + | |
| 122 | + pendingNotes.removeAll() | |
| 123 | + consecutiveFailures = 0 | |
| 124 | + log.append(AgentExchange(role: .user, text: userText)) | |
| 125 | + log.append(AgentExchange(role: .assistant, text: latest)) | |
| 126 | + refreshUsage() | |
| 127 | + return .completed(latest) | |
| 128 | + } catch let error as LanguageModelSession.GenerationError { | |
| 129 | + #if DEBUG | |
| 130 | + print("AgentSession generation error:", error) | |
| 131 | + #endif | |
| 132 | + switch error { | |
| 133 | + case .guardrailViolation, .refusal: | |
| 134 | + // Normal interface state: neutral message, thread intact. | |
| 135 | + return .refused("Je ne peux pas répondre à ça tel quel. Reformule autrement et on continue.") | |
| 136 | + case .exceededContextWindowSize: | |
| 137 | + // Safety net (CLAUDE.md §5): recycle, replay, never surface. | |
| 138 | + guard !isRetry else { | |
| 139 | + return .failed("Je n’arrive pas à reprendre le fil. Reformule ton dernier message.") | |
| 140 | + } | |
| 141 | + await recycle() | |
| 142 | + return await performTurn(prompt: prompt, userText: userText, onPartial: onPartial, isRetry: true) | |
| 143 | + case .assetsUnavailable: | |
| 144 | + return .failed("Le modèle n’est pas prêt sur cet appareil. Vérifie qu’Apple Intelligence est activé, puis réessaie dans un moment.") | |
| 145 | + default: | |
| 146 | + return .failed(genericFailureMessage()) | |
| 147 | + } | |
| 148 | + } catch { | |
| 149 | + #if DEBUG | |
| 150 | + print("AgentSession unexpected error:", error) | |
| 151 | + #endif | |
| 152 | + return .failed(genericFailureMessage()) | |
| 153 | + } | |
| 154 | + } | |
| 155 | + | |
| 156 | + private func genericFailureMessage() -> String { | |
| 157 | + consecutiveFailures += 1 | |
| 158 | + if consecutiveFailures >= 2 { | |
| 159 | + return "Le modèle de cet appareil ne répond pas. Dans le simulateur, Apple Intelligence est souvent indisponible — sur un iPhone compatible (15 Pro ou plus récent), tout fonctionne." | |
| 160 | + } | |
| 161 | + return "Un pépin de mon côté. Réessaie dans un instant." | |
| 162 | + } | |
| 163 | + | |
| 164 | + private func composePrompt(with userText: String) -> String { | |
| 165 | + guard !pendingNotes.isEmpty else { return userText } | |
| 166 | + let notes = pendingNotes.map { "[\($0)]" }.joined(separator: "\n") | |
| 167 | + return notes + "\n" + userText | |
| 168 | + } | |
| 169 | + | |
| 170 | + // MARK: - Condensation & recycling (invisible to the user) | |
| 171 | + | |
| 172 | + private func recycle() async { | |
| 173 | + let recent = Array(log.suffix(Self.keptVerbatim)) | |
| 174 | + let old = Array(log.dropLast(Self.keptVerbatim)) | |
| 175 | + | |
| 176 | + var summary = carriedSummary ?? "" | |
| 177 | + if !old.isEmpty { | |
| 178 | + do { | |
| 179 | + summary = try await condenser.condense(old) | |
| 180 | + } catch { | |
| 181 | + summary = condenser.fallbackSummary(for: old) | |
| 182 | + } | |
| 183 | + } | |
| 184 | + | |
| 185 | + carriedSummary = summary.isEmpty ? nil : summary | |
| 186 | + if let carriedSummary { | |
| 187 | + onSummary?(carriedSummary) | |
| 188 | + } | |
| 189 | + log = recent | |
| 190 | + session = Self.makeSession(tools: tools, summary: carriedSummary, recent: recent) | |
| 191 | + session.prewarm() | |
| 192 | + refreshUsage() | |
| 193 | + } | |
| 194 | + | |
| 195 | + private static func makeSession( | |
| 196 | + tools: [any Tool], | |
| 197 | + summary: String?, | |
| 198 | + recent: [AgentExchange] | |
| 199 | + ) -> LanguageModelSession { | |
| 200 | + var instructions = SystemInstructions.current | |
| 201 | + if let summary, !summary.isEmpty { | |
| 202 | + instructions += "\n\nRésumé fidèle de la conversation jusqu'ici :\n\(summary)" | |
| 203 | + } | |
| 204 | + if !recent.isEmpty { | |
| 205 | + let rendered = recent | |
| 206 | + .map { "\($0.role == .user ? "Utilisateur" : $0.role == .assistant ? "Assistant" : "Événement") : \(String($0.text.prefix(280)))" } | |
| 207 | + .joined(separator: "\n") | |
| 208 | + instructions += "\n\nDerniers échanges :\n\(rendered)" | |
| 209 | + } | |
| 210 | + return LanguageModelSession(model: .default, tools: tools, instructions: instructions) | |
| 211 | + } | |
| 212 | + | |
| 213 | + // MARK: - Accounting | |
| 214 | + | |
| 215 | + private var estimatedTokens: Int { | |
| 216 | + let logCost = log.reduce(0) { $0 + TokenEstimator.tokens(in: $1.text) } | |
| 217 | + let summaryCost = carriedSummary.map { TokenEstimator.tokens(in: $0) } ?? 0 | |
| 218 | + let notesCost = pendingNotes.reduce(0) { $0 + TokenEstimator.tokens(in: $1) } | |
| 219 | + return fixedCost + logCost + summaryCost + notesCost | |
| 220 | + } | |
| 221 | + | |
| 222 | + private func refreshUsage() { | |
| 223 | + usageRatio = budget.usageRatio(estimatedTokens: estimatedTokens) | |
| 224 | + } | |
| 225 | +} | |
added
Poche/Agent/Session/SystemInstructions.swift
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +// | |
| 2 | +// SystemInstructions.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Versioned system instructions (CLAUDE.md §10). | |
| 12 | +/// Estimated cost: ~230 tokens (TokenEstimator heuristic, measured 2026-08-11). | |
| 13 | +/// Instructions sit permanently in the 4096-token window — re-measure after | |
| 14 | +/// every edit. | |
| 15 | +enum SystemInstructions { | |
| 16 | + static let v1 = """ | |
| 17 | + Tu es Poche, un assistant personnel qui vit entièrement sur l'iPhone de l'utilisateur, hors ligne. | |
| 18 | + Réponds en français, bref et concret. | |
| 19 | + Utilise un outil quand l'utilisateur veut créer un rappel, un événement, une note ou une tâche, retrouver ses données, ou voir ce qui arrive. | |
| 20 | + Chaque écriture n'est qu'une proposition : l'utilisateur la confirme dans l'app. Ne dis JAMAIS qu'une action est faite tant qu'elle n'est pas confirmée. Dis « je te propose », jamais « j'ai créé ». | |
| 21 | + S'il manque une information nécessaire (titre, date), pose UNE question courte au lieu de deviner. | |
| 22 | + Fournis toujours les dates aux outils au format ISO 8601 (exemple : 2026-08-12T09:00:00). | |
| 23 | + """ | |
| 24 | + | |
| 25 | + static var current: String { v1 } | |
| 26 | +} | |
added
Poche/Agent/Tools/CreateCalendarEventTool.swift
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +// | |
| 2 | +// CreateCalendarEventTool.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import FoundationModels | |
| 11 | + | |
| 12 | +struct CreateCalendarEventTool: Tool { | |
| 13 | + let name = "createCalendarEvent" | |
| 14 | + let description = "Propose la création d'un événement de calendrier. L'utilisateur devra confirmer." | |
| 15 | + | |
| 16 | + @Generable | |
| 17 | + struct Arguments { | |
| 18 | + @Guide(description: "Titre de l'événement, court et concret") | |
| 19 | + let title: String | |
| 20 | + @Guide(description: "Début au format ISO 8601, par exemple 2026-08-12T14:00:00") | |
| 21 | + let startDate: String | |
| 22 | + @Guide(description: "Fin au format ISO 8601, seulement si précisée (sinon une heure après le début)") | |
| 23 | + let endDate: String? | |
| 24 | + @Guide(description: "Lieu, seulement si l'utilisateur l'a précisé") | |
| 25 | + let location: String? | |
| 26 | + } | |
| 27 | + | |
| 28 | + private let confirm: ConfirmCenter | |
| 29 | + | |
| 30 | + init(confirm: ConfirmCenter) { | |
| 31 | + self.confirm = confirm | |
| 32 | + } | |
| 33 | + | |
| 34 | + func call(arguments: Arguments) async throws -> String { | |
| 35 | + guard let title = ToolInput.title(arguments.title) else { | |
| 36 | + return "Erreur : le titre est vide. Demande un titre à l'utilisateur." | |
| 37 | + } | |
| 38 | + | |
| 39 | + let start: Date | |
| 40 | + do { | |
| 41 | + start = try DateResolver.resolve(iso: arguments.startDate) | |
| 42 | + } catch { | |
| 43 | + return "Erreur sur la date de début : \(error.localizedDescription)" | |
| 44 | + } | |
| 45 | + | |
| 46 | + var end = start.addingTimeInterval(3600) | |
| 47 | + if let rawEnd = arguments.endDate, !rawEnd.trimmingCharacters(in: .whitespaces).isEmpty { | |
| 48 | + do { | |
| 49 | + end = try DateResolver.resolve(iso: rawEnd) | |
| 50 | + } catch { | |
| 51 | + return "Erreur sur la date de fin : \(error.localizedDescription)" | |
| 52 | + } | |
| 53 | + guard end > start else { | |
| 54 | + return "Erreur : la fin est avant le début. Vérifie les horaires avec l'utilisateur." | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + let draft = EventDraft(title: title, start: start, end: end, location: ToolInput.optionalText(arguments.location)) | |
| 59 | + await confirm.propose(PendingAction(payload: .event(draft))) | |
| 60 | + | |
| 61 | + return "Proposition affichée : événement « \(title) » le \(DateResolver.display(start)). Rien n'est encore créé — l'utilisateur doit confirmer. Ne dis pas que l'événement est créé." | |
| 62 | + } | |
| 63 | +} | |
added
Poche/Agent/Tools/CreateReminderTool.swift
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +// | |
| 2 | +// CreateReminderTool.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import FoundationModels | |
| 11 | + | |
| 12 | +/// The pattern tool (CLAUDE.md §11): validate, never write, hand a pending | |
| 13 | +/// proposal to the Confirm layer, return a short budgeted string. | |
| 14 | +struct CreateReminderTool: Tool { | |
| 15 | + let name = "createReminder" | |
| 16 | + let description = "Propose la création d'un rappel avec un titre et une échéance. L'utilisateur devra confirmer." | |
| 17 | + | |
| 18 | + @Generable | |
| 19 | + struct Arguments { | |
| 20 | + @Guide(description: "Titre du rappel, court et concret") | |
| 21 | + let title: String | |
| 22 | + @Guide(description: "Échéance au format ISO 8601, par exemple 2026-08-12T09:00:00") | |
| 23 | + let dueDate: String | |
| 24 | + @Guide(description: "Nom de la liste de rappels, seulement si l'utilisateur l'a précisé") | |
| 25 | + let list: String? | |
| 26 | + } | |
| 27 | + | |
| 28 | + private let confirm: ConfirmCenter | |
| 29 | + | |
| 30 | + init(confirm: ConfirmCenter) { | |
| 31 | + self.confirm = confirm | |
| 32 | + } | |
| 33 | + | |
| 34 | + func call(arguments: Arguments) async throws -> String { | |
| 35 | + guard let title = ToolInput.title(arguments.title) else { | |
| 36 | + return "Erreur : le titre est vide. Demande un titre à l'utilisateur." | |
| 37 | + } | |
| 38 | + | |
| 39 | + let due: Date | |
| 40 | + do { | |
| 41 | + due = try DateResolver.resolve(iso: arguments.dueDate) | |
| 42 | + } catch { | |
| 43 | + return "Erreur : \(error.localizedDescription)" | |
| 44 | + } | |
| 45 | + | |
| 46 | + let draft = ReminderDraft(title: title, due: due, list: ToolInput.optionalText(arguments.list)) | |
| 47 | + await confirm.propose(PendingAction(payload: .reminder(draft))) | |
| 48 | + | |
| 49 | + return "Proposition affichée : rappel « \(title) » pour \(DateResolver.display(due)). Rien n'est encore créé — l'utilisateur doit confirmer dans l'app. Ne dis pas que le rappel est créé." | |
| 50 | + } | |
| 51 | +} | |
added
Poche/Agent/Tools/CreateTaskTool.swift
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +// | |
| 2 | +// CreateTaskTool.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import FoundationModels | |
| 11 | + | |
| 12 | +struct CreateTaskTool: Tool { | |
| 13 | + let name = "createTask" | |
| 14 | + let description = "Propose la création d'une tâche dans Poche. L'utilisateur devra confirmer." | |
| 15 | + | |
| 16 | + @Generable | |
| 17 | + struct Arguments { | |
| 18 | + @Guide(description: "Titre de la tâche, court et concret") | |
| 19 | + let title: String | |
| 20 | + @Guide(description: "Détails, seulement si utiles") | |
| 21 | + let details: String? | |
| 22 | + @Guide(description: "Échéance au format ISO 8601, seulement si l'utilisateur en a donné une") | |
| 23 | + let dueDate: String? | |
| 24 | + } | |
| 25 | + | |
| 26 | + private let confirm: ConfirmCenter | |
| 27 | + | |
| 28 | + init(confirm: ConfirmCenter) { | |
| 29 | + self.confirm = confirm | |
| 30 | + } | |
| 31 | + | |
| 32 | + func call(arguments: Arguments) async throws -> String { | |
| 33 | + guard let title = ToolInput.title(arguments.title) else { | |
| 34 | + return "Erreur : le titre est vide. Demande un titre à l'utilisateur." | |
| 35 | + } | |
| 36 | + | |
| 37 | + var due: Date? | |
| 38 | + if let rawDue = arguments.dueDate, !rawDue.trimmingCharacters(in: .whitespaces).isEmpty { | |
| 39 | + do { | |
| 40 | + due = try DateResolver.resolve(iso: rawDue) | |
| 41 | + } catch { | |
| 42 | + return "Erreur sur l'échéance : \(error.localizedDescription)" | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + let draft = TaskDraft( | |
| 47 | + title: title, | |
| 48 | + details: arguments.details.flatMap(ToolInput.body), | |
| 49 | + due: due | |
| 50 | + ) | |
| 51 | + await confirm.propose(PendingAction(payload: .newTask(draft))) | |
| 52 | + | |
| 53 | + return "Proposition affichée : tâche « \(title) ». Rien n'est encore créé — l'utilisateur doit confirmer." | |
| 54 | + } | |
| 55 | +} | |
added
Poche/Agent/Tools/GetUpcomingTool.swift
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +// | |
| 2 | +// GetUpcomingTool.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import FoundationModels | |
| 11 | + | |
| 12 | +/// Read-only view of upcoming events and reminders, strictly capped: | |
| 13 | +/// a tool that returns 30 calendar events blows the context and kills the | |
| 14 | +/// session (CLAUDE.md §4). | |
| 15 | +struct GetUpcomingTool: Tool { | |
| 16 | + let name = "getUpcoming" | |
| 17 | + let description = "Liste les prochains événements du calendrier et rappels à venir." | |
| 18 | + | |
| 19 | + @Generable | |
| 20 | + struct Arguments { | |
| 21 | + @Guide(description: "Nombre de jours à couvrir, entre 1 et 14 (défaut 7)") | |
| 22 | + let days: Int? | |
| 23 | + } | |
| 24 | + | |
| 25 | + private let bridge: EventKitBridge | |
| 26 | + | |
| 27 | + init(bridge: EventKitBridge) { | |
| 28 | + self.bridge = bridge | |
| 29 | + } | |
| 30 | + | |
| 31 | + func call(arguments: Arguments) async throws -> String { | |
| 32 | + let days = min(max(arguments.days ?? 7, 1), 14) | |
| 33 | + | |
| 34 | + var lines: [String] = [] | |
| 35 | + do { | |
| 36 | + let events = try await bridge.upcomingEvents(days: days, limit: 5) | |
| 37 | + if !events.isEmpty { | |
| 38 | + lines.append("Événements :") | |
| 39 | + lines.append(contentsOf: events) | |
| 40 | + } | |
| 41 | + } catch { | |
| 42 | + lines.append("Calendrier inaccessible (accès non autorisé).") | |
| 43 | + } | |
| 44 | + | |
| 45 | + do { | |
| 46 | + let reminders = try await bridge.upcomingReminders(limit: 5) | |
| 47 | + if !reminders.isEmpty { | |
| 48 | + lines.append("Rappels :") | |
| 49 | + lines.append(contentsOf: reminders) | |
| 50 | + } | |
| 51 | + } catch { | |
| 52 | + lines.append("Rappels inaccessibles (accès non autorisé).") | |
| 53 | + } | |
| 54 | + | |
| 55 | + if lines.isEmpty { | |
| 56 | + return "Rien de prévu dans les \(days) prochains jours." | |
| 57 | + } | |
| 58 | + return lines.joined(separator: "\n") | |
| 59 | + } | |
| 60 | +} | |
added
Poche/Agent/Tools/SaveNoteTool.swift
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +// | |
| 2 | +// SaveNoteTool.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import FoundationModels | |
| 11 | + | |
| 12 | +/// Saves into Poche's own notes (SwiftData). Not Apple Notes — there is no | |
| 13 | +/// public API for that, and the name must not pretend otherwise | |
| 14 | +/// (CLAUDE.md §4). | |
| 15 | +struct SaveNoteTool: Tool { | |
| 16 | + let name = "saveNote" | |
| 17 | + let description = "Propose d'enregistrer une note dans Poche (pas dans l'app Notes d'Apple). L'utilisateur devra confirmer." | |
| 18 | + | |
| 19 | + @Generable | |
| 20 | + struct Arguments { | |
| 21 | + @Guide(description: "Titre court de la note") | |
| 22 | + let title: String | |
| 23 | + @Guide(description: "Contenu de la note") | |
| 24 | + let content: String | |
| 25 | + } | |
| 26 | + | |
| 27 | + private let confirm: ConfirmCenter | |
| 28 | + | |
| 29 | + init(confirm: ConfirmCenter) { | |
| 30 | + self.confirm = confirm | |
| 31 | + } | |
| 32 | + | |
| 33 | + func call(arguments: Arguments) async throws -> String { | |
| 34 | + guard let title = ToolInput.title(arguments.title) else { | |
| 35 | + return "Erreur : le titre est vide. Demande un titre à l'utilisateur." | |
| 36 | + } | |
| 37 | + guard let content = ToolInput.body(arguments.content) else { | |
| 38 | + return "Erreur : le contenu est vide. Demande le contenu à l'utilisateur." | |
| 39 | + } | |
| 40 | + | |
| 41 | + let draft = NoteDraft(title: title, content: content) | |
| 42 | + await confirm.propose(PendingAction(payload: .note(draft))) | |
| 43 | + | |
| 44 | + return "Proposition affichée : note « \(title) ». Rien n'est encore enregistré — l'utilisateur doit confirmer." | |
| 45 | + } | |
| 46 | +} | |
added
Poche/Agent/Tools/SearchMyDataTool.swift
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +// | |
| 2 | +// SearchMyDataTool.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import FoundationModels | |
| 11 | + | |
| 12 | +/// Read-only. Long-term memory lives in SwiftData and is re-injected on | |
| 13 | +/// demand through this tool, never kept permanently in the context | |
| 14 | +/// (CLAUDE.md §5). | |
| 15 | +struct SearchMyDataTool: Tool { | |
| 16 | + let name = "searchMyData" | |
| 17 | + let description = "Recherche dans les notes, tâches et souvenirs enregistrés dans Poche." | |
| 18 | + | |
| 19 | + @Generable | |
| 20 | + struct Arguments { | |
| 21 | + @Guide(description: "Ce que l'utilisateur cherche, en quelques mots") | |
| 22 | + let query: String | |
| 23 | + } | |
| 24 | + | |
| 25 | + private let store: PocheStore | |
| 26 | + private let index: SemanticIndex | |
| 27 | + | |
| 28 | + init(store: PocheStore, index: SemanticIndex) { | |
| 29 | + self.store = store | |
| 30 | + self.index = index | |
| 31 | + } | |
| 32 | + | |
| 33 | + func call(arguments: Arguments) async throws -> String { | |
| 34 | + guard let query = ToolInput.title(arguments.query) else { | |
| 35 | + return "Erreur : la recherche est vide." | |
| 36 | + } | |
| 37 | + | |
| 38 | + let corpus = await store.corpus() | |
| 39 | + guard !corpus.isEmpty else { | |
| 40 | + return "Aucune donnée enregistrée dans Poche pour l'instant." | |
| 41 | + } | |
| 42 | + | |
| 43 | + let results = await index.rank(query: query, in: corpus, limit: 3) | |
| 44 | + guard !results.isEmpty else { | |
| 45 | + return "Rien trouvé pour « \(query) »." | |
| 46 | + } | |
| 47 | + | |
| 48 | + // Budgeted output: 3 results max, each clipped (~200 tokens total). | |
| 49 | + return results | |
| 50 | + .map { "[\($0.kind)] \($0.title) — \(String($0.body.prefix(160)))" } | |
| 51 | + .joined(separator: "\n") | |
| 52 | + } | |
| 53 | +} | |
added
Poche/Agent/Tools/ToolInput.swift
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +// | |
| 2 | +// ToolInput.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Shared sanitation for model-provided tool arguments. Model output is | |
| 12 | +/// hostile input: trim it, cap it, never let it balloon the store or the | |
| 13 | +/// confirmation card. | |
| 14 | +enum ToolInput { | |
| 15 | + static let maxTitleLength = 80 | |
| 16 | + static let maxBodyLength = 2000 | |
| 17 | + | |
| 18 | + /// Trimmed, capped title — nil when effectively empty. | |
| 19 | + static func title(_ raw: String) -> String? { | |
| 20 | + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 21 | + guard !trimmed.isEmpty else { return nil } | |
| 22 | + return String(trimmed.prefix(maxTitleLength)) | |
| 23 | + } | |
| 24 | + | |
| 25 | + static func body(_ raw: String) -> String? { | |
| 26 | + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 27 | + guard !trimmed.isEmpty else { return nil } | |
| 28 | + return String(trimmed.prefix(maxBodyLength)) | |
| 29 | + } | |
| 30 | + | |
| 31 | + static func optionalText(_ raw: String?) -> String? { | |
| 32 | + guard let raw else { return nil } | |
| 33 | + return title(raw) | |
| 34 | + } | |
| 35 | +} | |
added
Poche/Agent/Tools/UpdateTaskTool.swift
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +// | |
| 2 | +// UpdateTaskTool.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import FoundationModels | |
| 11 | + | |
| 12 | +struct UpdateTaskTool: Tool { | |
| 13 | + let name = "updateTask" | |
| 14 | + let description = "Propose de modifier une tâche existante de Poche (titre, échéance, marquer faite). L'utilisateur devra confirmer." | |
| 15 | + | |
| 16 | + @Generable | |
| 17 | + struct Arguments { | |
| 18 | + @Guide(description: "Mots du titre de la tâche à retrouver") | |
| 19 | + let taskQuery: String | |
| 20 | + @Guide(description: "Nouveau titre, seulement si l'utilisateur veut le changer") | |
| 21 | + let newTitle: String? | |
| 22 | + @Guide(description: "Nouvelle échéance ISO 8601, seulement si l'utilisateur veut la changer") | |
| 23 | + let newDueDate: String? | |
| 24 | + @Guide(description: "Vrai seulement si l'utilisateur veut marquer la tâche comme faite") | |
| 25 | + let markDone: Bool? | |
| 26 | + } | |
| 27 | + | |
| 28 | + private let confirm: ConfirmCenter | |
| 29 | + private let store: PocheStore | |
| 30 | + | |
| 31 | + init(confirm: ConfirmCenter, store: PocheStore) { | |
| 32 | + self.confirm = confirm | |
| 33 | + self.store = store | |
| 34 | + } | |
| 35 | + | |
| 36 | + func call(arguments: Arguments) async throws -> String { | |
| 37 | + guard let query = ToolInput.title(arguments.taskQuery) else { | |
| 38 | + return "Erreur : précise quelle tâche modifier." | |
| 39 | + } | |
| 40 | + | |
| 41 | + let matches = await store.findTasks(matching: query) | |
| 42 | + guard let task = matches.first else { | |
| 43 | + return "Aucune tâche ne correspond à « \(query) ». Demande à l'utilisateur de préciser." | |
| 44 | + } | |
| 45 | + if matches.count > 1 { | |
| 46 | + let titles = matches.prefix(3).map { "« \($0.title) »" }.joined(separator: ", ") | |
| 47 | + return "Plusieurs tâches correspondent : \(titles). Demande à l'utilisateur laquelle modifier." | |
| 48 | + } | |
| 49 | + | |
| 50 | + var newDue: Date? | |
| 51 | + if let rawDue = arguments.newDueDate, !rawDue.trimmingCharacters(in: .whitespaces).isEmpty { | |
| 52 | + do { | |
| 53 | + newDue = try DateResolver.resolve(iso: rawDue) | |
| 54 | + } catch { | |
| 55 | + return "Erreur sur l'échéance : \(error.localizedDescription)" | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + let newTitle = arguments.newTitle.flatMap(ToolInput.title) | |
| 60 | + guard newTitle != nil || newDue != nil || arguments.markDone != nil else { | |
| 61 | + return "Erreur : aucune modification demandée. Demande à l'utilisateur ce qu'il veut changer." | |
| 62 | + } | |
| 63 | + | |
| 64 | + let draft = TaskUpdateDraft( | |
| 65 | + taskID: task.uuid, | |
| 66 | + currentTitle: task.title, | |
| 67 | + newTitle: newTitle, | |
| 68 | + newDue: newDue, | |
| 69 | + markDone: arguments.markDone | |
| 70 | + ) | |
| 71 | + await confirm.propose(PendingAction(payload: .taskUpdate(draft))) | |
| 72 | + | |
| 73 | + return "Proposition affichée : modification de la tâche « \(task.title) ». Rien n'est encore modifié — l'utilisateur doit confirmer." | |
| 74 | + } | |
| 75 | +} | |
added
Poche/App/AppDependencies.swift
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +// | |
| 2 | +// AppDependencies.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import SwiftData | |
| 11 | +import FoundationModels | |
| 12 | +import Observation | |
| 13 | + | |
| 14 | +/// Composition root. Everything is built here once, so there is a single | |
| 15 | +/// ConfirmCenter (the only write path) and a single AgentSession (one | |
| 16 | +/// request in flight, ever). | |
| 17 | +@MainActor | |
| 18 | +@Observable | |
| 19 | +final class AppDependencies { | |
| 20 | + let store: PocheStore | |
| 21 | + let index: SemanticIndex | |
| 22 | + let bridge: EventKitBridge | |
| 23 | + let confirm: ConfirmCenter | |
| 24 | + let agent: AgentSession | |
| 25 | + let chat: ChatViewModel | |
| 26 | + | |
| 27 | + init(container: ModelContainer) { | |
| 28 | + let store = PocheStore(container: container) | |
| 29 | + let index = SemanticIndex() | |
| 30 | + let bridge = EventKitBridge() | |
| 31 | + let executor = ActionExecutor(bridge: bridge, store: store) | |
| 32 | + let confirm = ConfirmCenter(executor: executor) | |
| 33 | + | |
| 34 | + // Hard cap from CLAUDE.md §4: at most 8 tools exposed simultaneously. | |
| 35 | + let tools: [any Tool] = [ | |
| 36 | + CreateReminderTool(confirm: confirm), | |
| 37 | + CreateCalendarEventTool(confirm: confirm), | |
| 38 | + SaveNoteTool(confirm: confirm), | |
| 39 | + CreateTaskTool(confirm: confirm), | |
| 40 | + UpdateTaskTool(confirm: confirm, store: store), | |
| 41 | + SearchMyDataTool(store: store, index: index), | |
| 42 | + GetUpcomingTool(bridge: bridge), | |
| 43 | + ] | |
| 44 | + precondition(tools.count <= 8, "Tool cardinality cap exceeded (CLAUDE.md §4)") | |
| 45 | + | |
| 46 | + let agent = AgentSession(tools: tools) | |
| 47 | + | |
| 48 | + self.store = store | |
| 49 | + self.index = index | |
| 50 | + self.bridge = bridge | |
| 51 | + self.confirm = confirm | |
| 52 | + self.agent = agent | |
| 53 | + self.chat = ChatViewModel(agent: agent, confirm: confirm, store: store) | |
| 54 | + } | |
| 55 | +} | |
added
Poche/App/AvailabilityView.swift
+129 −0
@@ -0,0 +1,129 @@ | ||
| 1 | +// | |
| 2 | +// AvailabilityView.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | +import FoundationModels | |
| 11 | + | |
| 12 | +/// Unavailability screens. Careful, apologetic, never guilt-tripping, | |
| 13 | +/// and never offering a replacement LLM (CLAUDE.md §2). | |
| 14 | +struct AvailabilityView: View { | |
| 15 | + let reason: SystemLanguageModel.Availability.UnavailableReason | |
| 16 | + let retry: () -> Void | |
| 17 | + | |
| 18 | + var body: some View { | |
| 19 | + ZStack { | |
| 20 | + AppBackground() | |
| 21 | + | |
| 22 | + VStack(spacing: Theme.spacingLarge) { | |
| 23 | + Spacer() | |
| 24 | + | |
| 25 | + ZStack { | |
| 26 | + Circle() | |
| 27 | + .fill(Theme.accentGradient.opacity(0.14)) | |
| 28 | + .frame(width: 108, height: 108) | |
| 29 | + Image(systemName: symbolName) | |
| 30 | + .font(.system(size: 44, weight: .light)) | |
| 31 | + .foregroundStyle(Theme.accentGradient) | |
| 32 | + } | |
| 33 | + | |
| 34 | + Text(title) | |
| 35 | + .font(.title2.weight(.bold)) | |
| 36 | + .multilineTextAlignment(.center) | |
| 37 | + .padding(.horizontal, Theme.spacingLarge) | |
| 38 | + | |
| 39 | + Text(message) | |
| 40 | + .font(.body) | |
| 41 | + .foregroundStyle(.secondary) | |
| 42 | + .multilineTextAlignment(.center) | |
| 43 | + .padding(.horizontal, Theme.spacingLarge + 8) | |
| 44 | + | |
| 45 | + actionButton | |
| 46 | + | |
| 47 | + Spacer() | |
| 48 | + | |
| 49 | + HStack(spacing: Theme.spacingSmall) { | |
| 50 | + BrandMark(size: 20) | |
| 51 | + Text("Poche — ton agent, sur ton appareil") | |
| 52 | + .font(.footnote) | |
| 53 | + .foregroundStyle(.secondary) | |
| 54 | + } | |
| 55 | + .padding(.bottom, Theme.spacingLarge) | |
| 56 | + } | |
| 57 | + } | |
| 58 | + .fontDesign(.rounded) | |
| 59 | + } | |
| 60 | + | |
| 61 | + private var symbolName: String { | |
| 62 | + switch reason { | |
| 63 | + case .deviceNotEligible: "iphone.slash" | |
| 64 | + case .appleIntelligenceNotEnabled: "gearshape" | |
| 65 | + case .modelNotReady: "arrow.down.circle" | |
| 66 | + @unknown default: "questionmark.circle" | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + private var title: String { | |
| 71 | + switch reason { | |
| 72 | + case .deviceNotEligible: | |
| 73 | + "Cet iPhone ne peut pas faire tourner Poche" | |
| 74 | + case .appleIntelligenceNotEnabled: | |
| 75 | + "Apple Intelligence est désactivé" | |
| 76 | + case .modelNotReady: | |
| 77 | + "Le modèle se prépare" | |
| 78 | + @unknown default: | |
| 79 | + "L’intelligence sur l’appareil est indisponible" | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + private var message: String { | |
| 84 | + switch reason { | |
| 85 | + case .deviceNotEligible: | |
| 86 | + "Poche fonctionne entièrement sur l’appareil, sans serveur. Cela demande la puce A17 Pro ou plus récente (iPhone 15 Pro et suivants). Sur cet iPhone, le modèle n’est pas disponible — et Poche ne le remplacera jamais par un service en ligne." | |
| 87 | + case .appleIntelligenceNotEnabled: | |
| 88 | + "Active Apple Intelligence dans Réglages pour que Poche puisse fonctionner, entièrement sur ton iPhone." | |
| 89 | + case .modelNotReady: | |
| 90 | + "Le modèle est en cours de téléchargement ou de préparation par le système. Cela peut prendre quelques minutes, notamment après une mise à jour ou en mode économie d’énergie." | |
| 91 | + @unknown default: | |
| 92 | + "Le modèle sur l’appareil n’est pas disponible pour le moment. Réessaie dans un instant." | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + @ViewBuilder | |
| 97 | + private var actionButton: some View { | |
| 98 | + switch reason { | |
| 99 | + case .deviceNotEligible: | |
| 100 | + EmptyView() | |
| 101 | + case .appleIntelligenceNotEnabled: | |
| 102 | + Button { | |
| 103 | + if let url = URL(string: UIApplication.openSettingsURLString) { | |
| 104 | + UIApplication.shared.open(url) | |
| 105 | + } | |
| 106 | + } label: { | |
| 107 | + Text("Ouvrir Réglages") | |
| 108 | + .font(.subheadline.weight(.semibold)) | |
| 109 | + .foregroundStyle(.white) | |
| 110 | + .padding(.horizontal, Theme.spacingLarge) | |
| 111 | + .padding(.vertical, Theme.spacingMedium - 2) | |
| 112 | + .background(Theme.accentGradient) | |
| 113 | + .clipShape(Capsule()) | |
| 114 | + } | |
| 115 | + .buttonStyle(.plain) | |
| 116 | + default: | |
| 117 | + Button(action: retry) { | |
| 118 | + Text("Réessayer") | |
| 119 | + .font(.subheadline.weight(.semibold)) | |
| 120 | + .foregroundStyle(.white) | |
| 121 | + .padding(.horizontal, Theme.spacingLarge) | |
| 122 | + .padding(.vertical, Theme.spacingMedium - 2) | |
| 123 | + .background(Theme.accentGradient) | |
| 124 | + .clipShape(Capsule()) | |
| 125 | + } | |
| 126 | + .buttonStyle(.plain) | |
| 127 | + } | |
| 128 | + } | |
| 129 | +} | |
added
Poche/App/PocheApp.swift
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +// | |
| 2 | +// PocheApp.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | +import SwiftData | |
| 11 | + | |
| 12 | +@main | |
| 13 | +struct PocheApp: App { | |
| 14 | + @State private var dependencies = AppDependencies(container: PocheContainer.shared) | |
| 15 | + | |
| 16 | + var body: some Scene { | |
| 17 | + WindowGroup { | |
| 18 | + RootView() | |
| 19 | + .environment(dependencies) | |
| 20 | + .task { | |
| 21 | + // Content shared into Poche from other apps (Share | |
| 22 | + // Extension) lands in the group inbox; import it as notes. | |
| 23 | + dependencies.store.importSharedInbox() | |
| 24 | + } | |
| 25 | + } | |
| 26 | + .modelContainer(PocheContainer.shared) | |
| 27 | + } | |
| 28 | +} | |
added
Poche/App/RootView.swift
+36 −0
@@ -0,0 +1,36 @@ | ||
| 1 | +// | |
| 2 | +// RootView.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | +import FoundationModels | |
| 11 | + | |
| 12 | +/// Gates the whole app on model availability. Each unavailable state is | |
| 13 | +/// distinct and has its own message and action (CLAUDE.md §2). | |
| 14 | +struct RootView: View { | |
| 15 | + @Environment(\.scenePhase) private var scenePhase | |
| 16 | + @State private var availability = SystemLanguageModel.default.availability | |
| 17 | + | |
| 18 | + var body: some View { | |
| 19 | + Group { | |
| 20 | + switch availability { | |
| 21 | + case .available: | |
| 22 | + ChatView() | |
| 23 | + case .unavailable(let reason): | |
| 24 | + AvailabilityView(reason: reason) { | |
| 25 | + availability = SystemLanguageModel.default.availability | |
| 26 | + } | |
| 27 | + } | |
| 28 | + } | |
| 29 | + .onChange(of: scenePhase) { _, phase in | |
| 30 | + // The user may enable Apple Intelligence in Settings and come back. | |
| 31 | + if phase == .active { | |
| 32 | + availability = SystemLanguageModel.default.availability | |
| 33 | + } | |
| 34 | + } | |
| 35 | + } | |
| 36 | +} | |
added
Poche/Assets.xcassets/AccentColor.colorset/Contents.json
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +{ | |
| 2 | + "colors" : [ | |
| 3 | + { | |
| 4 | + "color" : { | |
| 5 | + "color-space" : "srgb", | |
| 6 | + "components" : { | |
| 7 | + "alpha" : "1.000", | |
| 8 | + "blue" : "0.960", | |
| 9 | + "green" : "0.350", | |
| 10 | + "red" : "0.390" | |
| 11 | + } | |
| 12 | + }, | |
| 13 | + "idiom" : "universal" | |
| 14 | + } | |
| 15 | + ], | |
| 16 | + "info" : { | |
| 17 | + "author" : "xcode", | |
| 18 | + "version" : 1 | |
| 19 | + } | |
| 20 | +} | |
added
Poche/Assets.xcassets/AppIcon.appiconset/Contents.json
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +{ | |
| 2 | + "images" : [ | |
| 3 | + { | |
| 4 | + "filename" : "icon-1024.png", | |
| 5 | + "idiom" : "universal", | |
| 6 | + "platform" : "ios", | |
| 7 | + "size" : "1024x1024" | |
| 8 | + } | |
| 9 | + ], | |
| 10 | + "info" : { | |
| 11 | + "author" : "xcode", | |
| 12 | + "version" : 1 | |
| 13 | + } | |
| 14 | +} | |
added
Poche/Assets.xcassets/AppIcon.appiconset/icon-1024.png
+0 −0
Binary file not shown.
added
Poche/Assets.xcassets/Contents.json
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +{ | |
| 2 | + "info" : { | |
| 3 | + "author" : "xcode", | |
| 4 | + "version" : 1 | |
| 5 | + } | |
| 6 | +} | |
added
Poche/Chat/State/ChatModels.swift
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +// | |
| 2 | +// ChatModels.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// One turn of the visible conversation thread. | |
| 12 | +/// A refusal or a failure is a normal turn state, never an error screen | |
| 13 | +/// (CLAUDE.md §2 — guardrails). | |
| 14 | +struct ChatTurn: Identifiable, Sendable { | |
| 15 | + enum Role: Sendable { | |
| 16 | + case user | |
| 17 | + case assistant | |
| 18 | + /// App-generated notices (confirmed action, cancelled proposal). | |
| 19 | + case system | |
| 20 | + } | |
| 21 | + | |
| 22 | + enum Status: Sendable { | |
| 23 | + case streaming | |
| 24 | + case complete | |
| 25 | + case refused | |
| 26 | + case failed | |
| 27 | + } | |
| 28 | + | |
| 29 | + let id = UUID() | |
| 30 | + let role: Role | |
| 31 | + var text: String | |
| 32 | + var status: Status | |
| 33 | +} | |
added
Poche/Chat/State/ChatViewModel.swift
+110 −0
@@ -0,0 +1,110 @@ | ||
| 1 | +// | |
| 2 | +// ChatViewModel.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Observation | |
| 11 | + | |
| 12 | +@MainActor | |
| 13 | +@Observable | |
| 14 | +final class ChatViewModel { | |
| 15 | + private(set) var turns: [ChatTurn] = [] | |
| 16 | + var draft = "" | |
| 17 | + | |
| 18 | + private let agent: AgentSession | |
| 19 | + private let confirm: ConfirmCenter | |
| 20 | + private let store: PocheStore | |
| 21 | + private var conversation: ConversationRecord? | |
| 22 | + | |
| 23 | + var isBusy: Bool { agent.isResponding } | |
| 24 | + var contextUsage: Double { agent.usageRatio } | |
| 25 | + | |
| 26 | + init(agent: AgentSession, confirm: ConfirmCenter, store: PocheStore) { | |
| 27 | + self.agent = agent | |
| 28 | + self.confirm = confirm | |
| 29 | + self.store = store | |
| 30 | + | |
| 31 | + // The Confirm layer reports back so (a) the user sees a truthful | |
| 32 | + // "done" notice only after execution, and (b) the model learns the | |
| 33 | + // outcome on its next turn instead of believing its own proposal. | |
| 34 | + confirm.onExecuted = { [weak self] summary in | |
| 35 | + self?.appendSystemNotice("✓ " + summary) | |
| 36 | + self?.agent.noteEvent("Action confirmée et exécutée : \(summary)") | |
| 37 | + } | |
| 38 | + confirm.onDismissed = { [weak self] summary in | |
| 39 | + self?.appendSystemNotice("Proposition annulée — dis-moi ce que tu veux changer.") | |
| 40 | + self?.agent.noteEvent("L'utilisateur a annulé la proposition : \(summary)") | |
| 41 | + } | |
| 42 | + agent.onSummary = { [weak self] summary in | |
| 43 | + guard let self, let conversation = self.conversation else { return } | |
| 44 | + self.store.updateSummary(of: conversation, to: summary) | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Sends a given text (welcome suggestions, driving hooks). | |
| 49 | + func send(_ text: String) async { | |
| 50 | + draft = text | |
| 51 | + await send() | |
| 52 | + } | |
| 53 | + | |
| 54 | + func send() async { | |
| 55 | + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 56 | + guard !text.isEmpty, !agent.isResponding else { return } | |
| 57 | + draft = "" | |
| 58 | + | |
| 59 | + turns.append(ChatTurn(role: .user, text: text, status: .complete)) | |
| 60 | + persist(role: "user", text: text) | |
| 61 | + | |
| 62 | + turns.append(ChatTurn(role: .assistant, text: "", status: .streaming)) | |
| 63 | + let index = turns.count - 1 | |
| 64 | + | |
| 65 | + let outcome = await agent.respond(to: text) { [weak self] partial in | |
| 66 | + self?.turns[index].text = partial | |
| 67 | + } | |
| 68 | + | |
| 69 | + switch outcome { | |
| 70 | + case .completed(let full): | |
| 71 | + turns[index].text = full | |
| 72 | + turns[index].status = .complete | |
| 73 | + persist(role: "assistant", text: full) | |
| 74 | + case .refused(let message): | |
| 75 | + turns[index].text = message | |
| 76 | + turns[index].status = .refused | |
| 77 | + case .failed(let message): | |
| 78 | + turns[index].text = message | |
| 79 | + turns[index].status = .failed | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + #if DEBUG | |
| 84 | + /// Visual-verification hook (simulator only): fills the thread with | |
| 85 | + /// representative turns without touching the model. | |
| 86 | + func injectDemoTurns() { | |
| 87 | + turns = [ | |
| 88 | + ChatTurn(role: .user, text: "Rappelle-moi d'appeler le dentiste demain à 9 h", status: .complete), | |
| 89 | + ChatTurn(role: .assistant, text: "Je te propose un rappel « Appeler le dentiste » pour demain à 9 h. Confirme sur la carte ci-dessous.", status: .complete), | |
| 90 | + ChatTurn(role: .system, text: "✓ Rappel « Appeler le dentiste » créé", status: .complete), | |
| 91 | + ChatTurn(role: .user, text: "Parfait, et qu'est-ce que j'ai cette semaine ?", status: .complete), | |
| 92 | + ChatTurn(role: .assistant, text: "", status: .streaming), | |
| 93 | + ] | |
| 94 | + } | |
| 95 | + #endif | |
| 96 | + | |
| 97 | + private func appendSystemNotice(_ text: String) { | |
| 98 | + turns.append(ChatTurn(role: .system, text: text, status: .complete)) | |
| 99 | + persist(role: "system", text: text) | |
| 100 | + } | |
| 101 | + | |
| 102 | + private func persist(role: String, text: String) { | |
| 103 | + if conversation == nil { | |
| 104 | + conversation = store.newConversation() | |
| 105 | + } | |
| 106 | + if let conversation { | |
| 107 | + store.appendTurn(to: conversation, role: role, text: text) | |
| 108 | + } | |
| 109 | + } | |
| 110 | +} | |
added
Poche/Chat/UI/ChatView.swift
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +// | |
| 2 | +// ChatView.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Single screen: thread, pending confirmations, composer. | |
| 12 | +/// No tabs, no settings menu on first launch (CLAUDE.md §6). | |
| 13 | +struct ChatView: View { | |
| 14 | + @Environment(AppDependencies.self) private var deps | |
| 15 | + | |
| 16 | + var body: some View { | |
| 17 | + @Bindable var chat = deps.chat | |
| 18 | + | |
| 19 | + ZStack { | |
| 20 | + AppBackground() | |
| 21 | + | |
| 22 | + VStack(spacing: 0) { | |
| 23 | + header | |
| 24 | + | |
| 25 | + if chat.turns.isEmpty { | |
| 26 | + WelcomeView { suggestion in | |
| 27 | + Task { await chat.send(suggestion) } | |
| 28 | + } | |
| 29 | + } else { | |
| 30 | + thread(chat: chat) | |
| 31 | + } | |
| 32 | + | |
| 33 | + ForEach(deps.confirm.pending) { action in | |
| 34 | + ConfirmationCardView( | |
| 35 | + action: action, | |
| 36 | + onConfirm: { Task { await deps.confirm.confirm(action) } }, | |
| 37 | + onModify: { deps.confirm.dismiss(action) } | |
| 38 | + ) | |
| 39 | + .padding(.horizontal, Theme.spacingMedium) | |
| 40 | + .padding(.bottom, Theme.spacingSmall) | |
| 41 | + .transition(.move(edge: .bottom).combined(with: .opacity)) | |
| 42 | + } | |
| 43 | + | |
| 44 | + ComposerView( | |
| 45 | + text: $chat.draft, | |
| 46 | + isBusy: chat.isBusy, | |
| 47 | + onSend: { Task { await chat.send() } } | |
| 48 | + ) | |
| 49 | + } | |
| 50 | + .animation(.spring(duration: 0.35), value: deps.confirm.pending.count) | |
| 51 | + } | |
| 52 | + .fontDesign(.rounded) | |
| 53 | + .task { | |
| 54 | + #if DEBUG | |
| 55 | + // Deterministic driving hooks for simulator verification only. | |
| 56 | + let env = ProcessInfo.processInfo.environment | |
| 57 | + if env["POCHE_DEMO_THREAD"] != nil, chat.turns.isEmpty { | |
| 58 | + chat.injectDemoTurns() | |
| 59 | + } | |
| 60 | + if env["POCHE_DEMO_CARD"] != nil, deps.confirm.pending.isEmpty { | |
| 61 | + deps.confirm.propose(PendingAction(payload: .reminder( | |
| 62 | + ReminderDraft(title: "Appeler le dentiste", due: .now.addingTimeInterval(86_400), list: nil) | |
| 63 | + ))) | |
| 64 | + } | |
| 65 | + if let text = env["POCHE_AUTOSEND"], chat.turns.isEmpty { | |
| 66 | + await chat.send(text) | |
| 67 | + } | |
| 68 | + #endif | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + private var header: some View { | |
| 73 | + VStack(spacing: 0) { | |
| 74 | + HStack(spacing: Theme.spacingSmall + 2) { | |
| 75 | + BrandMark(size: 30) | |
| 76 | + Text("Poche") | |
| 77 | + .font(.title3.weight(.bold)) | |
| 78 | + Spacer() | |
| 79 | + } | |
| 80 | + .padding(.horizontal, Theme.spacingMedium + 4) | |
| 81 | + .padding(.vertical, Theme.spacingSmall + 2) | |
| 82 | + | |
| 83 | + ContextGaugeView(ratio: deps.agent.usageRatio) | |
| 84 | + .padding(.horizontal, Theme.spacingMedium) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + private func thread(chat: ChatViewModel) -> some View { | |
| 89 | + ScrollView { | |
| 90 | + LazyVStack(spacing: Theme.spacingMedium) { | |
| 91 | + ForEach(chat.turns) { turn in | |
| 92 | + TurnBubbleView(turn: turn) | |
| 93 | + .transition(.move(edge: .bottom).combined(with: .opacity)) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + .padding(.horizontal, Theme.spacingMedium) | |
| 97 | + .padding(.vertical, Theme.spacingLarge) | |
| 98 | + .animation(.spring(duration: 0.35), value: chat.turns.count) | |
| 99 | + } | |
| 100 | + .defaultScrollAnchor(.bottom) | |
| 101 | + .scrollDismissesKeyboard(.interactively) | |
| 102 | + } | |
| 103 | +} | |
added
Poche/Chat/UI/ComposerView.swift
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +// | |
| 2 | +// ComposerView.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +struct ComposerView: View { | |
| 12 | + @Binding var text: String | |
| 13 | + let isBusy: Bool | |
| 14 | + let onSend: () -> Void | |
| 15 | + | |
| 16 | + @FocusState private var isFocused: Bool | |
| 17 | + @State private var dictation = DictationController() | |
| 18 | + | |
| 19 | + private var canSend: Bool { | |
| 20 | + !isBusy && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | |
| 21 | + } | |
| 22 | + | |
| 23 | + var body: some View { | |
| 24 | + HStack(alignment: .bottom, spacing: Theme.spacingSmall + 2) { | |
| 25 | + if dictation.isAvailable { | |
| 26 | + micButton | |
| 27 | + } | |
| 28 | + | |
| 29 | + TextField("Écris à Poche…", text: $text, axis: .vertical) | |
| 30 | + .lineLimit(1...5) | |
| 31 | + .textFieldStyle(.plain) | |
| 32 | + .focused($isFocused) | |
| 33 | + .padding(.horizontal, Theme.spacingMedium + 4) | |
| 34 | + .padding(.vertical, Theme.spacingMedium - 1) | |
| 35 | + .background(Theme.surface) | |
| 36 | + .clipShape(RoundedRectangle(cornerRadius: Theme.bubbleCornerRadius)) | |
| 37 | + .overlay { | |
| 38 | + RoundedRectangle(cornerRadius: Theme.bubbleCornerRadius) | |
| 39 | + .strokeBorder( | |
| 40 | + isFocused ? Theme.accentA.opacity(0.45) : Color.primary.opacity(0.07), | |
| 41 | + lineWidth: 1 | |
| 42 | + ) | |
| 43 | + } | |
| 44 | + .onSubmit { if canSend { onSend() } } | |
| 45 | + | |
| 46 | + Button(action: onSend) { | |
| 47 | + Image(systemName: "arrow.up") | |
| 48 | + .font(.system(size: 17, weight: .bold)) | |
| 49 | + .foregroundStyle(.white) | |
| 50 | + .frame(width: 40, height: 40) | |
| 51 | + .background { | |
| 52 | + if canSend { | |
| 53 | + Circle().fill(Theme.accentGradient) | |
| 54 | + } else { | |
| 55 | + Circle().fill(Color.secondary.opacity(0.35)) | |
| 56 | + } | |
| 57 | + } | |
| 58 | + .shadow( | |
| 59 | + color: canSend ? Theme.accentA.opacity(0.35) : .clear, | |
| 60 | + radius: 6, y: 3 | |
| 61 | + ) | |
| 62 | + } | |
| 63 | + .disabled(!canSend) | |
| 64 | + .animation(.easeInOut(duration: 0.15), value: canSend) | |
| 65 | + .accessibilityLabel("Envoyer") | |
| 66 | + } | |
| 67 | + .padding(.horizontal, Theme.spacingMedium) | |
| 68 | + .padding(.top, Theme.spacingSmall) | |
| 69 | + .padding(.bottom, Theme.spacingSmall + 2) | |
| 70 | + .onChange(of: dictation.transcript) { _, transcript in | |
| 71 | + if !transcript.isEmpty { text = transcript } | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + private var micButton: some View { | |
| 76 | + Button { | |
| 77 | + Task { await dictation.toggle() } | |
| 78 | + } label: { | |
| 79 | + Image(systemName: dictation.state == .recording ? "waveform.circle.fill" : "mic") | |
| 80 | + .font(.system(size: dictation.state == .recording ? 30 : 19, weight: .medium)) | |
| 81 | + .foregroundStyle( | |
| 82 | + dictation.state == .recording | |
| 83 | + ? AnyShapeStyle(Theme.accentGradient) | |
| 84 | + : AnyShapeStyle(Color.secondary) | |
| 85 | + ) | |
| 86 | + .frame(width: 40, height: 40) | |
| 87 | + .symbolEffect(.pulse, isActive: dictation.state == .recording) | |
| 88 | + } | |
| 89 | + .disabled(isBusy) | |
| 90 | + .accessibilityLabel(dictation.state == .recording ? "Arrêter la dictée" : "Dicter") | |
| 91 | + } | |
| 92 | +} | |
added
Poche/Chat/UI/ContextGaugeView.swift
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +// | |
| 2 | +// ContextGaugeView.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Discreet context gauge (CLAUDE.md §5): a thin line, never a token count. | |
| 12 | +struct ContextGaugeView: View { | |
| 13 | + let ratio: Double | |
| 14 | + | |
| 15 | + var body: some View { | |
| 16 | + GeometryReader { proxy in | |
| 17 | + ZStack(alignment: .leading) { | |
| 18 | + Capsule() | |
| 19 | + .fill(Color.primary.opacity(0.06)) | |
| 20 | + Capsule() | |
| 21 | + .fill(fillStyle) | |
| 22 | + .frame(width: max(4, proxy.size.width * min(ratio, 1))) | |
| 23 | + } | |
| 24 | + } | |
| 25 | + .frame(height: 3) | |
| 26 | + .opacity(ratio < 0.05 ? 0 : 0.85) | |
| 27 | + .animation(.easeInOut(duration: 0.3), value: ratio) | |
| 28 | + .accessibilityHidden(true) | |
| 29 | + } | |
| 30 | + | |
| 31 | + private var fillStyle: AnyShapeStyle { | |
| 32 | + switch ratio { | |
| 33 | + case ..<0.6: AnyShapeStyle(Theme.accentGradient) | |
| 34 | + case ..<0.85: AnyShapeStyle(Color.orange) | |
| 35 | + default: AnyShapeStyle(Color.red) | |
| 36 | + } | |
| 37 | + } | |
| 38 | +} | |
added
Poche/Chat/UI/TurnBubbleView.swift
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +// | |
| 2 | +// TurnBubbleView.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +struct TurnBubbleView: View { | |
| 12 | + let turn: ChatTurn | |
| 13 | + | |
| 14 | + var body: some View { | |
| 15 | + switch turn.role { | |
| 16 | + case .system: | |
| 17 | + systemNotice | |
| 18 | + case .user: | |
| 19 | + HStack { | |
| 20 | + Spacer(minLength: 56) | |
| 21 | + Text(turn.text) | |
| 22 | + .foregroundStyle(.white) | |
| 23 | + .padding(.horizontal, Theme.spacingMedium + 4) | |
| 24 | + .padding(.vertical, Theme.spacingMedium - 2) | |
| 25 | + .background(Theme.accentGradient) | |
| 26 | + .clipShape(RoundedRectangle(cornerRadius: Theme.bubbleCornerRadius)) | |
| 27 | + .shadow(color: Theme.accentA.opacity(0.28), radius: 7, y: 3) | |
| 28 | + } | |
| 29 | + case .assistant: | |
| 30 | + HStack { | |
| 31 | + assistantBody | |
| 32 | + .padding(.horizontal, Theme.spacingMedium + 4) | |
| 33 | + .padding(.vertical, Theme.spacingMedium - 2) | |
| 34 | + .background(Theme.surface) | |
| 35 | + .clipShape(RoundedRectangle(cornerRadius: Theme.bubbleCornerRadius)) | |
| 36 | + .shadow(color: .black.opacity(0.06), radius: 5, y: 2) | |
| 37 | + Spacer(minLength: 56) | |
| 38 | + } | |
| 39 | + } | |
| 40 | + } | |
| 41 | + | |
| 42 | + private var systemNotice: some View { | |
| 43 | + HStack(spacing: Theme.spacingSmall) { | |
| 44 | + Image(systemName: turn.text.hasPrefix("✓") ? "checkmark.circle.fill" : "info.circle") | |
| 45 | + .foregroundStyle(turn.text.hasPrefix("✓") ? AnyShapeStyle(.green) : AnyShapeStyle(.secondary)) | |
| 46 | + Text(turn.text.hasPrefix("✓") ? String(turn.text.dropFirst(2)) : turn.text) | |
| 47 | + .foregroundStyle(.secondary) | |
| 48 | + } | |
| 49 | + .font(.footnote.weight(.medium)) | |
| 50 | + .padding(.horizontal, Theme.spacingMedium + 2) | |
| 51 | + .padding(.vertical, Theme.spacingSmall + 2) | |
| 52 | + .background(.thinMaterial, in: Capsule()) | |
| 53 | + .frame(maxWidth: .infinity, alignment: .center) | |
| 54 | + } | |
| 55 | + | |
| 56 | + @ViewBuilder | |
| 57 | + private var assistantBody: some View { | |
| 58 | + switch turn.status { | |
| 59 | + case .streaming where turn.text.isEmpty: | |
| 60 | + TypingIndicator() | |
| 61 | + .padding(.vertical, 4) | |
| 62 | + case .refused, .failed: | |
| 63 | + // Neutral tone: the conversation stays intact, rephrasing stays possible. | |
| 64 | + Text(turn.text) | |
| 65 | + .italic() | |
| 66 | + .foregroundStyle(.secondary) | |
| 67 | + default: | |
| 68 | + Text(turn.text) | |
| 69 | + .foregroundStyle(.primary) | |
| 70 | + } | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +/// Three softly pulsing dots while the first token is on its way. | |
| 75 | +struct TypingIndicator: View { | |
| 76 | + @State private var animating = false | |
| 77 | + | |
| 78 | + var body: some View { | |
| 79 | + HStack(spacing: 5) { | |
| 80 | + ForEach(0..<3, id: \.self) { index in | |
| 81 | + Circle() | |
| 82 | + .fill(Theme.accentGradient) | |
| 83 | + .frame(width: 7, height: 7) | |
| 84 | + .scaleEffect(animating ? 1 : 0.55) | |
| 85 | + .opacity(animating ? 1 : 0.4) | |
| 86 | + .animation( | |
| 87 | + .easeInOut(duration: 0.5) | |
| 88 | + .repeatForever(autoreverses: true) | |
| 89 | + .delay(Double(index) * 0.16), | |
| 90 | + value: animating | |
| 91 | + ) | |
| 92 | + } | |
| 93 | + } | |
| 94 | + .onAppear { animating = true } | |
| 95 | + .accessibilityLabel("Poche écrit") | |
| 96 | + } | |
| 97 | +} | |
added
Poche/Chat/UI/WelcomeView.swift
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +// | |
| 2 | +// WelcomeView.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Empty state: the promise, then three one-tap ways to try the agent. | |
| 12 | +struct WelcomeView: View { | |
| 13 | + let onSuggestion: (String) -> Void | |
| 14 | + | |
| 15 | + private static let suggestions: [(icon: String, text: String)] = [ | |
| 16 | + ("bell", "Rappelle-moi d'appeler le dentiste demain à 9 h"), | |
| 17 | + ("calendar", "Qu'est-ce que j'ai au programme cette semaine ?"), | |
| 18 | + ("note.text", "Note que je dois renouveler mon passeport"), | |
| 19 | + ] | |
| 20 | + | |
| 21 | + var body: some View { | |
| 22 | + VStack(spacing: 0) { | |
| 23 | + Spacer() | |
| 24 | + | |
| 25 | + BrandMark(size: 76) | |
| 26 | + .padding(.bottom, Theme.spacingLarge) | |
| 27 | + | |
| 28 | + Text("Ton agent, sur ton appareil.") | |
| 29 | + .font(.title2.weight(.bold)) | |
| 30 | + .multilineTextAlignment(.center) | |
| 31 | + | |
| 32 | + Text("Hors ligne. Privé. Rien ne quitte ton iPhone.") | |
| 33 | + .font(.subheadline) | |
| 34 | + .foregroundStyle(.secondary) | |
| 35 | + .padding(.top, Theme.spacingSmall) | |
| 36 | + | |
| 37 | + Spacer() | |
| 38 | + | |
| 39 | + VStack(spacing: Theme.spacingSmall + 4) { | |
| 40 | + ForEach(Self.suggestions, id: \.text) { suggestion in | |
| 41 | + Button { | |
| 42 | + onSuggestion(suggestion.text) | |
| 43 | + } label: { | |
| 44 | + HStack(spacing: Theme.spacingMedium) { | |
| 45 | + Image(systemName: suggestion.icon) | |
| 46 | + .font(.system(size: 15, weight: .semibold)) | |
| 47 | + .foregroundStyle(Theme.accentGradient) | |
| 48 | + .frame(width: 22) | |
| 49 | + Text(suggestion.text) | |
| 50 | + .font(.subheadline.weight(.medium)) | |
| 51 | + .foregroundStyle(.primary) | |
| 52 | + .multilineTextAlignment(.leading) | |
| 53 | + Spacer() | |
| 54 | + } | |
| 55 | + .padding(.horizontal, Theme.spacingMedium + 4) | |
| 56 | + .padding(.vertical, Theme.spacingMedium) | |
| 57 | + .background(Theme.surface) | |
| 58 | + .clipShape(RoundedRectangle(cornerRadius: Theme.cardCornerRadius - 4)) | |
| 59 | + .overlay { | |
| 60 | + RoundedRectangle(cornerRadius: Theme.cardCornerRadius - 4) | |
| 61 | + .strokeBorder(Theme.accentA.opacity(0.14)) | |
| 62 | + } | |
| 63 | + } | |
| 64 | + .buttonStyle(.plain) | |
| 65 | + } | |
| 66 | + } | |
| 67 | + .padding(.horizontal, Theme.spacingLarge) | |
| 68 | + .padding(.bottom, Theme.spacingMedium) | |
| 69 | + } | |
| 70 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 71 | + } | |
| 72 | +} | |
added
Poche/Data/Bridges/EventKitBridge.swift
+137 −0
@@ -0,0 +1,137 @@ | ||
| 1 | +// | |
| 2 | +// EventKitBridge.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import EventKit | |
| 11 | + | |
| 12 | +enum BridgeError: Error { | |
| 13 | + case remindersAccessDenied | |
| 14 | + case calendarAccessDenied | |
| 15 | + | |
| 16 | + var userMessage: String { | |
| 17 | + switch self { | |
| 18 | + case .remindersAccessDenied: | |
| 19 | + "Poche n'a pas accès aux Rappels. Autorise l'accès dans Réglages → Confidentialité → Rappels." | |
| 20 | + case .calendarAccessDenied: | |
| 21 | + "Poche n'a pas accès au Calendrier. Autorise l'accès dans Réglages → Confidentialité → Calendriers." | |
| 22 | + } | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +/// EventKit bridge. Writes are reachable only through ActionExecutor after | |
| 27 | +/// user confirmation. Permissions are requested at the moment of need, | |
| 28 | +/// never at launch (CLAUDE.md §4). | |
| 29 | +@MainActor | |
| 30 | +final class EventKitBridge { | |
| 31 | + private let eventStore = EKEventStore() | |
| 32 | + | |
| 33 | + // MARK: - Permissions | |
| 34 | + | |
| 35 | + private func ensureRemindersAccess() async throws { | |
| 36 | + switch EKEventStore.authorizationStatus(for: .reminder) { | |
| 37 | + case .fullAccess: | |
| 38 | + return | |
| 39 | + case .notDetermined: | |
| 40 | + let granted = (try? await eventStore.requestFullAccessToReminders()) ?? false | |
| 41 | + guard granted else { throw BridgeError.remindersAccessDenied } | |
| 42 | + default: | |
| 43 | + throw BridgeError.remindersAccessDenied | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + private func ensureCalendarAccess() async throws { | |
| 48 | + switch EKEventStore.authorizationStatus(for: .event) { | |
| 49 | + case .fullAccess: | |
| 50 | + return | |
| 51 | + case .notDetermined: | |
| 52 | + let granted = (try? await eventStore.requestFullAccessToEvents()) ?? false | |
| 53 | + guard granted else { throw BridgeError.calendarAccessDenied } | |
| 54 | + default: | |
| 55 | + throw BridgeError.calendarAccessDenied | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + // MARK: - Writes (ActionExecutor only) | |
| 60 | + | |
| 61 | + func createReminder(_ draft: ReminderDraft) async throws { | |
| 62 | + try await ensureRemindersAccess() | |
| 63 | + | |
| 64 | + let reminder = EKReminder(eventStore: eventStore) | |
| 65 | + reminder.title = draft.title | |
| 66 | + reminder.calendar = remindersList(named: draft.list) | |
| 67 | + ?? eventStore.defaultCalendarForNewReminders() | |
| 68 | + reminder.dueDateComponents = Calendar.current.dateComponents( | |
| 69 | + [.year, .month, .day, .hour, .minute], | |
| 70 | + from: draft.due | |
| 71 | + ) | |
| 72 | + reminder.addAlarm(EKAlarm(absoluteDate: draft.due)) | |
| 73 | + try eventStore.save(reminder, commit: true) | |
| 74 | + } | |
| 75 | + | |
| 76 | + func createEvent(_ draft: EventDraft) async throws { | |
| 77 | + try await ensureCalendarAccess() | |
| 78 | + | |
| 79 | + let event = EKEvent(eventStore: eventStore) | |
| 80 | + event.title = draft.title | |
| 81 | + event.startDate = draft.start | |
| 82 | + event.endDate = draft.end | |
| 83 | + event.location = draft.location | |
| 84 | + event.calendar = eventStore.defaultCalendarForNewEvents | |
| 85 | + try eventStore.save(event, span: .thisEvent, commit: true) | |
| 86 | + } | |
| 87 | + | |
| 88 | + // MARK: - Reads | |
| 89 | + | |
| 90 | + /// Output is budgeted: at most `limit` lines, one short line each | |
| 91 | + /// (a tool returning 30 events kills the session — CLAUDE.md §4). | |
| 92 | + func upcomingEvents(days: Int, limit: Int) async throws -> [String] { | |
| 93 | + try await ensureCalendarAccess() | |
| 94 | + | |
| 95 | + let start = Date.now | |
| 96 | + let end = Calendar.current.date(byAdding: .day, value: days, to: start) ?? start | |
| 97 | + let predicate = eventStore.predicateForEvents(withStart: start, end: end, calendars: nil) | |
| 98 | + return eventStore.events(matching: predicate) | |
| 99 | + .sorted { $0.startDate < $1.startDate } | |
| 100 | + .prefix(limit) | |
| 101 | + .map { event in | |
| 102 | + let when = event.startDate.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) | |
| 103 | + return "• \(when) — \(event.title ?? "Sans titre")" | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + func upcomingReminders(limit: Int) async throws -> [String] { | |
| 108 | + try await ensureRemindersAccess() | |
| 109 | + | |
| 110 | + let predicate = eventStore.predicateForIncompleteReminders( | |
| 111 | + withDueDateStarting: nil, | |
| 112 | + ending: nil, | |
| 113 | + calendars: nil | |
| 114 | + ) | |
| 115 | + // EKReminder is not Sendable: render to plain strings inside the | |
| 116 | + // fetch callback instead of carrying objects across the continuation. | |
| 117 | + return await withCheckedContinuation { continuation in | |
| 118 | + eventStore.fetchReminders(matching: predicate) { result in | |
| 119 | + let lines = (result ?? []) | |
| 120 | + .prefix(limit) | |
| 121 | + .map { reminder in | |
| 122 | + let due = reminder.dueDateComponents?.date.map { | |
| 123 | + " — " + $0.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) | |
| 124 | + } ?? "" | |
| 125 | + return "• \(reminder.title ?? "Sans titre")\(due)" | |
| 126 | + } | |
| 127 | + continuation.resume(returning: Array(lines)) | |
| 128 | + } | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + private func remindersList(named name: String?) -> EKCalendar? { | |
| 133 | + guard let name else { return nil } | |
| 134 | + return eventStore.calendars(for: .reminder) | |
| 135 | + .first { $0.title.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame } | |
| 136 | + } | |
| 137 | +} | |
added
Poche/Data/Bridges/PocheIntents.swift
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +// | |
| 2 | +// PocheIntents.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import AppIntents | |
| 10 | +import SwiftData | |
| 11 | + | |
| 12 | +// App Intents let Raccourcis and Siri reach Poche (CLAUDE.md §4). These | |
| 13 | +// writes are user-initiated — the person explicitly runs the shortcut, which | |
| 14 | +// IS the confirmation. The Confirm layer guards the *model's* proposals; no | |
| 15 | +// model is involved here, and no EventKit bridge is exposed this way. | |
| 16 | + | |
| 17 | +struct SaveNoteIntent: AppIntent { | |
| 18 | + static let title: LocalizedStringResource = "Ajouter une note à Poche" | |
| 19 | + static let description = IntentDescription("Enregistre une note dans Poche, entièrement en local.") | |
| 20 | + | |
| 21 | + @Parameter(title: "Titre") | |
| 22 | + var noteTitle: String | |
| 23 | + | |
| 24 | + @Parameter(title: "Contenu") | |
| 25 | + var content: String | |
| 26 | + | |
| 27 | + @MainActor | |
| 28 | + func perform() async throws -> some IntentResult & ProvidesDialog { | |
| 29 | + let store = PocheStore(container: PocheContainer.shared) | |
| 30 | + store.addNote(title: String(noteTitle.prefix(80)), content: String(content.prefix(2000))) | |
| 31 | + return .result(dialog: "Note « \(noteTitle) » enregistrée dans Poche.") | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +struct CreateTaskIntent: AppIntent { | |
| 36 | + static let title: LocalizedStringResource = "Ajouter une tâche à Poche" | |
| 37 | + static let description = IntentDescription("Crée une tâche dans Poche, entièrement en local.") | |
| 38 | + | |
| 39 | + @Parameter(title: "Titre") | |
| 40 | + var taskTitle: String | |
| 41 | + | |
| 42 | + @MainActor | |
| 43 | + func perform() async throws -> some IntentResult & ProvidesDialog { | |
| 44 | + let store = PocheStore(container: PocheContainer.shared) | |
| 45 | + store.addTask(title: String(taskTitle.prefix(80)), details: nil, due: nil) | |
| 46 | + return .result(dialog: "Tâche « \(taskTitle) » créée dans Poche.") | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +struct PocheShortcuts: AppShortcutsProvider { | |
| 51 | + static var appShortcuts: [AppShortcut] { | |
| 52 | + AppShortcut( | |
| 53 | + intent: SaveNoteIntent(), | |
| 54 | + phrases: ["Ajoute une note dans \(.applicationName)"], | |
| 55 | + shortTitle: "Nouvelle note", | |
| 56 | + systemImageName: "note.text" | |
| 57 | + ) | |
| 58 | + AppShortcut( | |
| 59 | + intent: CreateTaskIntent(), | |
| 60 | + phrases: ["Ajoute une tâche dans \(.applicationName)"], | |
| 61 | + shortTitle: "Nouvelle tâche", | |
| 62 | + systemImageName: "checklist" | |
| 63 | + ) | |
| 64 | + } | |
| 65 | +} | |
added
Poche/Data/Search/SemanticIndex.swift
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +// | |
| 2 | +// SemanticIndex.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import NaturalLanguage | |
| 11 | + | |
| 12 | +/// Local semantic ranking over the user's data. NLEmbedding runs entirely | |
| 13 | +/// on-device — no service is involved, not even for indexing (CLAUDE.md §7). | |
| 14 | +/// | |
| 15 | +/// Constraint: distances are computed per query over the whole corpus. | |
| 16 | +/// Fine at personal-notes scale; precompute stored vectors before the | |
| 17 | +/// corpus grows past a few thousand documents. | |
| 18 | +@MainActor | |
| 19 | +final class SemanticIndex { | |
| 20 | + private let embedding = NLEmbedding.sentenceEmbedding(for: .french) | |
| 21 | + | |
| 22 | + func rank(query: String, in documents: [SearchDocument], limit: Int = 3) -> [SearchDocument] { | |
| 23 | + guard !documents.isEmpty else { return [] } | |
| 24 | + | |
| 25 | + if let embedding { | |
| 26 | + let scored = documents.map { document in | |
| 27 | + (document, embedding.distance(between: query, and: document.text, distanceType: .cosine)) | |
| 28 | + } | |
| 29 | + return scored | |
| 30 | + .sorted { $0.1 < $1.1 } | |
| 31 | + .prefix(limit) | |
| 32 | + .map(\.0) | |
| 33 | + } | |
| 34 | + | |
| 35 | + // Keyword fallback when the French sentence embedding asset is | |
| 36 | + // not present on the device. | |
| 37 | + let needles = query.lowercased().split(separator: " ").map(String.init) | |
| 38 | + let scored = documents.map { document in | |
| 39 | + (document, needles.count(where: { document.text.lowercased().contains($0) })) | |
| 40 | + } | |
| 41 | + return scored | |
| 42 | + .filter { $0.1 > 0 } | |
| 43 | + .sorted { $0.1 > $1.1 } | |
| 44 | + .prefix(limit) | |
| 45 | + .map(\.0) | |
| 46 | + } | |
| 47 | +} | |
added
Poche/Data/Store/Models.swift
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +// | |
| 2 | +// Models.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import SwiftData | |
| 11 | + | |
| 12 | +// Everything lives locally, encrypted at rest via Data Protection | |
| 13 | +// (CLAUDE.md §7). No field here ever leaves the device. | |
| 14 | + | |
| 15 | +/// Notes are internal to Poche (SwiftData). There is no public API for | |
| 16 | +/// Apple Notes — the brief's `searchNotes`-against-Notes idea is not | |
| 17 | +/// buildable (CLAUDE.md §4). | |
| 18 | +@Model | |
| 19 | +final class Note { | |
| 20 | + var uuid: UUID | |
| 21 | + var title: String | |
| 22 | + var content: String | |
| 23 | + var createdAt: Date | |
| 24 | + var updatedAt: Date | |
| 25 | + | |
| 26 | + init(title: String, content: String) { | |
| 27 | + self.uuid = UUID() | |
| 28 | + self.title = title | |
| 29 | + self.content = content | |
| 30 | + self.createdAt = .now | |
| 31 | + self.updatedAt = .now | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +@Model | |
| 36 | +final class TaskItem { | |
| 37 | + var uuid: UUID | |
| 38 | + var title: String | |
| 39 | + var details: String? | |
| 40 | + var dueDate: Date? | |
| 41 | + var isDone: Bool | |
| 42 | + var createdAt: Date | |
| 43 | + var updatedAt: Date | |
| 44 | + | |
| 45 | + init(title: String, details: String? = nil, dueDate: Date? = nil) { | |
| 46 | + self.uuid = UUID() | |
| 47 | + self.title = title | |
| 48 | + self.details = details | |
| 49 | + self.dueDate = dueDate | |
| 50 | + self.isDone = false | |
| 51 | + self.createdAt = .now | |
| 52 | + self.updatedAt = .now | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +@Model | |
| 57 | +final class ConversationRecord { | |
| 58 | + var startedAt: Date | |
| 59 | + var summary: String? | |
| 60 | + @Relationship(deleteRule: .cascade, inverse: \TurnRecord.conversation) | |
| 61 | + var turns: [TurnRecord] | |
| 62 | + | |
| 63 | + init() { | |
| 64 | + self.startedAt = .now | |
| 65 | + self.summary = nil | |
| 66 | + self.turns = [] | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +@Model | |
| 71 | +final class TurnRecord { | |
| 72 | + var date: Date | |
| 73 | + var role: String | |
| 74 | + var text: String | |
| 75 | + var conversation: ConversationRecord? | |
| 76 | + | |
| 77 | + init(role: String, text: String) { | |
| 78 | + self.date = .now | |
| 79 | + self.role = role | |
| 80 | + self.text = text | |
| 81 | + } | |
| 82 | +} | |
added
Poche/Data/Store/PocheContainer.swift
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +// | |
| 2 | +// PocheContainer.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftData | |
| 10 | + | |
| 11 | +/// Single ModelContainer for the whole process: the app UI and in-app | |
| 12 | +/// App Intents must share one container, never open two over the same file. | |
| 13 | +enum PocheContainer { | |
| 14 | + static let shared: ModelContainer = { | |
| 15 | + do { | |
| 16 | + return try ModelContainer( | |
| 17 | + for: Note.self, TaskItem.self, ConversationRecord.self, TurnRecord.self | |
| 18 | + ) | |
| 19 | + } catch { | |
| 20 | + // Local store is the only persistence layer; without it the app | |
| 21 | + // has no state at all. | |
| 22 | + fatalError("Unrecoverable: could not open the local store (\(error))") | |
| 23 | + } | |
| 24 | + }() | |
| 25 | +} | |
added
Poche/Data/Store/PocheStore.swift
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +// | |
| 2 | +// PocheStore.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import SwiftData | |
| 11 | + | |
| 12 | +/// Sendable snapshot handed to tools — @Model classes must not cross | |
| 13 | +/// actor boundaries. | |
| 14 | +struct TaskSnapshot: Sendable { | |
| 15 | + let uuid: UUID | |
| 16 | + let title: String | |
| 17 | + let due: Date? | |
| 18 | + let isDone: Bool | |
| 19 | +} | |
| 20 | + | |
| 21 | +/// Sendable document for the semantic index and searchMyData. | |
| 22 | +struct SearchDocument: Sendable { | |
| 23 | + let kind: String | |
| 24 | + let title: String | |
| 25 | + let body: String | |
| 26 | + | |
| 27 | + var text: String { "\(title). \(body)" } | |
| 28 | +} | |
| 29 | + | |
| 30 | +enum StoreError: Error { | |
| 31 | + case taskNotFound | |
| 32 | +} | |
| 33 | + | |
| 34 | +/// Local store. Write methods are called by ActionExecutor only — | |
| 35 | +/// tools go through the Confirm layer, never here (CLAUDE.md §3). | |
| 36 | +@MainActor | |
| 37 | +final class PocheStore { | |
| 38 | + private let context: ModelContext | |
| 39 | + | |
| 40 | + init(container: ModelContainer) { | |
| 41 | + self.context = container.mainContext | |
| 42 | + } | |
| 43 | + | |
| 44 | + // MARK: - Writes (ActionExecutor only) | |
| 45 | + | |
| 46 | + @discardableResult | |
| 47 | + func addNote(title: String, content: String) -> Note { | |
| 48 | + let note = Note(title: title, content: content) | |
| 49 | + context.insert(note) | |
| 50 | + try? context.save() | |
| 51 | + return note | |
| 52 | + } | |
| 53 | + | |
| 54 | + @discardableResult | |
| 55 | + func addTask(title: String, details: String?, due: Date?) -> TaskItem { | |
| 56 | + let task = TaskItem(title: title, details: details, dueDate: due) | |
| 57 | + context.insert(task) | |
| 58 | + try? context.save() | |
| 59 | + return task | |
| 60 | + } | |
| 61 | + | |
| 62 | + func updateTask(uuid: UUID, newTitle: String?, newDue: Date?, markDone: Bool?) throws { | |
| 63 | + let descriptor = FetchDescriptor<TaskItem>(predicate: #Predicate { $0.uuid == uuid }) | |
| 64 | + guard let task = try? context.fetch(descriptor).first else { | |
| 65 | + throw StoreError.taskNotFound | |
| 66 | + } | |
| 67 | + if let newTitle { task.title = newTitle } | |
| 68 | + if let newDue { task.dueDate = newDue } | |
| 69 | + if let markDone { task.isDone = markDone } | |
| 70 | + task.updatedAt = .now | |
| 71 | + try? context.save() | |
| 72 | + } | |
| 73 | + | |
| 74 | + // MARK: - Conversation persistence | |
| 75 | + | |
| 76 | + func newConversation() -> ConversationRecord { | |
| 77 | + let record = ConversationRecord() | |
| 78 | + context.insert(record) | |
| 79 | + try? context.save() | |
| 80 | + return record | |
| 81 | + } | |
| 82 | + | |
| 83 | + func appendTurn(to conversation: ConversationRecord, role: String, text: String) { | |
| 84 | + let turn = TurnRecord(role: role, text: text) | |
| 85 | + turn.conversation = conversation | |
| 86 | + context.insert(turn) | |
| 87 | + try? context.save() | |
| 88 | + } | |
| 89 | + | |
| 90 | + /// Persists the condensation summary: long-term memory lives in | |
| 91 | + /// SwiftData, searchable across sessions via `searchMyData` | |
| 92 | + /// (CLAUDE.md §5). | |
| 93 | + func updateSummary(of conversation: ConversationRecord, to summary: String) { | |
| 94 | + conversation.summary = summary | |
| 95 | + try? context.save() | |
| 96 | + } | |
| 97 | + | |
| 98 | + /// Imports notes shared into Poche from other apps (Share Extension | |
| 99 | + /// inbox). The user explicitly shared each item — that gesture is the | |
| 100 | + /// confirmation; the model is not involved (CLAUDE.md §3 concerns the | |
| 101 | + /// model's writes). | |
| 102 | + func importSharedInbox(from url: URL? = SharedInbox.url) { | |
| 103 | + for item in SharedInbox.drain(at: url) { | |
| 104 | + addNote(title: item.title, content: item.content) | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + // MARK: - Reads (tools receive Sendable snapshots) | |
| 109 | + | |
| 110 | + func findTasks(matching query: String, limit: Int = 5) -> [TaskSnapshot] { | |
| 111 | + let descriptor = FetchDescriptor<TaskItem>(sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]) | |
| 112 | + let all = (try? context.fetch(descriptor)) ?? [] | |
| 113 | + let needle = query.lowercased() | |
| 114 | + return all | |
| 115 | + .filter { $0.title.lowercased().contains(needle) } | |
| 116 | + .prefix(limit) | |
| 117 | + .map { TaskSnapshot(uuid: $0.uuid, title: $0.title, due: $0.dueDate, isDone: $0.isDone) } | |
| 118 | + } | |
| 119 | + | |
| 120 | + /// Full searchable corpus for `searchMyData`. Bodies are clipped: tool | |
| 121 | + /// output is budgeted, and so is what feeds it. | |
| 122 | + func corpus() -> [SearchDocument] { | |
| 123 | + var documents: [SearchDocument] = [] | |
| 124 | + | |
| 125 | + let notes = (try? context.fetch(FetchDescriptor<Note>())) ?? [] | |
| 126 | + for note in notes { | |
| 127 | + documents.append(SearchDocument(kind: "note", title: note.title, body: String(note.content.prefix(300)))) | |
| 128 | + } | |
| 129 | + | |
| 130 | + let tasks = (try? context.fetch(FetchDescriptor<TaskItem>())) ?? [] | |
| 131 | + for task in tasks { | |
| 132 | + let due = task.dueDate.map { " — échéance \(DateResolver.display($0))" } ?? "" | |
| 133 | + let state = task.isDone ? "faite" : "à faire" | |
| 134 | + documents.append(SearchDocument(kind: "tâche", title: task.title, body: "\(state)\(due). \(task.details ?? "")")) | |
| 135 | + } | |
| 136 | + | |
| 137 | + let conversations = (try? context.fetch(FetchDescriptor<ConversationRecord>())) ?? [] | |
| 138 | + for conversation in conversations { | |
| 139 | + if let summary = conversation.summary, !summary.isEmpty { | |
| 140 | + documents.append(SearchDocument(kind: "conversation", title: "Conversation", body: String(summary.prefix(300)))) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + return documents | |
| 145 | + } | |
| 146 | +} | |
added
Poche/Design/Theme.swift
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +// | |
| 2 | +// Theme.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +enum Theme { | |
| 12 | + // Brand: indigo → violet. Matches the AccentColor asset. | |
| 13 | + static let accentA = Color(red: 0.39, green: 0.35, blue: 0.96) | |
| 14 | + static let accentB = Color(red: 0.72, green: 0.33, blue: 0.98) | |
| 15 | + | |
| 16 | + static var accentGradient: LinearGradient { | |
| 17 | + LinearGradient( | |
| 18 | + colors: [accentA, accentB], | |
| 19 | + startPoint: .topLeading, | |
| 20 | + endPoint: .bottomTrailing | |
| 21 | + ) | |
| 22 | + } | |
| 23 | + | |
| 24 | + static let background = Color(.systemGroupedBackground) | |
| 25 | + static let surface = Color(.secondarySystemGroupedBackground) | |
| 26 | + | |
| 27 | + static let spacingSmall: CGFloat = 6 | |
| 28 | + static let spacingMedium: CGFloat = 12 | |
| 29 | + static let spacingLarge: CGFloat = 24 | |
| 30 | + | |
| 31 | + static let bubbleCornerRadius: CGFloat = 22 | |
| 32 | + static let cardCornerRadius: CGFloat = 20 | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// Soft app-wide backdrop: grouped background with a discreet brand wash | |
| 36 | +/// at the top. Adapts to dark mode through the system colors. | |
| 37 | +struct AppBackground: View { | |
| 38 | + var body: some View { | |
| 39 | + ZStack { | |
| 40 | + Theme.background | |
| 41 | + RadialGradient( | |
| 42 | + colors: [Theme.accentA.opacity(0.12), .clear], | |
| 43 | + center: .top, | |
| 44 | + startRadius: 0, | |
| 45 | + endRadius: 520 | |
| 46 | + ) | |
| 47 | + } | |
| 48 | + .ignoresSafeArea() | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +/// The Poche mark: gradient disc + sparkles. | |
| 53 | +struct BrandMark: View { | |
| 54 | + var size: CGFloat = 30 | |
| 55 | + | |
| 56 | + var body: some View { | |
| 57 | + Circle() | |
| 58 | + .fill(Theme.accentGradient) | |
| 59 | + .overlay { | |
| 60 | + Image(systemName: "sparkles") | |
| 61 | + .font(.system(size: size * 0.48, weight: .semibold)) | |
| 62 | + .foregroundStyle(.white) | |
| 63 | + } | |
| 64 | + .frame(width: size, height: size) | |
| 65 | + .shadow(color: Theme.accentA.opacity(0.35), radius: size * 0.18, y: size * 0.08) | |
| 66 | + } | |
| 67 | +} | |
added
Poche/Info.plist
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 3 | +<plist version="1.0"> | |
| 4 | +<dict> | |
| 5 | + <key>CFBundleDevelopmentRegion</key> | |
| 6 | + <string>$(DEVELOPMENT_LANGUAGE)</string> | |
| 7 | + <key>CFBundleDisplayName</key> | |
| 8 | + <string>Poche</string> | |
| 9 | + <key>CFBundleExecutable</key> | |
| 10 | + <string>$(EXECUTABLE_NAME)</string> | |
| 11 | + <key>CFBundleIdentifier</key> | |
| 12 | + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> | |
| 13 | + <key>CFBundleInfoDictionaryVersion</key> | |
| 14 | + <string>6.0</string> | |
| 15 | + <key>CFBundleName</key> | |
| 16 | + <string>$(PRODUCT_NAME)</string> | |
| 17 | + <key>CFBundlePackageType</key> | |
| 18 | + <string>APPL</string> | |
| 19 | + <key>CFBundleShortVersionString</key> | |
| 20 | + <string>$(MARKETING_VERSION)</string> | |
| 21 | + <key>CFBundleVersion</key> | |
| 22 | + <string>$(CURRENT_PROJECT_VERSION)</string> | |
| 23 | + <key>ITSAppUsesNonExemptEncryption</key> | |
| 24 | + <false/> | |
| 25 | + <key>NSCalendarsFullAccessUsageDescription</key> | |
| 26 | + <string>Poche lit tes prochains événements et crée ceux que tu confirmes.</string> | |
| 27 | + <key>NSMicrophoneUsageDescription</key> | |
| 28 | + <string>Le micro sert uniquement à dicter tes messages, en local.</string> | |
| 29 | + <key>NSRemindersFullAccessUsageDescription</key> | |
| 30 | + <string>Poche crée les rappels que tu confirmes, directement dans l’app Rappels.</string> | |
| 31 | + <key>NSSpeechRecognitionUsageDescription</key> | |
| 32 | + <string>Poche transcrit ta voix entièrement sur l’appareil, jamais sur un serveur.</string> | |
| 33 | + <key>UILaunchScreen</key> | |
| 34 | + <dict/> | |
| 35 | + <key>UISupportedInterfaceOrientations</key> | |
| 36 | + <array> | |
| 37 | + <string>UIInterfaceOrientationPortrait</string> | |
| 38 | + </array> | |
| 39 | +</dict> | |
| 40 | +</plist> | |
added
Poche/Poche.entitlements
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 3 | +<plist version="1.0"> | |
| 4 | +<dict> | |
| 5 | + <key>com.apple.security.application-groups</key> | |
| 6 | + <array> | |
| 7 | + <string>group.ai.spboucher.poche</string> | |
| 8 | + </array> | |
| 9 | +</dict> | |
| 10 | +</plist> | |
added
Poche/Shared/SharedInbox.swift
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +// | |
| 2 | +// SharedInbox.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Compiled into BOTH the app and the Share Extension: the extension | |
| 9 | +// appends, the app drains. Everything stays inside the app group — | |
| 10 | +// shared content never touches the network (CLAUDE.md §7). | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +struct SharedInboxItem: Codable, Sendable { | |
| 16 | + let title: String | |
| 17 | + let content: String | |
| 18 | + let date: Date | |
| 19 | +} | |
| 20 | + | |
| 21 | +enum SharedInbox { | |
| 22 | + static let groupID = "group.ai.spboucher.poche" | |
| 23 | + | |
| 24 | + static var url: URL? { | |
| 25 | + FileManager.default | |
| 26 | + .containerURL(forSecurityApplicationGroupIdentifier: groupID)? | |
| 27 | + .appendingPathComponent("inbox.json") | |
| 28 | + } | |
| 29 | + | |
| 30 | + static func append(_ item: SharedInboxItem, at url: URL? = SharedInbox.url) { | |
| 31 | + guard let url else { return } | |
| 32 | + var items = read(at: url) | |
| 33 | + items.append(item) | |
| 34 | + let encoder = JSONEncoder() | |
| 35 | + encoder.dateEncodingStrategy = .iso8601 | |
| 36 | + if let data = try? encoder.encode(items) { | |
| 37 | + try? data.write(to: url, options: .atomic) | |
| 38 | + } | |
| 39 | + } | |
| 40 | + | |
| 41 | + /// Returns all pending items and empties the inbox. | |
| 42 | + static func drain(at url: URL? = SharedInbox.url) -> [SharedInboxItem] { | |
| 43 | + guard let url else { return [] } | |
| 44 | + let items = read(at: url) | |
| 45 | + if !items.isEmpty { | |
| 46 | + try? FileManager.default.removeItem(at: url) | |
| 47 | + } | |
| 48 | + return items | |
| 49 | + } | |
| 50 | + | |
| 51 | + private static func read(at url: URL) -> [SharedInboxItem] { | |
| 52 | + guard let data = try? Data(contentsOf: url) else { return [] } | |
| 53 | + let decoder = JSONDecoder() | |
| 54 | + decoder.dateDecodingStrategy = .iso8601 | |
| 55 | + return (try? decoder.decode([SharedInboxItem].self, from: data)) ?? [] | |
| 56 | + } | |
| 57 | +} | |
added
Poche/Support/DictationController.swift
+117 −0
@@ -0,0 +1,117 @@ | ||
| 1 | +// | |
| 2 | +// DictationController.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Speech | |
| 11 | +import AVFoundation | |
| 12 | +import Observation | |
| 13 | + | |
| 14 | +/// On-device dictation. `requiresOnDeviceRecognition` is non-negotiable: | |
| 15 | +/// server-side recognition would break the local promise through the back | |
| 16 | +/// door (CLAUDE.md §6). If the device cannot recognize locally, the feature | |
| 17 | +/// simply does not exist — no fallback. | |
| 18 | +@MainActor | |
| 19 | +@Observable | |
| 20 | +final class DictationController { | |
| 21 | + enum State: Equatable { | |
| 22 | + case idle | |
| 23 | + case recording | |
| 24 | + case denied | |
| 25 | + } | |
| 26 | + | |
| 27 | + private(set) var state: State = .idle | |
| 28 | + private(set) var transcript = "" | |
| 29 | + | |
| 30 | + private let recognizer = SFSpeechRecognizer(locale: Locale.current) | |
| 31 | + ?? SFSpeechRecognizer(locale: Locale(identifier: "fr_FR")) | |
| 32 | + private let engine = AVAudioEngine() | |
| 33 | + private var request: SFSpeechAudioBufferRecognitionRequest? | |
| 34 | + private var task: SFSpeechRecognitionTask? | |
| 35 | + | |
| 36 | + /// Hidden entirely when local recognition is impossible. | |
| 37 | + var isAvailable: Bool { | |
| 38 | + recognizer?.supportsOnDeviceRecognition ?? false | |
| 39 | + } | |
| 40 | + | |
| 41 | + func toggle() async { | |
| 42 | + if state == .recording { | |
| 43 | + stop() | |
| 44 | + } else { | |
| 45 | + await start() | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + func start() async { | |
| 50 | + guard let recognizer, recognizer.supportsOnDeviceRecognition else { return } | |
| 51 | + | |
| 52 | + // Permissions at the moment of need, never at launch (CLAUDE.md §4). | |
| 53 | + let speechAuth = await withCheckedContinuation { continuation in | |
| 54 | + SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) } | |
| 55 | + } | |
| 56 | + guard speechAuth == .authorized else { | |
| 57 | + state = .denied | |
| 58 | + return | |
| 59 | + } | |
| 60 | + guard await AVAudioApplication.requestRecordPermission() else { | |
| 61 | + state = .denied | |
| 62 | + return | |
| 63 | + } | |
| 64 | + | |
| 65 | + let session = AVAudioSession.sharedInstance() | |
| 66 | + try? session.setCategory(.record, mode: .measurement, options: .duckOthers) | |
| 67 | + try? session.setActive(true, options: .notifyOthersOnDeactivation) | |
| 68 | + | |
| 69 | + let request = SFSpeechAudioBufferRecognitionRequest() | |
| 70 | + request.requiresOnDeviceRecognition = true | |
| 71 | + request.shouldReportPartialResults = true | |
| 72 | + self.request = request | |
| 73 | + | |
| 74 | + let input = engine.inputNode | |
| 75 | + let format = input.outputFormat(forBus: 0) | |
| 76 | + // The tap runs on the audio thread; appending buffers to an active | |
| 77 | + // request is the documented pattern. | |
| 78 | + nonisolated(unsafe) let liveRequest = request | |
| 79 | + input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in | |
| 80 | + liveRequest.append(buffer) | |
| 81 | + } | |
| 82 | + | |
| 83 | + engine.prepare() | |
| 84 | + do { | |
| 85 | + try engine.start() | |
| 86 | + } catch { | |
| 87 | + input.removeTap(onBus: 0) | |
| 88 | + return | |
| 89 | + } | |
| 90 | + | |
| 91 | + transcript = "" | |
| 92 | + state = .recording | |
| 93 | + | |
| 94 | + task = recognizer.recognitionTask(with: request) { [weak self] result, error in | |
| 95 | + let text = result?.bestTranscription.formattedString | |
| 96 | + let isFinal = result?.isFinal ?? false | |
| 97 | + let failed = error != nil | |
| 98 | + Task { @MainActor in | |
| 99 | + guard let self else { return } | |
| 100 | + if let text { self.transcript = text } | |
| 101 | + if isFinal || failed { self.stop() } | |
| 102 | + } | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + func stop() { | |
| 107 | + guard state == .recording else { return } | |
| 108 | + engine.stop() | |
| 109 | + engine.inputNode.removeTap(onBus: 0) | |
| 110 | + request?.endAudio() | |
| 111 | + task?.cancel() | |
| 112 | + request = nil | |
| 113 | + task = nil | |
| 114 | + state = .idle | |
| 115 | + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) | |
| 116 | + } | |
| 117 | +} | |
added
Poche/Support/ThermalMonitor.swift
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +// | |
| 2 | +// ThermalMonitor.swift | |
| 3 | +// Poche | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Observation | |
| 11 | + | |
| 12 | +/// Watches thermal state so we slow down before the system throttles us | |
| 13 | +/// (CLAUDE.md §9). Battery and heat are the scarcest budgets of a local | |
| 14 | +/// chat app. | |
| 15 | +@MainActor | |
| 16 | +@Observable | |
| 17 | +final class ThermalMonitor { | |
| 18 | + private(set) var state: ProcessInfo.ThermalState = ProcessInfo.processInfo.thermalState | |
| 19 | + // App-lifetime object (owned by AgentSession): the observation is never | |
| 20 | + // removed, so no deinit — Swift 6 deinits cannot touch isolated state. | |
| 21 | + private var observer: (any NSObjectProtocol)? | |
| 22 | + | |
| 23 | + var shouldPauseInference: Bool { | |
| 24 | + state == .critical | |
| 25 | + } | |
| 26 | + | |
| 27 | + init() { | |
| 28 | + observer = NotificationCenter.default.addObserver( | |
| 29 | + forName: ProcessInfo.thermalStateDidChangeNotification, | |
| 30 | + object: nil, | |
| 31 | + queue: .main | |
| 32 | + ) { [weak self] _ in | |
| 33 | + // Delivered on the main queue; re-read from ProcessInfo because | |
| 34 | + // the Notification object is not Sendable and carries nothing. | |
| 35 | + MainActor.assumeIsolated { | |
| 36 | + self?.state = ProcessInfo.processInfo.thermalState | |
| 37 | + } | |
| 38 | + } | |
| 39 | + } | |
| 40 | +} | |
added
PocheShare/Info.plist
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 3 | +<plist version="1.0"> | |
| 4 | +<dict> | |
| 5 | + <key>CFBundleDevelopmentRegion</key> | |
| 6 | + <string>$(DEVELOPMENT_LANGUAGE)</string> | |
| 7 | + <key>CFBundleDisplayName</key> | |
| 8 | + <string>Poche</string> | |
| 9 | + <key>CFBundleExecutable</key> | |
| 10 | + <string>$(EXECUTABLE_NAME)</string> | |
| 11 | + <key>CFBundleIdentifier</key> | |
| 12 | + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> | |
| 13 | + <key>CFBundleInfoDictionaryVersion</key> | |
| 14 | + <string>6.0</string> | |
| 15 | + <key>CFBundleName</key> | |
| 16 | + <string>$(PRODUCT_NAME)</string> | |
| 17 | + <key>CFBundlePackageType</key> | |
| 18 | + <string>XPC!</string> | |
| 19 | + <key>CFBundleShortVersionString</key> | |
| 20 | + <string>$(MARKETING_VERSION)</string> | |
| 21 | + <key>CFBundleVersion</key> | |
| 22 | + <string>$(CURRENT_PROJECT_VERSION)</string> | |
| 23 | + <key>NSExtension</key> | |
| 24 | + <dict> | |
| 25 | + <key>NSExtensionAttributes</key> | |
| 26 | + <dict> | |
| 27 | + <key>NSExtensionActivationRule</key> | |
| 28 | + <dict> | |
| 29 | + <key>NSExtensionActivationSupportsText</key> | |
| 30 | + <true/> | |
| 31 | + <key>NSExtensionActivationSupportsWebURLWithMaxCount</key> | |
| 32 | + <integer>1</integer> | |
| 33 | + </dict> | |
| 34 | + </dict> | |
| 35 | + <key>NSExtensionPointIdentifier</key> | |
| 36 | + <string>com.apple.share-services</string> | |
| 37 | + <key>NSExtensionPrincipalClass</key> | |
| 38 | + <string>$(PRODUCT_MODULE_NAME).ShareViewController</string> | |
| 39 | + </dict> | |
| 40 | +</dict> | |
| 41 | +</plist> | |
added
PocheShare/PocheShare.entitlements
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 3 | +<plist version="1.0"> | |
| 4 | +<dict> | |
| 5 | + <key>com.apple.security.application-groups</key> | |
| 6 | + <array> | |
| 7 | + <string>group.ai.spboucher.poche</string> | |
| 8 | + </array> | |
| 9 | +</dict> | |
| 10 | +</plist> | |
added
PocheShare/ShareViewController.swift
+84 −0
@@ -0,0 +1,84 @@ | ||
| 1 | +// | |
| 2 | +// ShareViewController.swift | |
| 3 | +// PocheShare | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import UIKit | |
| 10 | +import UniformTypeIdentifiers | |
| 11 | + | |
| 12 | +/// Share Extension: entry only (CLAUDE.md §4). The shared text or link is | |
| 13 | +/// dropped into the app-group inbox; the app imports it as a note on next | |
| 14 | +/// launch. Nothing leaves the device. | |
| 15 | +final class ShareViewController: UIViewController { | |
| 16 | + override func viewDidLoad() { | |
| 17 | + super.viewDidLoad() | |
| 18 | + view.backgroundColor = .clear | |
| 19 | + | |
| 20 | + let card = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) | |
| 21 | + card.layer.cornerRadius = 20 | |
| 22 | + card.clipsToBounds = true | |
| 23 | + card.translatesAutoresizingMaskIntoConstraints = false | |
| 24 | + | |
| 25 | + let label = UILabel() | |
| 26 | + label.text = "Enregistré dans Poche ✓" | |
| 27 | + label.font = .systemFont(ofSize: 17, weight: .semibold) | |
| 28 | + label.translatesAutoresizingMaskIntoConstraints = false | |
| 29 | + card.contentView.addSubview(label) | |
| 30 | + | |
| 31 | + view.addSubview(card) | |
| 32 | + NSLayoutConstraint.activate([ | |
| 33 | + card.centerXAnchor.constraint(equalTo: view.centerXAnchor), | |
| 34 | + card.centerYAnchor.constraint(equalTo: view.centerYAnchor), | |
| 35 | + label.topAnchor.constraint(equalTo: card.contentView.topAnchor, constant: 18), | |
| 36 | + label.bottomAnchor.constraint(equalTo: card.contentView.bottomAnchor, constant: -18), | |
| 37 | + label.leadingAnchor.constraint(equalTo: card.contentView.leadingAnchor, constant: 24), | |
| 38 | + label.trailingAnchor.constraint(equalTo: card.contentView.trailingAnchor, constant: -24), | |
| 39 | + ]) | |
| 40 | + } | |
| 41 | + | |
| 42 | + override func viewDidAppear(_ animated: Bool) { | |
| 43 | + super.viewDidAppear(animated) | |
| 44 | + Task { | |
| 45 | + await saveSharedContent() | |
| 46 | + try? await Task.sleep(for: .seconds(0.8)) | |
| 47 | + extensionContext?.completeRequest(returningItems: nil) | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + private func saveSharedContent() async { | |
| 52 | + guard let items = extensionContext?.inputItems as? [NSExtensionItem] else { return } | |
| 53 | + | |
| 54 | + for item in items { | |
| 55 | + for provider in item.attachments ?? [] { | |
| 56 | + if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier), | |
| 57 | + let raw = try? await provider.loadItem(forTypeIdentifier: UTType.plainText.identifier), | |
| 58 | + let text = raw as? String { | |
| 59 | + save(text: text, fallbackTitle: item.attributedContentText?.string) | |
| 60 | + return | |
| 61 | + } | |
| 62 | + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier), | |
| 63 | + let raw = try? await provider.loadItem(forTypeIdentifier: UTType.url.identifier), | |
| 64 | + let url = raw as? URL { | |
| 65 | + save(text: url.absoluteString, fallbackTitle: item.attributedContentText?.string) | |
| 66 | + return | |
| 67 | + } | |
| 68 | + } | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + private func save(text: String, fallbackTitle: String?) { | |
| 73 | + let firstLine = text | |
| 74 | + .components(separatedBy: .newlines) | |
| 75 | + .first? | |
| 76 | + .trimmingCharacters(in: .whitespaces) ?? "Partage" | |
| 77 | + let title = String((fallbackTitle?.isEmpty == false ? fallbackTitle! : firstLine).prefix(80)) | |
| 78 | + SharedInbox.append(SharedInboxItem( | |
| 79 | + title: title, | |
| 80 | + content: String(text.prefix(2000)), | |
| 81 | + date: .now | |
| 82 | + )) | |
| 83 | + } | |
| 84 | +} | |
added
PocheTests/ContextBudgetTests.swift
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +// | |
| 2 | +// ContextBudgetTests.swift | |
| 3 | +// PocheTests | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Testing | |
| 10 | +@testable import Poche | |
| 11 | + | |
| 12 | +struct ContextBudgetTests { | |
| 13 | + let budget = ContextBudget() | |
| 14 | + | |
| 15 | + @Test func reservesAtLeastThirtyPercentForResponse() { | |
| 16 | + #expect(budget.responseReserve >= budget.windowSize * 3 / 10) | |
| 17 | + #expect(budget.sendableLimit == budget.windowSize - budget.responseReserve) | |
| 18 | + } | |
| 19 | + | |
| 20 | + @Test func condensesAtSeventyPercent() { | |
| 21 | + #expect(!budget.needsCondensation(estimatedTokens: budget.condenseThreshold - 1)) | |
| 22 | + #expect(budget.needsCondensation(estimatedTokens: budget.condenseThreshold)) | |
| 23 | + } | |
| 24 | + | |
| 25 | + @Test func aFullPromptCannotBeSent() { | |
| 26 | + // A prompt at 4092/4096 still fails: no room left for the answer. | |
| 27 | + #expect(!budget.canSend(estimatedTokens: budget.windowSize - 4)) | |
| 28 | + } | |
| 29 | + | |
| 30 | + @Test func usageRatioIsClamped() { | |
| 31 | + #expect(budget.usageRatio(estimatedTokens: budget.windowSize * 2) == 1) | |
| 32 | + #expect(budget.usageRatio(estimatedTokens: 0) == 0) | |
| 33 | + } | |
| 34 | + | |
| 35 | + @Test func estimatorOvercountsRatherThanUndercounts() { | |
| 36 | + // ~3 chars/token is pessimistic for French (~3.5–4 real). | |
| 37 | + let text = String(repeating: "bonjour tout le monde ", count: 50) | |
| 38 | + #expect(TokenEstimator.tokens(in: text) >= text.count / 4) | |
| 39 | + } | |
| 40 | +} | |
added
PocheTests/CreateReminderToolTests.swift
+100 −0
@@ -0,0 +1,100 @@ | ||
| 1 | +// | |
| 2 | +// CreateReminderToolTests.swift | |
| 3 | +// PocheTests | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import SwiftData | |
| 11 | +import Testing | |
| 12 | +@testable import Poche | |
| 13 | + | |
| 14 | +/// The pattern tool, tested with invalid, missing and hostile arguments | |
| 15 | +/// (CLAUDE.md §10). Nothing here may touch EventKit: the tool only | |
| 16 | +/// proposes; execution happens after user confirmation. | |
| 17 | +@MainActor | |
| 18 | +struct CreateReminderToolTests { | |
| 19 | + private func makeFixture() throws -> (tool: CreateReminderTool, confirm: ConfirmCenter) { | |
| 20 | + let container = try ModelContainer( | |
| 21 | + for: Note.self, TaskItem.self, ConversationRecord.self, TurnRecord.self, | |
| 22 | + configurations: ModelConfiguration(isStoredInMemoryOnly: true) | |
| 23 | + ) | |
| 24 | + let store = PocheStore(container: container) | |
| 25 | + let executor = ActionExecutor(bridge: EventKitBridge(), store: store) | |
| 26 | + let confirm = ConfirmCenter(executor: executor) | |
| 27 | + return (CreateReminderTool(confirm: confirm), confirm) | |
| 28 | + } | |
| 29 | + | |
| 30 | + @Test func validArgumentsProposeButNeverWrite() async throws { | |
| 31 | + let (tool, confirm) = try makeFixture() | |
| 32 | + let output = try await tool.call( | |
| 33 | + arguments: .init(title: "Appeler le dentiste", dueDate: "2030-06-01T10:00:00", list: nil) | |
| 34 | + ) | |
| 35 | + | |
| 36 | + #expect(confirm.pending.count == 1) | |
| 37 | + #expect(confirm.pending.first?.status == .waiting) | |
| 38 | + // The tool must never let the model believe the write happened. | |
| 39 | + #expect(output.contains("confirmer")) | |
| 40 | + #expect(output.contains("Rien n'est encore créé")) | |
| 41 | + } | |
| 42 | + | |
| 43 | + @Test func emptyTitleIsRejected() async throws { | |
| 44 | + let (tool, confirm) = try makeFixture() | |
| 45 | + let output = try await tool.call( | |
| 46 | + arguments: .init(title: " ", dueDate: "2030-06-01T10:00:00", list: nil) | |
| 47 | + ) | |
| 48 | + | |
| 49 | + #expect(confirm.pending.isEmpty) | |
| 50 | + #expect(output.contains("Erreur")) | |
| 51 | + } | |
| 52 | + | |
| 53 | + @Test func pastDateIsRejected() async throws { | |
| 54 | + let (tool, confirm) = try makeFixture() | |
| 55 | + let output = try await tool.call( | |
| 56 | + arguments: .init(title: "Trop tard", dueDate: "2001-01-01T10:00:00", list: nil) | |
| 57 | + ) | |
| 58 | + | |
| 59 | + #expect(confirm.pending.isEmpty) | |
| 60 | + #expect(output.contains("Erreur")) | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Test func garbageDateIsRejected() async throws { | |
| 64 | + let (tool, confirm) = try makeFixture() | |
| 65 | + let output = try await tool.call( | |
| 66 | + arguments: .init(title: "Rappel", dueDate: "mardi prochain", list: nil) | |
| 67 | + ) | |
| 68 | + | |
| 69 | + #expect(confirm.pending.isEmpty) | |
| 70 | + #expect(output.contains("Erreur")) | |
| 71 | + } | |
| 72 | + | |
| 73 | + @Test func hostileTitleIsCapped() async throws { | |
| 74 | + let (tool, confirm) = try makeFixture() | |
| 75 | + let hostile = String(repeating: "A", count: 10_000) | |
| 76 | + _ = try await tool.call( | |
| 77 | + arguments: .init(title: hostile, dueDate: "2030-06-01T10:00:00", list: nil) | |
| 78 | + ) | |
| 79 | + | |
| 80 | + guard case .reminder(let draft)? = confirm.pending.first?.payload else { | |
| 81 | + Issue.record("Expected a reminder proposal") | |
| 82 | + return | |
| 83 | + } | |
| 84 | + #expect(draft.title.count <= ToolInput.maxTitleLength) | |
| 85 | + } | |
| 86 | + | |
| 87 | + @Test func dismissingRemovesTheProposal() async throws { | |
| 88 | + let (tool, confirm) = try makeFixture() | |
| 89 | + _ = try await tool.call( | |
| 90 | + arguments: .init(title: "À annuler", dueDate: "2030-06-01T10:00:00", list: nil) | |
| 91 | + ) | |
| 92 | + guard let action = confirm.pending.first else { | |
| 93 | + Issue.record("Expected a pending proposal") | |
| 94 | + return | |
| 95 | + } | |
| 96 | + | |
| 97 | + confirm.dismiss(action) | |
| 98 | + #expect(confirm.pending.isEmpty) | |
| 99 | + } | |
| 100 | +} | |
added
PocheTests/DateResolverTests.swift
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +// | |
| 2 | +// DateResolverTests.swift | |
| 3 | +// PocheTests | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Testing | |
| 11 | +@testable import Poche | |
| 12 | + | |
| 13 | +struct DateResolverTests { | |
| 14 | + private let now = Date(timeIntervalSince1970: 1_754_900_000) // 2025-08-11 ~08:13 UTC | |
| 15 | + | |
| 16 | + @Test func resolvesFutureISODateTime() throws { | |
| 17 | + let date = try DateResolver.resolve(iso: "2030-05-10T14:30:00", now: now) | |
| 18 | + let components = Calendar.current.dateComponents([.year, .hour, .minute], from: date) | |
| 19 | + #expect(components.year == 2030) | |
| 20 | + #expect(components.hour == 14) | |
| 21 | + #expect(components.minute == 30) | |
| 22 | + } | |
| 23 | + | |
| 24 | + @Test func bareDateDefaultsToNineLocal() throws { | |
| 25 | + let date = try DateResolver.resolve(iso: "2030-05-10", now: now) | |
| 26 | + let components = Calendar.current.dateComponents([.hour, .minute], from: date) | |
| 27 | + #expect(components.hour == 9) | |
| 28 | + #expect(components.minute == 0) | |
| 29 | + } | |
| 30 | + | |
| 31 | + @Test func rejectsPastDate() { | |
| 32 | + #expect(throws: DateResolutionError.self) { | |
| 33 | + try DateResolver.resolve(iso: "2020-01-01T10:00:00", now: now) | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + @Test func rejectsGarbage() { | |
| 38 | + #expect(throws: DateResolutionError.self) { | |
| 39 | + try DateResolver.resolve(iso: "mardi prochain", now: now) | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + @Test func rejectsHostileInput() { | |
| 44 | + #expect(throws: DateResolutionError.self) { | |
| 45 | + try DateResolver.resolve(iso: "'; DROP TABLE reminders; --", now: now) | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + @Test func rejectsUnreasonablyFarDate() { | |
| 50 | + #expect(throws: DateResolutionError.self) { | |
| 51 | + try DateResolver.resolve(iso: "2099-01-01T10:00:00", now: now) | |
| 52 | + } | |
| 53 | + } | |
| 54 | +} | |
added
PocheTests/NetworkIsolationTests.swift
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +// | |
| 2 | +// NetworkIsolationTests.swift | |
| 3 | +// PocheTests | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import SwiftData | |
| 11 | +import Testing | |
| 12 | +@testable import Poche | |
| 13 | + | |
| 14 | +/// CLAUDE.md §7: no network request of any kind in the agent path. This | |
| 15 | +/// tripwire registers a URLProtocol spy on the default URL loading system | |
| 16 | +/// and fails if anything in the exercised path issues a request. | |
| 17 | +/// (It cannot see custom-configuration sessions — but Poche must not | |
| 18 | +/// create any URLSession at all, so any hit here is already a breach.) | |
| 19 | +final class NetworkSpyProtocol: URLProtocol { | |
| 20 | + // Test-serial access only. | |
| 21 | + nonisolated(unsafe) static var requestCount = 0 | |
| 22 | + | |
| 23 | + override class func canInit(with request: URLRequest) -> Bool { | |
| 24 | + requestCount += 1 | |
| 25 | + return false | |
| 26 | + } | |
| 27 | + | |
| 28 | + override class func canonicalRequest(for request: URLRequest) -> URLRequest { | |
| 29 | + request | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 33 | +@MainActor | |
| 34 | +struct NetworkIsolationTests { | |
| 35 | + @Test func agentToolPathMakesNoNetworkRequest() async throws { | |
| 36 | + NetworkSpyProtocol.requestCount = 0 | |
| 37 | + URLProtocol.registerClass(NetworkSpyProtocol.self) | |
| 38 | + defer { URLProtocol.unregisterClass(NetworkSpyProtocol.self) } | |
| 39 | + | |
| 40 | + let container = try ModelContainer( | |
| 41 | + for: Note.self, TaskItem.self, ConversationRecord.self, TurnRecord.self, | |
| 42 | + configurations: ModelConfiguration(isStoredInMemoryOnly: true) | |
| 43 | + ) | |
| 44 | + let store = PocheStore(container: container) | |
| 45 | + let index = SemanticIndex() | |
| 46 | + let executor = ActionExecutor(bridge: EventKitBridge(), store: store) | |
| 47 | + let confirm = ConfirmCenter(executor: executor) | |
| 48 | + | |
| 49 | + // Exercise validation, proposal, store writes and semantic search. | |
| 50 | + let reminderTool = CreateReminderTool(confirm: confirm) | |
| 51 | + _ = try await reminderTool.call( | |
| 52 | + arguments: .init(title: "Test réseau", dueDate: "2030-06-01T10:00:00", list: nil) | |
| 53 | + ) | |
| 54 | + | |
| 55 | + store.addNote(title: "Idée", content: "Comparer les embeddings locaux") | |
| 56 | + let searchTool = SearchMyDataTool(store: store, index: index) | |
| 57 | + _ = try await searchTool.call(arguments: .init(query: "embeddings")) | |
| 58 | + | |
| 59 | + #expect(NetworkSpyProtocol.requestCount == 0) | |
| 60 | + } | |
| 61 | +} | |
added
PocheTests/SharedInboxTests.swift
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +// | |
| 2 | +// SharedInboxTests.swift | |
| 3 | +// PocheTests | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import SwiftData | |
| 11 | +import Testing | |
| 12 | +@testable import Poche | |
| 13 | + | |
| 14 | +@MainActor | |
| 15 | +struct SharedInboxTests { | |
| 16 | + private func temporaryInboxURL() -> URL { | |
| 17 | + FileManager.default.temporaryDirectory | |
| 18 | + .appendingPathComponent("inbox-\(UUID().uuidString).json") | |
| 19 | + } | |
| 20 | + | |
| 21 | + @Test func appendThenDrainRoundTrips() { | |
| 22 | + let url = temporaryInboxURL() | |
| 23 | + defer { try? FileManager.default.removeItem(at: url) } | |
| 24 | + | |
| 25 | + SharedInbox.append(SharedInboxItem(title: "Lien", content: "https://exemple.fr", date: .now), at: url) | |
| 26 | + SharedInbox.append(SharedInboxItem(title: "Texte", content: "Un extrait partagé", date: .now), at: url) | |
| 27 | + | |
| 28 | + let drained = SharedInbox.drain(at: url) | |
| 29 | + #expect(drained.count == 2) | |
| 30 | + #expect(drained.first?.title == "Lien") | |
| 31 | + // Draining empties the inbox. | |
| 32 | + #expect(SharedInbox.drain(at: url).isEmpty) | |
| 33 | + } | |
| 34 | + | |
| 35 | + @Test func importCreatesNotesFromInbox() throws { | |
| 36 | + let url = temporaryInboxURL() | |
| 37 | + defer { try? FileManager.default.removeItem(at: url) } | |
| 38 | + | |
| 39 | + SharedInbox.append(SharedInboxItem(title: "Article", content: "À lire plus tard", date: .now), at: url) | |
| 40 | + | |
| 41 | + let container = try ModelContainer( | |
| 42 | + for: Note.self, TaskItem.self, ConversationRecord.self, TurnRecord.self, | |
| 43 | + configurations: ModelConfiguration(isStoredInMemoryOnly: true) | |
| 44 | + ) | |
| 45 | + let store = PocheStore(container: container) | |
| 46 | + store.importSharedInbox(from: url) | |
| 47 | + | |
| 48 | + let corpus = store.corpus() | |
| 49 | + #expect(corpus.contains { $0.kind == "note" && $0.title == "Article" }) | |
| 50 | + } | |
| 51 | + | |
| 52 | + @Test func missingInboxIsHarmless() throws { | |
| 53 | + let container = try ModelContainer( | |
| 54 | + for: Note.self, TaskItem.self, ConversationRecord.self, TurnRecord.self, | |
| 55 | + configurations: ModelConfiguration(isStoredInMemoryOnly: true) | |
| 56 | + ) | |
| 57 | + let store = PocheStore(container: container) | |
| 58 | + store.importSharedInbox(from: nil) | |
| 59 | + store.importSharedInbox(from: temporaryInboxURL()) | |
| 60 | + #expect(store.corpus().isEmpty) | |
| 61 | + } | |
| 62 | +} | |
added
README.md
+299 −0
@@ -0,0 +1,299 @@ | ||
| 1 | +<!-- | |
| 2 | + ───────────────────────────────────────────── | |
| 3 | + Poche — Agent personnel 100 % on-device | |
| 4 | + ───────────────────────────────────────────── | |
| 5 | + Author : Simon-Pierre Boucher | |
| 6 | + Contact : contact@spboucher.ai | |
| 7 | + File : README.md | |
| 8 | + Purpose : Documentation complète du projet | |
| 9 | + ───────────────────────────────────────────── | |
| 10 | +--> | |
| 11 | +<div align="center"> | |
| 12 | + | |
| 13 | +<img src="docs/icon.png" width="96" alt="Icône Poche" /> | |
| 14 | + | |
| 15 | +# Poche | |
| 16 | + | |
| 17 | +**Ton agent, sur ton appareil. Hors ligne. Privé. Rien ne quitte ton iPhone.** | |
| 18 | + | |
| 19 | +[](https://developer.apple.com) | |
| 20 | +[](#-limitations-connues) | |
| 21 | +[](https://swift.org) | |
| 22 | +[](https://developer.apple.com/xcode/swiftui/) | |
| 23 | +[-5E5CE6)](https://developer.apple.com/documentation/foundationmodels) | |
| 24 | +[](#-donn%C3%A9es-et-confidentialit%C3%A9) | |
| 25 | +[](#-tests) | |
| 26 | +[%20upload%C3%A9-0D96F6?logo=apple)](#-build-et-distribution) | |
| 27 | +[](mailto:contact@spboucher.ai) | |
| 28 | + | |
| 29 | +*Agent personnel local : chat, rappels, calendrier, notes, tâches et recherche sémantique — | |
| 30 | +propulsé exclusivement par le modèle Apple Intelligence embarqué. Aucune API. Aucun abonnement. Aucun serveur.* | |
| 31 | + | |
| 32 | +</div> | |
| 33 | + | |
| 34 | +--- | |
| 35 | + | |
| 36 | +## 📱 Captures d'écran | |
| 37 | + | |
| 38 | +| Accueil | Conversation + confirmation | Mode sombre | | |
| 39 | +|:---:|:---:|:---:| | |
| 40 | +|  |  |  | | |
| 41 | + | |
| 42 | +--- | |
| 43 | + | |
| 44 | +## 🧭 Le concept | |
| 45 | + | |
| 46 | +Poche est un agent personnel qui vit **entièrement sur l'iPhone**. Le seul moteur | |
| 47 | +génératif de l'application est **Apple Foundation Models**, le modèle ~3 milliards de | |
| 48 | +paramètres d'Apple Intelligence, exécuté sur le Neural Engine de l'appareil. | |
| 49 | + | |
| 50 | +La règle fondatrice du projet (voir [`CLAUDE.md`](CLAUDE.md), la source de vérité) : | |
| 51 | + | |
| 52 | +> Si une fonctionnalité ne peut pas être faite on-device, **elle n'est pas faite**. | |
| 53 | +> On ne dégrade pas la promesse pour ajouter une capacité. | |
| 54 | + | |
| 55 | +Concrètement, c'est un chat en streaming qui sait **agir** : créer des rappels et des | |
| 56 | +événements (EventKit), enregistrer des notes et des tâches (SwiftData), retrouver tes | |
| 57 | +données par recherche sémantique locale (NLEmbedding) — chaque écriture passant par une | |
| 58 | +**carte de confirmation** que l'utilisateur valide explicitement. | |
| 59 | + | |
| 60 | +--- | |
| 61 | + | |
| 62 | +## 📊 Métriques | |
| 63 | + | |
| 64 | +| Métrique | Valeur | | |
| 65 | +|---|---| | |
| 66 | +| Fichiers Swift (app + extension) | **41** | | |
| 67 | +| Fichiers Swift (tests) | **5** | | |
| 68 | +| Lignes de code (app + extension) | **~2 930** | | |
| 69 | +| Lignes de code (tests) | **~320** | | |
| 70 | +| Tests automatisés | **21 / 21 ✓** (5 suites) | | |
| 71 | +| Outils exposés au modèle | **7** (plafond dur : 8) | | |
| 72 | +| Fenêtre de contexte gérée | **4 096 tokens** (condensation à 70 %, réserve réponse 30 %) | | |
| 73 | +| Coût des instructions système | **~230 tokens** (mesuré, documenté dans le code) | | |
| 74 | +| Coût fixe par outil (schéma) | **~120 tokens** | | |
| 75 | +| Requêtes réseau dans le chemin IA | **0** — vérifié par un test tripwire | | |
| 76 | +| Cibles | App iOS + Share Extension + App Intents | | |
| 77 | +| Objectif premier token | **< 400 ms** | | |
| 78 | + | |
| 79 | +--- | |
| 80 | + | |
| 81 | +## 🏗 Architecture | |
| 82 | + | |
| 83 | +``` | |
| 84 | +Poche/ | |
| 85 | +├─ App/ point d'entrée, gate de disponibilité du modèle | |
| 86 | +├─ Chat/ | |
| 87 | +│ ├─ UI/ fil, bulles, composeur, jauge de contexte, accueil | |
| 88 | +│ └─ State/ ChatViewModel, tours de conversation | |
| 89 | +├─ Agent/ | |
| 90 | +│ ├─ Session/ cycle de vie LanguageModelSession + instructions versionnées | |
| 91 | +│ ├─ Budget/ tokens, condensation, recyclage ⚠️ cœur du projet | |
| 92 | +│ ├─ Tools/ un fichier par outil (7 outils) | |
| 93 | +│ ├─ Confirm/ couche de confirmation ⚠️ non contournable | |
| 94 | +│ └─ Schemas/ types @Generable | |
| 95 | +├─ Data/ | |
| 96 | +│ ├─ Store/ SwiftData — notes, tâches, conversations | |
| 97 | +│ ├─ Search/ index sémantique local (NLEmbedding) | |
| 98 | +│ └─ Bridges/ EventKit, App Intents | |
| 99 | +├─ Shared/ boîte d'échange app ↔ extension (groupe d'apps) | |
| 100 | +├─ Support/ dictée on-device, veille thermique | |
| 101 | +└─ Design/ thème, marque, fond | |
| 102 | +PocheShare/ Share Extension (entrée seulement) | |
| 103 | +PocheTests/ 21 tests, 5 suites | |
| 104 | +``` | |
| 105 | + | |
| 106 | +### Le principe non négociable | |
| 107 | + | |
| 108 | +**Le modèle ne fait jamais d'effet de bord.** Il propose un appel d'outil ; l'application | |
| 109 | +valide, affiche, et n'exécute qu'après confirmation de l'utilisateur. | |
| 110 | + | |
| 111 | +```mermaid | |
| 112 | +flowchart LR | |
| 113 | + U[Utilisateur] -->|message| S[LanguageModelSession] | |
| 114 | + S -->|appel d'outil| T[Outil<br/>valide les arguments] | |
| 115 | + T -->|proposition| C[ConfirmCenter<br/>carte de confirmation] | |
| 116 | + C -->|Confirmer| E[ActionExecutor<br/>seul chemin d'écriture] | |
| 117 | + C -->|Modifier| U | |
| 118 | + E --> EK[EventKit] | |
| 119 | + E --> SD[SwiftData] | |
| 120 | + E -->|résultat réel| S | |
| 121 | +``` | |
| 122 | + | |
| 123 | +Il n'existe **aucun chemin de code** qui écrit sans traverser `Confirm/` — pas de mode | |
| 124 | +expert, pas de préférence pour le désactiver. Les dates sont résolues et validées **en | |
| 125 | +Swift** (jamais par le modèle) et affichées en clair sur la carte. | |
| 126 | + | |
| 127 | +--- | |
| 128 | + | |
| 129 | +## 🧮 Le budget de contexte — le chantier central | |
| 130 | + | |
| 131 | +4 096 tokens pour tout : instructions système, schémas des outils, historique complet | |
| 132 | +et réponse à venir. Sans gestion, une app de chat locale **casse en quelques minutes**. | |
| 133 | + | |
| 134 | +```mermaid | |
| 135 | +flowchart TD | |
| 136 | + A[Nouveau message] --> B{Préflight :<br/>estimation ≥ 70 % ?} | |
| 137 | + B -->|non| C[streamResponse] | |
| 138 | + B -->|oui| D[Condensation<br/>appel séparé, sortie @Generable] | |
| 139 | + D --> E[Recyclage : nouvelle session<br/>instructions + résumé + 3 derniers tours] | |
| 140 | + E --> C | |
| 141 | + C -->|exceededContextWindowSize| E | |
| 142 | + C --> F[Réponse streamée<br/>+ jauge discrète mise à jour] | |
| 143 | +``` | |
| 144 | + | |
| 145 | +1. **Mesure continue** — estimateur pessimiste (~3 caractères/token), jauge fine dans l'UI, jamais un chiffre. | |
| 146 | +2. **À 70 % : condensation** — résumé dense et fidèle via un appel séparé (`@Generable`, jamais de String à parser). | |
| 147 | +3. **Recyclage invisible** — nouvelle `LanguageModelSession` réamorcée ; l'utilisateur ne voit rien. | |
| 148 | +4. **Mémoire longue externalisée** — les résumés sont persistés dans SwiftData et réinjectés à la demande via `searchMyData`. | |
| 149 | +5. **Filet de sécurité** — `exceededContextWindowSize` → recycler, rejouer, ne jamais afficher d'erreur technique. | |
| 150 | + | |
| 151 | +--- | |
| 152 | + | |
| 153 | +## 🧰 Catalogue d'outils (7 / plafond 8) | |
| 154 | + | |
| 155 | +| Outil | Type | Bridge | Confirmation | | |
| 156 | +|---|---|---|:---:| | |
| 157 | +| `createReminder` | écriture | EventKit | ✅ | | |
| 158 | +| `createCalendarEvent` | écriture | EventKit | ✅ | | |
| 159 | +| `saveNote` | écriture | SwiftData | ✅ | | |
| 160 | +| `createTask` | écriture | SwiftData | ✅ | | |
| 161 | +| `updateTask` | écriture | SwiftData | ✅ | | |
| 162 | +| `searchMyData` | lecture | SwiftData + NLEmbedding | — | | |
| 163 | +| `getUpcoming` | lecture | EventKit | — | | |
| 164 | + | |
| 165 | +Chaque outil : arguments `@Generable` + `@Guide`, validation hostile (titres tronqués à | |
| 166 | +80 caractères, dates ISO 8601 résolues en Swift, bornes passé/futur), **sortie plafonnée | |
| 167 | +(~200 tokens)** — un outil qui renvoie 30 événements tue la session. | |
| 168 | + | |
| 169 | +> ⚠️ Il n'existe **aucune API publique pour l'app Notes d'Apple** — `saveNote` écrit dans | |
| 170 | +> les notes internes de Poche (SwiftData), et son nom ne prétend pas le contraire. | |
| 171 | +> Aucun outil de suppression en v1. | |
| 172 | + | |
| 173 | +--- | |
| 174 | + | |
| 175 | +## 🔒 Données et confidentialité | |
| 176 | + | |
| 177 | +- **Tout est local** : conversations, notes, tâches, résumés, index de recherche. | |
| 178 | +- **Zéro requête réseau dans le chemin IA** — un test automatisé (`NetworkIsolationTests`) | |
| 179 | + enregistre un espion `URLProtocol` et **échoue si une seule requête sort** pendant | |
| 180 | + l'exercice des outils et de la recherche. | |
| 181 | +- Recherche sémantique via `NLEmbedding` (français) — pas de service externe, même pour l'indexation. | |
| 182 | +- Dictée `SFSpeechRecognizer` avec `requiresOnDeviceRecognition = true` — si l'appareil ne | |
| 183 | + sait pas transcrire localement, le bouton micro n'existe pas. Pas de repli serveur. | |
| 184 | +- Chiffrement au repos via Data Protection. Aucune analytique sur le contenu. | |
| 185 | +- Un refus des garde-fous est un **état normal de l'interface** : message neutre, fil intact. | |
| 186 | + | |
| 187 | +--- | |
| 188 | + | |
| 189 | +## 🧪 Tests | |
| 190 | + | |
| 191 | +```bash | |
| 192 | +xcodebuild test -project Poche.xcodeproj -scheme Poche \ | |
| 193 | + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' | |
| 194 | +``` | |
| 195 | + | |
| 196 | +| Suite | Couverture | Tests | | |
| 197 | +|---|---|:---:| | |
| 198 | +| `DateResolverTests` | ISO 8601, date seule → 9 h, passé/garbage/hostile/trop loin rejetés | 6 | | |
| 199 | +| `ContextBudgetTests` | réserve 30 %, seuil 70 %, prompt plein refusé, estimateur pessimiste | 5 | | |
| 200 | +| `CreateReminderToolTests` | l'outil patron : propose sans écrire, args invalides/manquants/hostiles | 6 | | |
| 201 | +| `NetworkIsolationTests` | tripwire : 0 requête réseau dans le chemin de l'agent | 1 | | |
| 202 | +| `SharedInboxTests` | aller-retour de la boîte partagée, import en notes, boîte absente | 3 | | |
| 203 | + | |
| 204 | +**21 / 21 ✓** — build vert sous Swift 6 concurrence stricte (`SWIFT_STRICT_CONCURRENCY=complete`). | |
| 205 | + | |
| 206 | +--- | |
| 207 | + | |
| 208 | +## 🔨 Build et distribution | |
| 209 | + | |
| 210 | +### Prérequis | |
| 211 | + | |
| 212 | +- Xcode 26+ (SDK iOS 26), [XcodeGen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`) | |
| 213 | +- Pour l'inférence réelle : iPhone 15 Pro ou plus récent (A17 Pro), Apple Intelligence activé | |
| 214 | + | |
| 215 | +### Développement | |
| 216 | + | |
| 217 | +```bash | |
| 218 | +xcodegen generate # project.yml est la source de vérité du projet Xcode | |
| 219 | +open Poche.xcodeproj | |
| 220 | +``` | |
| 221 | + | |
| 222 | +### TestFlight | |
| 223 | + | |
| 224 | +Le build **1.0.0 (1)** est uploadé sur App Store Connect (fiche `ai.spboucher.poche`). | |
| 225 | +Pour les suivants — bumper `CURRENT_PROJECT_VERSION` dans `project.yml`, puis : | |
| 226 | + | |
| 227 | +```bash | |
| 228 | +xcodegen generate | |
| 229 | +xcodebuild -project Poche.xcodeproj -scheme Poche \ | |
| 230 | + -destination 'generic/platform=iOS' -archivePath build/Poche.xcarchive \ | |
| 231 | + archive -allowProvisioningUpdates | |
| 232 | +xcodebuild -exportArchive -archivePath build/Poche.xcarchive \ | |
| 233 | + -exportOptionsPlist build/ExportOptions.plist -exportPath build/export \ | |
| 234 | + -allowProvisioningUpdates | |
| 235 | +``` | |
| 236 | + | |
| 237 | +`ITSAppUsesNonExemptEncryption = false` est déjà déclaré : pas de questionnaire de | |
| 238 | +conformité à chaque build. | |
| 239 | + | |
| 240 | +### Hooks de vérification (DEBUG uniquement) | |
| 241 | + | |
| 242 | +```bash | |
| 243 | +SIMCTL_CHILD_POCHE_AUTOSEND="Bonjour" \ | |
| 244 | +SIMCTL_CHILD_POCHE_DEMO_THREAD=1 \ | |
| 245 | +SIMCTL_CHILD_POCHE_DEMO_CARD=1 \ | |
| 246 | +xcrun simctl launch booted ai.spboucher.poche | |
| 247 | +``` | |
| 248 | + | |
| 249 | +Absents d'un build release (`#if DEBUG`). | |
| 250 | + | |
| 251 | +--- | |
| 252 | + | |
| 253 | +## 🚦 États de disponibilité | |
| 254 | + | |
| 255 | +L'app gère chaque état du modèle avec son propre écran et sa propre action : | |
| 256 | + | |
| 257 | +| État | Écran | Action | | |
| 258 | +|---|---|---| | |
| 259 | +| `available` | Chat | — | | |
| 260 | +| `deviceNotEligible` | Mur définitif, soigné, sans culpabilisation | Jamais de LLM de remplacement | | |
| 261 | +| `appleIntelligenceNotEnabled` | Explication | Bouton « Ouvrir Réglages » | | |
| 262 | +| `modelNotReady` | Téléchargement en cours | Bouton « Réessayer » | | |
| 263 | + | |
| 264 | +--- | |
| 265 | + | |
| 266 | +## ⚠️ Limitations connues | |
| 267 | + | |
| 268 | +| Limitation | Détail | | |
| 269 | +|---|---| | |
| 270 | +| **A17 Pro minimum** | Risque commercial principal — à annoncer sur la fiche App Store, pas au premier lancement | | |
| 271 | +| **Simulateur** | Sur certains hôtes, le pont Apple Intelligence du simulateur est cassé (`promptTemplateNotFound`) ; l'app l'annonce honnêtement après 2 échecs. Sur appareil réel, tout fonctionne | | |
| 272 | +| **Pas d'API token count** | Le SDK iOS 26 n'expose pas `tokenCount(for:)` — estimateur pessimiste en attendant (documenté dans `TokenEstimator.swift`) | | |
| 273 | +| **Modèle ~3B** | Bon en extraction/classification/sortie structurée ; la confirmation systématique est ce qui le rend viable en agent | | |
| 274 | + | |
| 275 | +--- | |
| 276 | + | |
| 277 | +## 🗺 Roadmap | |
| 278 | + | |
| 279 | +- [x] Chat nu : session, streaming, états d'indisponibilité | |
| 280 | +- [x] Budget de contexte : mesure, condensation, recyclage invisible | |
| 281 | +- [x] `createReminder` + couche Confirm (l'outil patron) | |
| 282 | +- [x] Stockage local + `searchMyData` (mémoire longue) | |
| 283 | +- [x] Reste du catalogue (7 outils, chacun testé) | |
| 284 | +- [x] Dictée on-device, App Intents, Share Extension | |
| 285 | +- [x] TestFlight 1.0.0 (1) | |
| 286 | +- [ ] Verrouillage Face ID optionnel à l'ouverture | |
| 287 | +- [ ] Écran de consultation des notes/tâches | |
| 288 | +- [ ] iCloud optionnel, désactivé par défaut, chiffré | |
| 289 | +- [ ] `deleteTask` (quand la confiance dans l'agent sera établie) | |
| 290 | + | |
| 291 | +--- | |
| 292 | + | |
| 293 | +<div align="center"> | |
| 294 | + | |
| 295 | +**Simon-Pierre Boucher** · [contact@spboucher.ai](mailto:contact@spboucher.ai) | |
| 296 | + | |
| 297 | +*Aucune API. Aucun abonnement. Aucun serveur. Juste ton iPhone.* | |
| 298 | + | |
| 299 | +</div> | |
added
docs/icon.png
+0 −0
Binary file not shown.
added
docs/screenshots/accueil.png
+0 −0
Binary file not shown.
added
docs/screenshots/conversation.png
+0 −0
Binary file not shown.
added
docs/screenshots/sombre.png
+0 −0
Binary file not shown.
added
project.yml
+110 −0
@@ -0,0 +1,110 @@ | ||
| 1 | +# project.yml — Poche | |
| 2 | +# XcodeGen manifest. Regenerate with: xcodegen generate | |
| 3 | +# | |
| 4 | +# Author: Simon-Pierre Boucher | |
| 5 | +# Contact: contact@spboucher.ai | |
| 6 | + | |
| 7 | +name: Poche | |
| 8 | +options: | |
| 9 | + bundleIdPrefix: ai.spboucher | |
| 10 | + deploymentTarget: | |
| 11 | + iOS: "26.0" | |
| 12 | + createIntermediateGroups: true | |
| 13 | + | |
| 14 | +settings: | |
| 15 | + base: | |
| 16 | + SWIFT_VERSION: "6.0" | |
| 17 | + SWIFT_STRICT_CONCURRENCY: complete | |
| 18 | + GENERATE_INFOPLIST_FILE: NO | |
| 19 | + DEVELOPMENT_TEAM: 3YM54G49SN | |
| 20 | + MARKETING_VERSION: "1.0.0" | |
| 21 | + CURRENT_PROJECT_VERSION: "1" | |
| 22 | + | |
| 23 | +targets: | |
| 24 | + Poche: | |
| 25 | + type: application | |
| 26 | + platform: iOS | |
| 27 | + sources: | |
| 28 | + - Poche | |
| 29 | + entitlements: | |
| 30 | + path: Poche/Poche.entitlements | |
| 31 | + properties: | |
| 32 | + com.apple.security.application-groups: | |
| 33 | + - group.ai.spboucher.poche | |
| 34 | + info: | |
| 35 | + path: Poche/Info.plist | |
| 36 | + properties: | |
| 37 | + CFBundleDisplayName: Poche | |
| 38 | + CFBundleShortVersionString: $(MARKETING_VERSION) | |
| 39 | + CFBundleVersion: $(CURRENT_PROJECT_VERSION) | |
| 40 | + ITSAppUsesNonExemptEncryption: false | |
| 41 | + UILaunchScreen: {} | |
| 42 | + UISupportedInterfaceOrientations: | |
| 43 | + - UIInterfaceOrientationPortrait | |
| 44 | + NSRemindersFullAccessUsageDescription: "Poche crée les rappels que tu confirmes, directement dans l’app Rappels." | |
| 45 | + NSCalendarsFullAccessUsageDescription: "Poche lit tes prochains événements et crée ceux que tu confirmes." | |
| 46 | + NSSpeechRecognitionUsageDescription: "Poche transcrit ta voix entièrement sur l’appareil, jamais sur un serveur." | |
| 47 | + NSMicrophoneUsageDescription: "Le micro sert uniquement à dicter tes messages, en local." | |
| 48 | + settings: | |
| 49 | + base: | |
| 50 | + PRODUCT_BUNDLE_IDENTIFIER: ai.spboucher.poche | |
| 51 | + TARGETED_DEVICE_FAMILY: "1" | |
| 52 | + CODE_SIGN_STYLE: Automatic | |
| 53 | + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon | |
| 54 | + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor | |
| 55 | + dependencies: | |
| 56 | + - target: PocheShare | |
| 57 | + | |
| 58 | + PocheShare: | |
| 59 | + type: app-extension | |
| 60 | + platform: iOS | |
| 61 | + sources: | |
| 62 | + - PocheShare | |
| 63 | + - Poche/Shared | |
| 64 | + entitlements: | |
| 65 | + path: PocheShare/PocheShare.entitlements | |
| 66 | + properties: | |
| 67 | + com.apple.security.application-groups: | |
| 68 | + - group.ai.spboucher.poche | |
| 69 | + info: | |
| 70 | + path: PocheShare/Info.plist | |
| 71 | + properties: | |
| 72 | + CFBundleDisplayName: Poche | |
| 73 | + CFBundleShortVersionString: $(MARKETING_VERSION) | |
| 74 | + CFBundleVersion: $(CURRENT_PROJECT_VERSION) | |
| 75 | + NSExtension: | |
| 76 | + NSExtensionPointIdentifier: com.apple.share-services | |
| 77 | + NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController | |
| 78 | + NSExtensionAttributes: | |
| 79 | + NSExtensionActivationRule: | |
| 80 | + NSExtensionActivationSupportsText: true | |
| 81 | + NSExtensionActivationSupportsWebURLWithMaxCount: 1 | |
| 82 | + settings: | |
| 83 | + base: | |
| 84 | + PRODUCT_BUNDLE_IDENTIFIER: ai.spboucher.poche.share | |
| 85 | + CODE_SIGN_STYLE: Automatic | |
| 86 | + SKIP_INSTALL: YES | |
| 87 | + | |
| 88 | + PocheTests: | |
| 89 | + type: bundle.unit-test | |
| 90 | + platform: iOS | |
| 91 | + sources: | |
| 92 | + - PocheTests | |
| 93 | + settings: | |
| 94 | + base: | |
| 95 | + GENERATE_INFOPLIST_FILE: YES | |
| 96 | + dependencies: | |
| 97 | + - target: Poche | |
| 98 | + | |
| 99 | +schemes: | |
| 100 | + Poche: | |
| 101 | + build: | |
| 102 | + targets: | |
| 103 | + Poche: all | |
| 104 | + PocheTests: [test] | |
| 105 | + run: | |
| 106 | + config: Debug | |
| 107 | + test: | |
| 108 | + config: Debug | |
| 109 | + targets: | |
| 110 | + - PocheTests | |
| 111 | ||