SPB Git

spb/poche Public

Agent personnel 100 % on-device — SwiftUI + Apple Foundation Models + EventKit + SwiftData. Aucune API, aucun serveur.

Swift 100%

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>
Simon-Pierre Boucher committed 3 h ago (Aug 12, 2026)

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 +[![platform](https://img.shields.io/badge/plateforme-iOS%2026%2B-black?logo=apple)](https://developer.apple.com)
20 +[![puce](https://img.shields.io/badge/mat%C3%A9riel-A17%20Pro%20minimum-orange)](#-limitations-connues)
21 +[![swift](https://img.shields.io/badge/Swift-6.0%20strict%20concurrency-F05138?logo=swift&logoColor=white)](https://swift.org)
22 +[![ui](https://img.shields.io/badge/UI-SwiftUI%20%2B%20%40Observable-blue)](https://developer.apple.com/xcode/swiftui/)
23 +[![llm](https://img.shields.io/badge/LLM-Apple%20Foundation%20Models%20(~3B)-5E5CE6)](https://developer.apple.com/documentation/foundationmodels)
24 +[![réseau](https://img.shields.io/badge/requ%C3%AAtes%20r%C3%A9seau%20IA-0-brightgreen)](#-donn%C3%A9es-et-confidentialit%C3%A9)
25 +[![tests](https://img.shields.io/badge/tests-21%2F21%20%E2%9C%93-brightgreen)](#-tests)
26 +[![testflight](https://img.shields.io/badge/TestFlight-1.0.0%20(1)%20upload%C3%A9-0D96F6?logo=apple)](#-build-et-distribution)
27 +[![auteur](https://img.shields.io/badge/%C2%A9-Simon--Pierre%20Boucher-lightgrey)](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 +| ![Accueil](docs/screenshots/accueil.png) | ![Conversation](docs/screenshots/conversation.png) | ![Mode sombre](docs/screenshots/sombre.png) |
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