SPB Git

spb/poche Public

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

Swift 100%
15.1 KB

# CLAUDE.md — Poche

Agent personnel local. SwiftUI + Foundation Models + EventKit + SwiftData. 100 % on-device. Aucune API LLM externe. Ce fichier est la source de vérité du projet. Le lire avant toute modification.


# 1. Règle fondatrice

Le seul moteur génératif de l'application est Apple Foundation Models, sur l'appareil.

Interdit, sans exception et sans mode caché :

  • OpenAI, Anthropic, Google, Mistral, ou tout autre fournisseur d'API LLM
  • Tout serveur d'inférence, y compris auto-hébergé
  • Tout modèle tiers embarqué (Hugging Face, GGUF, MLX avec poids externes)
  • Toute clé API pour le fonctionnement principal
  • Tout abonnement obligatoire pour utiliser l'IA

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é.

# Décision à trancher avant la v1 : Private Cloud Compute

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.

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.


# 2. Contraintes — à lire avant de promettre quoi que ce soit

# Le contexte : le problème numéro un d'un produit de chat

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.

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.

Preflight obligatoire avant chaque appel :

swift
let model = SystemLanguageModel.default
let budget = try await model.contextSize
let cost = try await model.tokenCount(for: prompt)
guard cost < budget - responseReserve else { try await condense() }

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.

# La capacité réelle du modèle

~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.

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.

# Le matériel

A17 Pro minimum. Sur tout appareil antérieur, SystemLanguageModel.default.availability renvoie indisponible.

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 :

  • 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.
  • L'écran d'incompatibilité est soigné, explique pourquoi, et ne culpabilise pas. Il ne propose jamais un LLM de remplacement.
  • 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.
swift
switch SystemLanguageModel.default.availability {
case .available:                          // OK
case .unavailable(.deviceNotEligible):    // mur, définitif
case .unavailable(.appleIntelligenceNotEnabled):  // action possible : réglages
case .unavailable(.modelNotReady):        // temporaire : attendre, réessayer
@unknown default:                         // traiter comme indisponible
}

# Les garde-fous

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.


# 3. Architecture

text
Poche/
├─ App/
├─ Chat/
│  ├─ UI/               fil de conversation, saisie, streaming
│  └─ State/            conversation, tours, états de chargement
├─ Agent/
│  ├─ Session/          cycle de vie LanguageModelSession
│  ├─ Budget/           tokens, condensation, recyclage  ⚠️ cœur du projet
│  ├─ Tools/            un fichier par outil
│  ├─ Confirm/          validation des actions à effet de bord  ⚠️ non contournable
│  └─ Schemas/          types @Generable
├─ Data/
│  ├─ Store/            SwiftData — notes, tâches, conversations
│  ├─ Search/           index sémantique local (NLEmbedding)
│  └─ Bridges/          EventKit, Fichiers, App Intents
└─ Design/

# Le principe non négociable

Le modèle ne fait jamais d'effet de bord. Il propose un appel d'outil ; l'application décide, valide, et exécute.

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.


# 4. Les outils

# Règle de cardinalité

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.

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.

# Catalogue v1

Outil Type Bridge
createReminder écriture EventKit
createCalendarEvent écriture EventKit
searchMyData lecture SwiftData + embeddings
saveNote écriture SwiftData
createTask / updateTask écriture SwiftData
getUpcoming lecture EventKit

# Correction importante au brief : les Notes d'Apple

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.

Options réelles, à choisir explicitement :

  1. Notes internes à Poche (SwiftData) — recommandé pour la v1. Contrôle total, recherche sémantique possible, cohérent avec le local-first.
  2. Share Extension — l'utilisateur envoie du contenu vers Poche depuis n'importe quelle app, y compris Notes. Entrée seulement.
  3. App Intents — permet à Raccourcis et à Siri d'atteindre Poche, et à Poche d'être orchestrée. Pas un accès à Notes.

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.

# Anatomie d'un outil

swift
struct CreateReminderTool: Tool {
    let name = "createReminder"
    let description = "Crée un rappel avec un titre et une échéance"

    @Generable
    struct Arguments {
        @Guide(description: "Titre du rappel, court et concret")
        let title: String
        @Guide(description: "Date et heure ISO 8601")
        let dueDate: String
        @Guide(description: "Liste de destination, si précisée")
        let list: String?
    }

    func call(arguments: Arguments) async throws -> String {
        // 1. VALIDER : date réelle, dans le futur, titre non vide
        // 2. NE PAS ÉCRIRE. Retourner une proposition en attente.
        // 3. L'écriture EventKit se fait après confirmation utilisateur, hors du modèle.
    }
}

Règles :

  • 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.
  • 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.
  • Permissions EventKit : requestFullAccessToEvents / requestFullAccessToReminders (iOS 17+). Demander au moment du besoin, jamais au lancement.
  • Aucun outil de suppression en v1. deleteTask attendra que la confiance dans l'agent soit établie.

# 5. Gestion du contexte — le chantier central

Sans ça, l'app casse au bout de quelques minutes de conversation. À traiter comme une fonctionnalité, pas comme une correction de bug.

Stratégie, dans l'ordre :

  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).
  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.
  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.
  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.
  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.

Règle : perdre du contexte est acceptable, perdre le fil visiblement ne l'est pas.


# 6. Conversation et interface

  • 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.
  • Écran unique, champ de saisie, fil. Aucun onglet, aucun menu de configuration au premier lancement.
  • 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.
  • 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.
  • Dictée : SFSpeechRecognizer en mode on-device (requiresOnDeviceRecognition = true), sinon la promesse locale est rompue par la porte de derrière.

# La confirmation

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.

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.


# 7. Données et confidentialité

  • Tout en local : conversations, notes, tâches, préférences, index de recherche.
  • Recherche sémantique locale via NLEmbedding + SwiftData. Pas de service externe, même pour l'indexation.
  • 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.
  • Aucune analytique sur le contenu des conversations. Métriques produit uniquement anonymes et agrégées, ou aucune.
  • Chiffrement au repos via Data Protection. Verrouillage optionnel par Face ID à l'ouverture.
  • iCloud : optionnel, désactivé par défaut, chiffré. L'utilisateur choisit.

# 8. Positionnement — attention au message marketing

Le brief propose « Powered entirely by Apple » et « No API. No subscription. Just your iPhone. »

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.

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.


# 9. Performance

  • Premier token en moins de 400 ms. Métrique principale du produit, mesurée à chaque build.
  • L'inférence est sérialisée sur le Neural Engine : jamais plus d'une requête en vol. File d'attente avec annulation.
  • Aucune inférence spéculative en arrière-plan. La batterie est le budget le plus précieux d'une app de chat local.
  • Surveiller ProcessInfo.thermalState ; ralentir avant que le système le fasse à notre place.

# 10. Conventions de code

  • SwiftUI, @Observable, Swift 6, concurrence stricte.
  • Jamais de String libre en sortie de modèle. Toujours @Generable. Une réponse à parser au regex est un bug.
  • Un fichier par outil, dans Agent/Tools. Chaque outil a son test unitaire avec arguments invalides, manquants et hostiles.
  • La couche Confirm est traversée par toute écriture — aucun bridge n'est appelé directement ailleurs.
  • Les instructions système vivent dans un fichier versionné, avec leur coût en tokens documenté en commentaire.
  • Nommage utilisateur en français ; code, commentaires et commits en anglais.

# 11. Ordre de construction

  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.
  2. Gestion du budget de contexte. Avant tout outil. C'est ce qui casse en premier en usage réel.
  3. Un seul outil : createReminder, avec sa confirmation. Le faire parfait. Il définit le patron de tous les autres.
  4. Stockage local + searchMyData. La mémoire longue.
  5. Le reste du catalogue, un outil à la fois, chacun avec ses tests.
  6. Dictée, App Intents, Share Extension.

# 12. Risques connus

Risque Réalité
A17 Pro requis Risque commercial principal. À annoncer sur la fiche App Store, pas au lancement
Contexte 4-8K Casse une app de chat en quelques minutes si ignoré. §5 est prioritaire sur les fonctionnalités
Fiabilité d'un modèle 3B en agent La confirmation systématique est ce qui rend le produit viable. Ne jamais la retirer
Pas d'API pour Notes d'Apple Corriger le brief : notes internes en v1
Faux positifs des garde-fous Traiter le refus comme un état normal de l'interface
« Powered by Apple » en marketing Risque de refus en revue. Décrire la capacité, pas l'affiliation
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