Pièce 1 (backend) : state.rs câble AdaptiveMemoryRecall via build_memory_recall, piloté par le profil embedder chargé depuis embedder.json (fallback none). Défaut none ⇒ NaiveMemoryRecall nu (comportement inchangé, StubEmbedder jamais touché, zéro dépendance lourde). Instance de recall partagée (RecallMemory + LaunchAgent). Chargement du profil isolé sur un runtime dédié (évite le block_on imbriqué). Pièce 2 (frontend) : feature mémoire complète en miroir de skills — MemoryGateway (port) + TauriMemoryGateway + MockMemoryGateway, types domaine Memory/MemoryIndexEntry/MemoryType, MemoryPanel/MemoryEditor/useMemory, onglet sidebar « Memory » dans ProjectsView. CRUD par slug, liens [[slug]] résolus. Le sujet mémoire est clos : CRUD .md + index + rappel adaptatif + injection à l'activation des agents + UI de gestion. Embedder concret ONNX/HTTP reste un follow-up (défaut none = pleinement fonctionnel sans dépendance). Tests: backend 57 binaires verts, frontend 285 tests verts, typecheck OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
72 KiB
IdeA — Cartographie d'Architecture
Document de référence produit par l'Agent Architecture. Fait autorité sur les frontières, ports, adapters, modules et conventions. Toute feature DOIT être validée contre ce document avant développement. Architecture Hexagonale (Ports & Adapters) + SOLID, stricte.
Stack non négociable : Tauri v2 (shell) · Rust (cœur hexagonal) · TypeScript + React (UI) · xterm.js + portable-pty (terminaux) · git2/libgit2 · russh/ssh2 · wsl.exe.
Principe fondateur : IdeA est un IDE 100 % IA dont le rôle est de refléter fidèlement la façon dont on travaille avec des IAs — sans jamais dépendre des commandes, flags ou conventions d'un modèle en particulier. Les deux abstractions de premier rang sont les Agents (instances IA à rôle/contexte définis) et les Skills (workflows réutilisables). Ces deux concepts sont gérés par IdeA de façon universelle : un utilisateur qui passe de Claude Code à Gemini CLI ou Codex retrouve exactement les mêmes Agents et Skills — seul le moteur d'exécution change.
1. Principes : SOLID + Hexagonal, appliqués concrètement
1.1 Règle de dépendance (la seule qui compte)
┌─────────────────────────────────────────────┐
│ Le sens des dépendances │
│ │
Présentation ─► Application ─► Domaine ◄─ Infrastructure │
(React/Tauri) (use cases) (pur) (adapters) │
│ │
└─────────────────────────────────────────────┘
- Le Domaine ne dépend de RIEN : ni Tauri, ni tokio, ni git2, ni portable-pty, ni serde (le moins possible — voir §1.4). Il ne contient que des entités, value objects, règles métier et traits = ports.
- L'Application dépend du Domaine. Elle orchestre les use cases en parlant uniquement aux ports (traits), jamais aux adapters concrets.
- L'Infrastructure dépend du Domaine et de l'Application (elle implémente les ports). Elle contient tous les détails techniques (PTY, FS, git, SSH, WSL, stores).
- La Présentation (Tauri commands + React) dépend de l'Application. Les commandes Tauri sont des adapters entrants (driving adapters) ; les impl de ports sont des adapters sortants (driven adapters).
Aucune flèche ne pointe vers la présentation ou l'infrastructure. L'inversion de dépendance (le D de SOLID) est matérialisée par les traits définis dans le domaine et implémentés dehors.
1.2 SOLID, point par point, traduit IdeA
| Principe | Application concrète |
|---|---|
| S — Single Responsibility | Un use case = une intention métier (LaunchAgent, SyncAgentWithTemplate). Un adapter = une techno (Git2Repository ne fait que du git). Le LayoutNode ne gère que la topologie, pas le rendu. |
| O — Open/Closed | Ajouter une IA = ajouter un profil déclaratif (donnée), pas du code. Ajouter un mode distant = nouvel adapter RemoteHost sans toucher aux use cases. Ajouter une stratégie d'injection de contexte = nouvelle variante d'enum + handler, use case inchangé. |
| L — Liskov | Tout RemoteHost (local, SSH, WSL) est substituable : un use case marche identiquement quelle que soit l'impl. Les contrats (pré/postconditions) des ports sont documentés et respectés par chaque adapter. |
| I — Interface Segregation | Ports fins et ciblés : ProcessSpawner, FileSystem, PtyPort séparés plutôt qu'un System fourre-tout. Un use case ne reçoit que les ports qu'il consomme. |
| D — Dependency Inversion | Domaine définit les traits ; infra les implémente ; l'application reçoit des Arc<dyn Port> par injection (composition root dans la couche Tauri). |
1.3 Hexagonal côté Frontend (React aussi)
L'hexagonal ne s'arrête pas à Rust. Côté React on applique le même découpage :
- Domaine UI / modèles de vue : types TS purs (miroir des DTO), logique de présentation pure (ex. calcul de tailles de cellules d'un
LayoutNode), testable sans React ni Tauri. - Ports UI : interfaces TS (
AgentGateway,TerminalGateway,ProjectGateway,LayoutGateway,GitGateway,RemoteGateway) décrivant ce dont l'UI a besoin, indépendamment du transport. - Adapters UI : implémentation des ports via
@tauri-apps/api(invokepour commands,listenpour events). Remplaçables par des mocks en test/Storybook. - Présentation : composants React, hooks, state (Zustand/Redux) qui consomment les ports UI, jamais
invoke()en direct.
Bénéfice : le frontend est testable et développable sans backend (adapters mock), et la frontière IPC est centralisée en un seul endroit.
1.4 Domaine pur vs adapters — règle pratique Rust
- Le crate
domainest#![no_std]-friendly d'esprit (pas imposé), sans dépendance I/O. Tolérance pragmatique :serdeest autorisé uniquement pour dériver la (dé)sérialisation des entités persistées (manifeste, layout, profils), car c'est une contrainte métier de format, pas un détail technique d'I/O. Les traits/ports y vivent. Pas detokio, pas destd::process, pas destd::fs. - Tout ce qui touche le monde réel (
std::fs,Command, sockets, libgit2, PTY) vit exclusivement dansinfrastructure.
2. Découpage en couches & frontière Rust ↔ Tauri ↔ React
┌───────────────────────────────────────────────────────────────────────┐
│ PRÉSENTATION (Frontend) — TypeScript + React + xterm.js │
│ features/* · ui-ports (gateways) · tauri-adapters (invoke/listen) │
└───────────────────────────────┬───────────────────────────────────────┘
│ IPC Tauri (commands ⇄ events, JSON)
┌───────────────────────────────▼───────────────────────────────────────┐
│ PRÉSENTATION (Backend) — crate `app-tauri` (DRIVING ADAPTER) │
│ #[tauri::command] handlers · event emitters · COMPOSITION ROOT (DI) │
│ PTY byte-stream bridge ⇄ xterm.js │
└───────────────────────────────┬───────────────────────────────────────┘
│ appels de use cases (Arc<UseCase>)
┌───────────────────────────────▼───────────────────────────────────────┐
│ APPLICATION — crate `application` │
│ Use cases / services · DTOs · orchestration · transactions métier │
│ Dépend UNIQUEMENT des ports (traits) du domaine │
└───────────────────────────────┬───────────────────────────────────────┘
│ implémente / consomme
┌───────────────────────────────▼───────────────────────────────────────┐
│ DOMAINE — crate `domain` (PUR, sans I/O) │
│ Entities · Value Objects · Invariants · PORTS (traits) · DomainEvents │
└───────────────────────────────▲───────────────────────────────────────┘
│ implémentent les ports (DRIVEN ADAPTERS)
┌───────────────────────────────┴───────────────────────────────────────┐
│ INFRASTRUCTURE — crate `infrastructure` │
│ portable-pty · git2 · russh/ssh2 · wsl.exe · fs local · md/json store │
└─────────────────────────────────────────────────────────────────────────┘
Frontière IPC Tauri — deux directions
- Commands (Frontend → Backend, request/response) :
invoke("create_project", {...}). Le handler#[tauri::command]désérialise le DTO, appelle le use case, renvoie unResult<DTO, ErrorDTO>. Stateless côté forme : tout l'état vit dans des services managés viatauri::State. - Events (Backend → Frontend, push) : flux PTY (octets/base64), changements de statut d'agent, fin de processus, progrès git, drift de template détecté. Émis via
app_handle.emit(...)/ channels Tauri. L'EventBusdomaine est relayé vers ces events Tauri par un adapter dansapp-tauri.
Décision : le flux PTY haute fréquence passe par des Tauri Channels (
tauri::ipc::Channel) plutôt que des events globaux, pour la perf et l'isolement par session terminal.
3. Modèle de domaine
3.1 Vue d'ensemble (relations)
Workspace 1───* Window 1───* Tab 1───1 Project
│ │
│ 1 ├──* Agent ─────? AgentTemplate (origine)
│ │ │ 1
│ 1 │ └──1 AgentProfile (runtime IA, par réf id)
LayoutTree ├──1 GitRepository
(LayoutNode récursif) ├──1 RemoteHost (Local | Ssh | Wsl)
│ feuilles └──1 AgentManifest (.ideai/agents.json)
▼
TerminalSession 1───? Agent (si lancé par un agent)
3.2 Entités & Value Objects (avec invariants)
ProjectId, AgentId, TemplateId, ProfileId, SessionId, WindowId, TabId, NodeId — VO newtype(Uuid) ou string typée. Invariant : non vide, immuable.
Project (entité, racine d'agrégat projet)
- Champs :
id,name,root: ProjectPath,remote: RemoteRef,created_at. - Invariants :
rootdoit être un chemin absolu et valide pour sonRemoteRef; deux projets ne peuvent partager le même(remote, root).
ProjectPath (VO) — chemin absolu normalisé, conscient de la plateforme cible (POSIX vs Windows vs WSL /mnt/...).
Agent (entité)
- Champs :
id,name,context: AgentContextRef(chemin du.mddans.ideai/),profile_id: ProfileId,origin: AgentOrigin(Scratch|FromTemplate { template_id, synced_version }),synchronized: bool. - Invariants :
synchronized == true⇒origin == FromTemplate{..}(on ne peut pas synchroniser un agent créé from scratch).contextdoit exister à l'activation.profile_iddoit référencer unAgentProfileconnu.
AgentTemplate (entité, store global)
- Champs :
id,name,content_md: MarkdownDoc,version: TemplateVersion,default_profile_id. - Invariants :
versionmonotone croissante ; toute modification ducontent_md⇒version + 1(voir §8).
AgentProfile (entité de config runtime IA — le port AgentRuntime est paramétré par elle)
- Champs :
id,name,command: String,args: Vec<String>,context_injection: ContextInjection,detect: Option<String>,cwd_template: String(vaut toujours"{agentRunDir}"— voir §9.1 et §14.1). - Invariants :
commandnon vide ; cohérence deContextInjection(voir VO ci-dessous).
ContextInjection (VO, enum — cœur du moteur IA flexible)
ContextInjection =
| ConventionFile { target: String } // ex. "CLAUDE.md" / "AGENTS.md" / "GEMINI.md"
| Flag { flag: String } // ex. "--context-file {path}" ou "-f"
| Stdin // pipe du contenu md sur stdin
| Env { var: String } // ex. "AGENT_CONTEXT_FILE"
- Invariants :
ConventionFile.targetest un nom de fichier relatif (pas de.., pas absolu) ;Env.varest un identifiant d'env valide ;Flag.flagnon vide.
TerminalSession (entité)
- Champs :
id,node_id(cellule du layout qui l'héberge),cwd: ProjectPath,kind: SessionKind(Plain|Agent { agent_id }),pty_size: PtySize { rows, cols },status(Starting|Running|Exited{code}). - Invariants : une cellule (feuille de layout) héberge au plus une
TerminalSessionactive.pty_size.rows>0 && cols>0.
LayoutNode / LayoutTree (VO récursif — voir §7 pour le détail complet)
- Invariants : poids relatifs strictement positifs ; somme normalisable ; pas de fusion qui chevauche deux conteneurs distincts ; un
Leafréférence 0 ou 1SessionId.
RemoteHost (VO de stratégie de localisation — abstrait Local/SSH/WSL)
RemoteRef =
| Local
| Ssh { host, port, user, auth: SshAuth, remote_root }
| Wsl { distro: String }
- Invariants :
Ssh.port∈ 1..=65535 ;Wsl.distronon vide ; pourSsh/Wsl, les chemins projet sont interprétés côté distant.
GitRepository (entité)
- Champs :
project_id,root,current_branch,is_dirty. - Invariants :
rootcontient (ou contiendra après init) un.git. État dérivé, rafraîchi via le port.
AgentManifest (entité — image en mémoire de .ideai/agents.json)
- Champs :
entries: Vec<ManifestEntry { agent_id, md_path, template_id?, synchronized, synced_template_version? }>. - Invariants :
synchronized ⇒ template_id.is_some() && synced_template_version.is_some();md_pathunique ; cohérence avec lesAgentchargés.
Workspace / Window / Tab (entités de présentation persistée)
Workspace= ensemble des fenêtres d'une session utilisateur.Window= fenêtre OS ; possède unLayoutTreepar onglet actif et une liste deTab.Tab= onglet ⇔ unProject(1:1).- Invariants : un
Projectouvert apparaît dans exactement unTabà la fois (le drag déplace, ne duplique pas) ; unWindowa ≥ 1Tabou est fermée.
Skill (entité)
- Champs :
id,name,content_md: MarkdownDoc,scope: SkillScope(Global|Project). - Invariants :
namenon vide ;content_mdnon vide. - Un agent référence 0..N skills (dans l'
AgentManifest). Les skills assignés sont injectés dans son convention file à l'activation.
DomainEvent (enum) — ProjectCreated, AgentLaunched, AgentExited, TemplateUpdated, AgentDriftDetected, SkillAssigned, LayoutChanged, RemoteConnected, GitStateChanged, PtyOutput{session_id, bytes}, OrchestratorRequest{requester_id, action} (ce dernier souvent court-circuité vers un Channel).
4. Ports (traits du domaine)
Signatures conceptuelles (Rust idiomatique,
asyncviaasync_traitou retoursFuture; erreurs typées par port). « Consommé par » = use cases. « Implémenté par » = adapters de §5.
AgentRuntime
- Rôle : lancer/piloter la CLI d'une IA selon un
AgentProfile, en gérant l'injection du contexte.md. - Signature :
trait AgentRuntime { fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>; fn prepare_invocation(&self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath) -> Result<SpawnSpec, RuntimeError>; // commande + args + plan d'injection (fichier/flag/stdin/env) } - Consommé par :
LaunchAgent,DetectProfilesUseCase(first-run). - Implémenté par :
CliAgentRuntime(un seul adapter générique piloté par le profil déclaratif — c'est l'Open/Closed). La diversité des IA = données, pas code.
PtyPort (alias domaine de TerminalSessionPort)
- Rôle : ouvrir un pseudo-terminal, lire/écrire, redimensionner, tuer.
- Signature :
trait PtyPort { async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError>; fn write(&self, h: &PtyHandle, data: &[u8]) -> Result<(), PtyError>; fn resize(&self, h: &PtyHandle, size: PtySize) -> Result<(), PtyError>; fn subscribe_output(&self, h: &PtyHandle) -> OutputStream; // flux d'octets async fn kill(&self, h: &PtyHandle) -> Result<ExitStatus, PtyError>; } - Consommé par :
OpenTerminal,LaunchAgent,CloseTerminal. - Implémenté par :
PortablePtyAdapter(local),SshPtyAdapter(PTY distant via russh exec/shell),WslPtyAdapter(PTY viawsl.exe). Sélection par stratégieRemoteRef(Liskov).
RemoteHost
- Rôle : abstraction de la localisation d'exécution (local / SSH / WSL) : exécuter une commande, ouvrir un PTY, accéder au FS, dans le bon contexte.
- Signature :
trait RemoteHost { fn kind(&self) -> RemoteKind; async fn connect(&self) -> Result<(), RemoteError>; fn file_system(&self) -> Arc<dyn FileSystem>; fn process_spawner(&self) -> Arc<dyn ProcessSpawner>; fn pty(&self) -> Arc<dyn PtyPort>; } - Consommé par : tous les use cases qui touchent un projet (résolvent leurs ports via le
RemoteHostdu projet → transparence local/distant). - Implémenté par :
LocalHost,SshHost(russh/ssh2),WslHost(wsl.exe). C'est la stratégie qui unifie les 3 modes.
ProcessSpawner
- Rôle : lancer un process non interactif et récupérer sortie/exit (ex.
detect, commandes git hors libgit2, scripts). - Signature :
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>; - Consommé par :
DetectProfilesUseCase, services divers. - Implémenté par :
LocalProcessSpawner,SshProcessSpawner,WslProcessSpawner.
FileSystem
- Rôle : lecture/écriture/listing/symlink, neutre vis-à-vis de la localisation.
- Signature :
trait FileSystem { async fn read(&self, p: &RemotePath) -> Result<Vec<u8>, FsError>; async fn write(&self, p: &RemotePath, data: &[u8]) -> Result<(), FsError>; async fn exists(&self, p: &RemotePath) -> Result<bool, FsError>; async fn create_dir_all(&self, p: &RemotePath) -> Result<(), FsError>; async fn list(&self, p: &RemotePath) -> Result<Vec<DirEntry>, FsError>; async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError>; } - Consommé par :
AgentContextStore,ProjectStore, injectionconventionFile, etc. - Implémenté par :
LocalFileSystem(std::fs/tokio::fs),SshFileSystem(SFTP),WslFileSystem(viawsl.exeou chemins\\wsl$).
TemplateStore
- Rôle : CRUD des
AgentTemplatedans le store global IDE + versioning. - Signature :
list / get / save / delete / bump_version. - Consommé par :
CreateTemplate,UpdateTemplate,CreateAgentFromTemplate,SyncAgentWithTemplate. - Implémenté par :
FsTemplateStore(md + index json dans le dossier de données app).
ProjectStore
- Rôle : persistance de la liste des projets connus, workspaces, windows, tabs, layouts.
- Signature :
list_projects / load_project / save_project / save_workspace / load_workspace. - Consommé par :
CreateProject,OpenProject, persistance fenêtres/onglets/layout. - Implémenté par :
FsProjectStore(json dans données app pour le registre ; layout par projet dans.ideai/).
AgentContextStore
- Rôle : lire/écrire les
.mdd'agents et le manifeste.ideai/agents.json(au sein du projet, via leFileSystemduRemoteHost). - Signature :
trait AgentContextStore { async fn read_context(&self, project: &Project, agent: &AgentId) -> Result<MarkdownDoc, StoreError>; async fn write_context(&self, project: &Project, agent: &AgentId, md: &MarkdownDoc) -> Result<(), StoreError>; async fn load_manifest(&self, project: &Project) -> Result<AgentManifest, StoreError>; async fn save_manifest(&self, project: &Project, m: &AgentManifest) -> Result<(), StoreError>; } - Consommé par :
CreateAgent*,LaunchAgent,SyncAgentWithTemplate. - Implémenté par :
IdeaiContextStore(composeFileSystem, écrit.ideai/).
GitRepository
- Rôle : opérations git du projet.
- Signature :
status / stage / unstage / commit / branches / checkout / current_branch / diff / log / pull / push / clone / init. - Consommé par : use cases Git.
- Implémenté par :
Git2Repository(libgit2, local) ; sur SSH/WSL,RemoteGitRepositorydélègue à git CLI viaProcessSpawnerquand libgit2 ne peut pas atteindre le FS distant (point ouvert §13).
EventBus
- Rôle : publier/souscrire les
DomainEvent(découple émetteurs et présentation). - Signature :
fn publish(&self, e: DomainEvent); fn subscribe(&self) -> EventStream; - Consommé par : tous use cases (publient) ; l'adapter Tauri (souscrit → relaye en events/channels IPC).
- Implémenté par :
TokioBroadcastEventBus(in-process), relayé parTauriEventRelay.
Clock & IdGenerator (ports utilitaires — testabilité)
- Rôle : éliminer le non-déterminisme (
now(),uuid) du domaine/application. - Implémenté par :
SystemClock/UuidGenerator(prod),FixedClock/SeqIdGenerator(tests).
5. Adapters (impl concrètes par port)
| Port | Adapter(s) | Techno | Notes |
|---|---|---|---|
AgentRuntime |
CliAgentRuntime |
piloté par AgentProfile |
Construit SpawnSpec + plan d'injection. Un seul adapter, N profils. |
PtyPort |
PortablePtyAdapter |
portable-pty | Local. Stream octets → Channel Tauri. |
SshPtyAdapter |
russh (channel shell/exec + pty req) | Distant SSH. | |
WslPtyAdapter |
wsl.exe -d <distro> + portable-pty |
PTY dans la distro. | |
RemoteHost |
LocalHost / SshHost / WslHost |
— / russh,ssh2 / wsl.exe | Stratégie ; fabrique FS/Spawner/PTY adaptés. |
ProcessSpawner |
LocalProcessSpawner |
std/tokio Command |
|
SshProcessSpawner |
russh exec | ||
WslProcessSpawner |
wsl.exe |
||
FileSystem |
LocalFileSystem |
tokio::fs | |
SshFileSystem |
SFTP (ssh2/russh-sftp) | ||
WslFileSystem |
\\wsl$\ / wsl.exe cat… |
||
TemplateStore |
FsTemplateStore |
tokio::fs + serde_json | Dossier données app. |
ProjectStore |
FsProjectStore |
tokio::fs + serde_json | Registre projets + workspace. |
AgentContextStore |
IdeaiContextStore |
compose FileSystem |
Écrit .ideai/. |
GitRepository |
Git2Repository |
git2 | Local. |
RemoteGitRepository |
git CLI via ProcessSpawner |
SSH/WSL fallback. | |
EventBus |
TokioBroadcastEventBus (+ TauriEventRelay) |
tokio::broadcast | Relais vers IPC. |
Clock/IdGenerator |
SystemClock/UuidGenerator |
std/uuid | Mocks en test. |
Adapters entrants (driving) : handlers #[tauri::command] (frontend → app) + TauriEventRelay (app → frontend). Côté UI : tauri-adapters implémentant les gateways TS.
6. Use cases / services applicatifs
Chaque use case : un struct
XxxUseCaseportant ses ports enArc<dyn Port>, une méthodeexecute(input: XxxInput) -> Result<XxxOutput, AppError>. Single Responsibility. Aucune dépendance à Tauri.
| Use case | Rôle | Ports consommés |
|---|---|---|
CreateProject |
Crée un projet (project root), init .ideai/, registre. |
ProjectStore, FileSystem, IdGenerator, EventBus |
OpenProject |
Charge projet, manifeste, layout, résout RemoteHost. |
ProjectStore, AgentContextStore, RemoteHost |
CloseProject / CloseTab |
Persiste l'état, libère PTYs. | ProjectStore, PtyPort, EventBus |
DetectProfiles (first-run) |
Teste detect de chaque profil candidat. |
AgentRuntime, ProcessSpawner |
ConfigureProfiles |
Enregistre profils choisis/édités/custom. | TemplateStore/profile store, FileSystem |
CreateAgentFromScratch |
Crée agent + .md, met à jour manifeste. |
AgentContextStore, IdGenerator |
CreateAgentFromTemplate |
Copie le content_md du template → agent ; lie origine + version + synchronized. |
TemplateStore, AgentContextStore |
UpdateTemplate |
Modifie un template, bump version, signale drift aux agents liés. | TemplateStore, EventBus |
DetectAgentDrift |
Compare synced_template_version vs template.version. |
TemplateStore, AgentContextStore |
SyncAgentWithTemplate |
Applique la MAJ template→agent si synchronized. |
TemplateStore, AgentContextStore, EventBus |
LaunchAgent |
Résout profil+contexte, prépare injection, ouvre cellule PTY au bon cwd, spawn CLI. |
AgentRuntime, AgentContextStore, RemoteHost→PtyPort/FileSystem, EventBus |
OpenTerminal |
Ouvre un PTY simple dans une cellule. | RemoteHost→PtyPort, EventBus |
WriteToTerminal / ResizeTerminal / CloseTerminal |
I/O PTY. | PtyPort |
MutateLayout (split/merge/resize/move) |
Applique une opération sur le LayoutTree (logique pure dans le domaine, persistée ici). |
ProjectStore (persistance) |
ConnectRemote (SSH/WSL) |
Établit la connexion, valide l'accès au root. | RemoteHost, FileSystem |
MoveTabToNewWindow |
Détache un onglet → nouvelle fenêtre (réaffectation WindowId). |
ProjectStore, EventBus |
| Use cases Git | GitStatus, GitCommit, GitCheckout, GitPush, … |
GitRepository, EventBus |
7. Modèle de layout terminal (grille tableur récursive + fusion)
7.1 Structure de données
La grille « type tableur, lignes/colonnes imbriquées indépendamment + fusion » est modélisée par un arbre de splits récursif où chaque conteneur définit son propre découpage. La fusion est obtenue nativement : fusionner = ne pas subdiviser une zone (un Leaf couvre plusieurs « cellules visuelles » d'un parent voisin). Pour le cas Excel pur (fusion arbitraire chevauchant la grille), on superpose un modèle GridContainer avec spans.
enum LayoutNode {
Leaf(LeafCell),
Split(SplitContainer),
Grid(GridContainer),
}
struct LeafCell {
id: NodeId,
session: Option<SessionId>, // 0 ou 1 terminal
}
struct SplitContainer { // découpage simple binaire/n-aire pondéré
id: NodeId,
direction: Direction, // Row (colonnes) | Column (lignes)
children: Vec<WeightedChild>, // ordre = gauche→droite / haut→bas
}
struct WeightedChild { node: LayoutNode, weight: f32 } // poids = part redimensionnable
struct GridContainer { // grille tableur avec fusion (spans)
id: NodeId,
col_weights: Vec<f32>, // largeurs de colonnes
row_weights: Vec<f32>, // hauteurs de lignes
cells: Vec<GridCell>, // placements avec spans (fusion)
}
struct GridCell {
node: LayoutNode, // récursif : une cellule peut re-contenir un Split/Grid
row: u16, col: u16,
row_span: u16, // ≥1 ; >1 = cellules fusionnées verticalement
col_span: u16, // ≥1 ; >1 = cellules fusionnées horizontalement
}
- Lignes/colonnes indépendantes par zone : chaque
SplitContainer/GridContainera ses propres poids ⇒ pas de grille uniforme rigide. - Imbrication : un enfant peut être un nouveau
Split/Grid⇒ « N colonnes dans une ligne, M lignes dans une colonne » de façon arbitraire. - Fusion :
row_span/col_spandansGridContainer(modèle tableur fidèle) ou simplement unLeafplus grand viaSplitContainer(cas courant). Le domaine supporte les deux ; l'UI choisit la représentation selon l'interaction.
7.2 Invariants (validés dans le domaine, testables sans I/O)
- Tous les
weight > 0. Les poids sont relatifs (l'UI normalise pour le rendu). - Dans un
GridContainer: aucune superposition de spans ; toute la surface couverte ;row+row_span ≤ rows,col+col_span ≤ cols. - Un
SessionIdn'apparaît que dans un seulLeaf. - Les opérations
split,merge,resize,movesont des fonctions puresLayoutTree -> Result<LayoutTree, LayoutError>(immutabilité ⇒ testabilité, undo/redo facile).
7.3 Sérialisation & persistance
- Sérialisé en JSON (serde,
tag/contentpour l'enum) →.ideai/layout.json(par projet, donc voyage avec le projet, y compris distant). - Le
Workspace/Window/Tab(organisation des fenêtres OS) est persisté côté store global IDE (machine-local, pas dans le projet) car lié à l'écran de l'utilisateur, pas au code.
8. Synchronisation template → agents
8.1 Versioning
AgentTemplate.version: u64monotone.UpdateTemplateincrémente la version à chaque changement decontent_md. Un hash du contenu (content_hash) est aussi stocké pour détecter les éditions hors-app.- Chaque
ManifestEntryd'agent lié gardesynced_template_version= version du template au dernier sync réussi.
8.2 Détection de drift
drift(agent) =
agent.synchronized
&& agent.origin == FromTemplate{ template_id, .. }
&& template_store.get(template_id).version > entry.synced_template_version
DetectAgentDrift est lancé à OpenProject et après chaque UpdateTemplate ; émet AgentDriftDetected { agent_id, from, to } → badge UI.
8.3 Application de la MAJ (SyncAgentWithTemplate)
1. Charger template (version courante) + manifeste projet.
2. Pour chaque agent ciblé avec synchronized==true :
a. Stratégie de MAJ = REMPLACEMENT du .md par content_md du template
(le contexte d'un agent synchronisé est "possédé" par le template).
→ Variante future : merge 3-way si l'agent a un bloc local marqué.
b. write_context(agent, template.content_md)
c. entry.synced_template_version = template.version
3. save_manifest. publish(AgentSynced{..}).
8.4 Agents non synchronisés
synchronized == false: ne reçoivent jamais de MAJ auto. Ils gardent leur.mdlibre. On peut afficher « une nouvelle version du template existe » (info) mais aucune écriture n'a lieu sans action explicite (qui basculeraitsynchronizedou ferait un sync ponctuel one-shot).- Agents
Scratch: aucun lien template, hors périmètre de sync.
9. Stockage & arborescence des fichiers
9.1 Dans le projet — .ideai/ (voyage avec le code, versionnable)
<project_root>/
├── .ideai/
│ ├── agents.json # AgentManifest (mapping md ↔ template ↔ sync ↔ version)
│ ├── layout.json # LayoutTree de l'onglet (sérialisé)
│ ├── project.json # méta projet local (nom, profil par défaut, remote ref)
│ ├── agents/
│ │ ├── reviewer.md # contexte d'un agent de projet
│ │ ├── backend-dev.md
│ │ └── ...
│ ├── skills/
│ │ ├── code-review.md # contexte d'un skill (voir §14.2)
│ │ ├── simplify.md
│ │ └── ...
│ └── run/
│ ├── <agent-id>/ # cwd isolé par agent actif (créé à l'activation)
│ │ └── CLAUDE.md # fichier de convention généré par IdeA (profil-dépendant)
│ └── ...
└── (aucun CLAUDE.md/AGENTS.md/GEMINI.md à la racine — jamais — voir §14.1)
Schéma agents.json :
{
"version": 1,
"agents": [
{
"id": "a3f1...",
"name": "Backend Dev",
"md": "agents/backend-dev.md",
"profileId": "claude-code",
"origin": { "type": "fromTemplate", "templateId": "tpl-backend", "syncedTemplateVersion": 4 },
"synchronized": true
},
{
"id": "b7c2...",
"name": "Ad-hoc",
"md": "agents/adhoc.md",
"profileId": "codex-cli",
"origin": { "type": "scratch" },
"synchronized": false
}
]
}
9.2 Store global IDE (données app, hors projet, machine-local)
Emplacement résolu via Tauri path API (AppData/~/.local/share/IdeA/~/Library/Application Support/IdeA).
<app_data_dir>/IdeA/
├── profiles.json # AgentProfile[] configurés (first-run + custom + édités)
├── settings.json # préférences IDE
├── workspace.json # Workspace/Window/Tab + quel projet dans quel onglet (machine-local)
└── templates/
├── index.json # [{id, name, version, contentHash, defaultProfileId}]
└── md/
├── tpl-backend.md
├── tpl-reviewer.md
└── ...
Schéma profiles.json (item) : exactement le profil déclaratif de CONTEXT.md §9 (id, name, command, args, contextInjection{strategy,target/flag/var}, detect, cwd).
Formats : contextes & templates en Markdown ; tout le reste en JSON (serde). Pas de base de données : fichiers plats, simples, diffables, portables (AppImage friendly).
Note
:
.ideai/run/contient des répertoires d'exécution éphémères (créés à l'activation, nettoyés à la fermeture). Leur contenu (convention files générés) ne doit pas être versionné dans git — ajouter.ideai/run/au.gitignoredu projet.
10. Arborescence du repo
10.1 Décision : workspace Cargo multi-crate
Multi-crate retenu (vs mono-crate) pour forcer la règle de dépendance à la compilation : le crate domain ne peut littéralement pas dépendre de infrastructure si ce n'est pas dans son Cargo.toml. C'est la garantie mécanique de l'hexagonal (mieux qu'une convention). Coût : un peu de cérémonie de workspace — acceptable et même souhaitable ici vu le découpage en lots/agents (§12).
IdeA/
├── Cargo.toml # [workspace] members
├── ARCHITECTURE.md
├── CONTEXT.md
├── crates/
│ ├── domain/ # PUR : entities, VO, ports (traits), domain events, layout logic
│ │ └── src/{project,agent,template,profile,terminal,layout,remote,git,ports,events}.rs
│ ├── application/ # use cases, DTOs, AppError ; dépend de domain
│ │ └── src/{project,agent,template,terminal,layout,remote,git}/
│ ├── infrastructure/ # adapters ; dépend de domain (+ application pour DTO si besoin)
│ │ └── src/{pty,fs,process,remote,git,store,runtime,eventbus}/
│ └── app-tauri/ # binaire Tauri : commands, events, COMPOSITION ROOT (DI)
│ ├── src/{commands,events,state,main.rs}
│ ├── tauri.conf.json
│ ├── build.rs
│ └── icons/, bundle (NSIS + AppImage)
├── frontend/ # TypeScript + React (Vite)
│ ├── package.json, vite.config.ts, index.html
│ └── src/
│ ├── domain/ # types & logique de vue purs (miroir DTO, calc layout)
│ ├── ports/ # gateways TS (interfaces) : AgentGateway, TerminalGateway, ...
│ ├── adapters/ # impl gateways via @tauri-apps/api (invoke/listen/Channel)
│ │ └── mock/ # impl mock pour dev/test/storybook
│ ├── features/ # par feature : projects, agents, templates, terminals, layout, git, remote, first-run
│ │ └── <feature>/{components,hooks,store,index.ts}
│ ├── shared/ # ui kit, xterm wrapper, design system
│ └── app/ # bootstrap, routing, providers (DI des adapters)
└── docs/ # ADRs, schémas
app-tauri = seul endroit qui connaît tous les crates : il instancie les adapters concrets et injecte dans les use cases (composition root). Personne d'autre ne fait de new ConcreteAdapter.
11. Stratégie de tests
| Couche | Type de test | Comment / où |
|---|---|---|
domain |
Unitaires purs (sans I/O, sans async) | #[cfg(test)] mod tests par module. Invariants d'entités, opérations de layout (split/merge/resize), détection de drift, validation ContextInjection. Déterministe via FixedClock/SeqIdGenerator. |
application |
Unitaires avec ports mockés | Chaque use case testé avec des mocks de ports (mockall ou fakes manuels). Ex. LaunchAgent vérifie qu'il appelle prepare_invocation puis pty.spawn avec le bon cwd et plan d'injection. Aucun vrai PTY/FS/git. |
infrastructure |
Tests d'intégration ciblés | Par adapter : LocalFileSystem sur tmpdir, Git2Repository sur repo temporaire, PortablePtyAdapter lance echo. SSH/WSL : tests #[ignore] gated derrière feature/env (CI conditionnelle). |
app-tauri |
Tests des commands (mapping DTO ↔ use case) | Wiring testé avec use cases réels + adapters in-memory. |
Frontend domain/ports |
Vitest (unitaires purs) | Logique de vue, calc tailles cellules, réducteurs de state. |
Frontend features |
React Testing Library + gateways mock | Composants testés avec adapters mock ⇒ sans backend. |
| E2E (plus tard) | Playwright / tauri-driver |
Smoke tests des parcours clés. |
Clé de testabilité : grâce aux ports, le domaine et l'application se testent 100 % sans I/O. C'est l'argument central de l'hexagonal et le socle du cycle dev↔test (chaque agent dev appairé à un agent test, cf. CONTEXT §3). Règle d'or : une feature n'est verte que quand cargo test -p <crate> et vitest passent.
12. Découpage en lots/features livrables
Chaque lot = périmètre autonome, validable par le cycle dev/test, confiable à un binôme (agent dev + agent test). Ordonnés par dépendance.
| # | Lot | Contenu | Crates/zones |
|---|---|---|---|
| L0 | Socle domaine & ports | Entities, VO, tous les traits ports, domain events, AppError. Aucun adapter. |
domain (+ ports utilitaires) |
| L1 | Composition root & IPC | app-tauri : DI, registre de commands/events, bridge PTY↔Channel, gateways TS + adapters Tauri + mocks. |
app-tauri, frontend/ports+adapters |
| L2 | Projets & stockage | CreateProject/OpenProject/CloseProject, FsProjectStore, LocalFileSystem, init .ideai/. UI projets/onglets. |
application/project, infrastructure/{fs,store}, frontend/features/projects |
| L3 | Terminaux & PTY (local) | PtyPort + PortablePtyAdapter, use cases terminal, wrapper xterm.js, flux Channel. |
infrastructure/pty, application/terminal, frontend/features/terminals |
| L4 | Layout tableur | Logique pure LayoutTree (déjà en L0 partiellement), MutateLayout, persistance layout.json, UI grille redimensionnable + fusion. |
domain/layout, application/layout, frontend/features/layout |
| L5 | Profils IA & runtime | AgentProfile, CliAgentRuntime, DetectProfiles, first-run wizard, profiles.json. |
infrastructure/runtime, application/agent, frontend/features/first-run |
| L6 | Agents & contextes | AgentContextStore/IdeaiContextStore, CRUD agents, LaunchAgent (injection + spawn + cellule). |
application/agent, infrastructure/store, frontend/features/agents |
| L7 | Templates & synchro | TemplateStore, versioning, DetectAgentDrift, SyncAgentWithTemplate. UI templates + badges drift. |
application/template, infrastructure/store, frontend/features/templates |
| L8 | Git | GitRepository/Git2Repository, use cases git, UI git. |
infrastructure/git, application/git, frontend/features/git |
| L9 | Remote (SSH + WSL) | RemoteHost stratégie, SshHost/WslHost, adapters FS/PTY/Spawner distants, RemoteGitRepository. UI connexion. |
infrastructure/remote, application/remote, frontend/features/remote |
| L10 | Fenêtres & multi-window | Workspace/Window/Tab, MoveTabToNewWindow, drag d'onglet → nouvelle fenêtre OS Tauri. |
application, app-tauri, frontend/app |
| L11 | Packaging & livraison | Tauri bundle : NSIS setup.exe, AppImage multi-distro, CI Linux+Windows. |
app-tauri, CI |
| L12 | Skills | Entité Skill, SkillStore, CRUD skills global+projet, assignation agent↔skills, injection dans convention file à l'activation. UI onglet Skills. |
domain/skill, application/skill, infrastructure/store, frontend/features/skills |
| L13 | OrchestratorApi | File-watcher .ideai/requests/, port OrchestratorApi, adapter FsOrchestratorAdapter, actions spawn_agent/stop_agent/update_agent_context. |
infrastructure/orchestrator, application/agent, app-tauri |
| L14 | Mémoire | Base de connaissance projet model-agnostic. Modèle 2 étages : .md source de vérité (port MemoryStore), rappel adaptatif (port MemoryRecall), embeddings déclaratifs (port Embedder), bascule auto sur seuil. Découpé en sous-lots A/B/C (voir §14.5). |
domain/memory, application/memory, infrastructure/store, frontend/features/memory |
14. Décisions d'architecture figées (2026-06-06)
14.1 Isolation du cwd par agent — résolution de la collision de contexte
Problème : plusieurs agents du même profil (ex. deux instances Claude Code) sur le même project root produisaient une collision — le fichier de convention (CLAUDE.md, AGENTS.md…) est un emplacement fixe unique à la racine.
Décision : le cwd du PTY d'un agent n'est jamais le project root. C'est .ideai/run/<agent-id>/, un dossier créé par IdeA à l'activation et nettoyé à la fermeture.
Convention file généré par IdeA : IdeA écrit dans ce dossier le fichier conventionnel attendu par le profil (CLAUDE.md, AGENTS.md, etc.). Ce fichier contient :
- La persona/rôle de l'agent (son
.mddans.ideai/agents/). - Le chemin absolu du project root (pour que l'agent sache où opérer).
- Les skills actifs assignés à cet agent (voir §14.2).
- Le rappel mémoire du projet (index/hooks), si présent (voir §14.5.4).
Avantages :
- Zéro collision entre agents, même N instances du même profil.
- Universel : fonctionne pour toute CLI qui lit un fichier de convention depuis son cwd — aucun flag ou commande propre à un modèle.
- Zéro dépendance à git (git est optionnel — supprimer un repo ne casse rien).
Impact sur AgentProfile.cwd_template : la valeur est toujours "{agentRunDir}", jamais "{projectRoot}". La connaissance du project root passe par le contenu du convention file, pas par le cwd.
14.2 Skills — abstraction universelle de workflows réutilisables
Définition : un Skill est un workflow/comportement réutilisable qu'on peut assigner à un agent. Exemples : code-review, simplify, run-tests, explain. C'est l'équivalent universel des slash-commands de Claude Code — mais sans dépendance à la syntaxe /command d'un modèle particulier.
Stockage :
- Skills globaux (templates) :
<app_data>/IdeA/skills/(store global IDE, réutilisables entre projets). - Skills de projet :
.ideai/skills/<skill-name>.md(spécifiques au projet).
Injection : les skills assignés à un agent sont inclus dans son convention file généré par IdeA au moment de l'activation. L'agent reçoit donc ses skills comme du contexte textuel — aucun mécanisme CLI propriétaire.
Entité Skill (à ajouter au domaine) :
- Champs :
id,name,content_md: MarkdownDoc,scope: SkillScope(Global|Project). - Un agent peut avoir 0..N skills assignés (stocké dans l'
AgentManifest).
Port SkillStore : CRUD skills globaux + skills projet (compose FileSystem/store global selon le scope).
14.3 OrchestratorApi — spawn d'agents depuis un agent ou depuis l'UI
Objectif : qu'un agent orchestrateur puisse demander à IdeA de créer un nouvel agent (visible dans la grille et dans l'onglet Agents), exactement comme le ferait l'utilisateur via l'UI.
Mécanisme : file-watching sur .ideai/requests/<requester-id>/. L'orchestrateur écrit un fichier JSON de requête :
{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code", "context": "agents/dev-backend.md" }
IdeA détecte le fichier, exécute la même logique que LaunchAgent déclenché depuis l'UI, crée la cellule terminal, inscrit l'agent dans l'onglet Agents, puis supprime le fichier et écrit une réponse.
Règle : l'orchestrateur ne spawne jamais lui-même un process CLI — il délègue à IdeA. IdeA reste l'unique source de vérité du cycle de vie des agents.
Port OrchestratorApi (adapter entrant, driven by file-watcher) : surveille .ideai/requests/, désérialise les commandes, les traduit en appels de use cases (LaunchAgent, StopAgent…). Implémenté dans infrastructure/orchestrator.
Actions supportées (v1) : spawn_agent, stop_agent, update_agent_context, create_skill.
create_skill permet à un orchestrateur de créer un skill réutilisable exactement comme l'UI (use case CreateSkill). Champs : name, context (corps Markdown du skill), scope optionnel ("global" | "project", défaut project). Exemple :
{ "action": "create_skill", "name": "deploy", "context": "# Étapes de déploiement…", "scope": "project" }
14.4 Git = intégration optionnelle, zéro dépendance fonctionnelle
Git est un outil posé par-dessus l'IDE, pas un socle. Supprimer le repo git d'un projet ne doit casser aucune feature d'IdeA (agents, terminaux, layout, skills, orchestration). Les use cases git (L8) sont un module indépendant ; rien d'autre n'en dépend. Cette contrainte s'applique à toute future décision de conception.
14.5 Système de mémoire — base de connaissance projet model-agnostic (L14)
Objectif : doter chaque projet d'une mémoire persistante (préférences, décisions, feedback, références) que les agents lisent et enrichissent, sans jamais dépendre d'un modèle ou d'une CLI (principe fondateur). La mémoire est commune au project root, partagée par tous les agents (décision : mémoire au niveau projet, pas par run/<id>).
Modèle 2 étages (hybride)
- Étage 1 — fichiers
.md(source de vérité) : chaque note = un.md(frontmatter YAMLname/description/metadata.type+ corps Markdown) sous.ideai/memory/<slug>.md. Un index agrégéMEMORY.md(une ligne- [Titre](slug.md) — hookpar note) est dérivé, reconstructible. Les notes se référencent par liens[[slug]]. C'est le seul étage qui fait foi. - Étage 2 — index de rappel sémantique vectoriel (dérivé) : embeddings des notes, jamais source de vérité, reconstructible et supprimable à tout moment. Sert uniquement à accélérer/cibler le rappel quand la mémoire devient trop grosse pour être lue intégralement.
Bascule étage 1 ↔ étage 2 (automatique, sur seuil objectif)
Le rappel (port MemoryRecall) est adaptatif : tant que la taille de la mémoire reste sous un budget de tokens configurable, l'adapter naïf lit MEMORY.md intégralement (zéro dépendance). Au-dessus du seuil, l'adapter vectoriel prend le relais. La bascule est une décision objective (taille vs budget), pas un choix manuel. Défaut : none (rappel naïf, aucun embedder) — esprit Linux « rien d'imposé, tout fonctionnel ».
Ports (frontières domaine)
MemoryStore(fait — LOT A étage 1) : CRUD des.md+ indexMEMORY.mddérivé +resolve_links(ignore les liens cassés). AdapterFsMemoryStore(composeFileSystem, location-neutral). ErreursMemoryError.MemoryRecall(LOT B) : rappel adaptatif d'un sous-ensemble pertinent de notes pour une requête. Deux adapters substituables (Liskov) :NaiveMemoryRecall(lecture intégrale viaMemoryStore) et, plus tard,VectorMemoryRecall(étage 2).Embedder(LOT C) : production de vecteurs d'embedding, décrit par des profils déclaratifs façon CLI LLM (§9). Stratégies :localOnnx/localServer/api/none.none= pas d'embedder ⇒ rappel naïf forcé.
Conformité hexagonale
MemoryStore/MemoryRecall/Embedder sont des traits du domaine ; les adapters vivent en infrastructure. L'application ne parle qu'aux ports. Le domaine memory reste I/O-free (le format YAML/index est possédé par l'adapter FsMemoryStore, pas par le domaine). La mémoire est indépendante de git (§14.4) et du moteur IA.
Découpage en sous-lots (binôme dev+test par sous-lot)
- LOT A — Étage 1
.md: domaine + adapter faits et verts. Reste à livrer : use casesapplication/memory+ câblage app-tauri (commandes + DTO ; les eventsMemory*et leurs DTO existent déjà). Voir §14.5.1. - LOT B — Rappel adaptatif : port
MemoryRecall, adapterNaiveMemoryRecall, use caseRecallMemory. Voir §14.5.2. - LOT C — Étage 2 vectoriel : port
Embedder, profils déclaratifsembedder.json, adapterVectorMemoryRecall, logique de bascule sur seuil. Voir §14.5.3. - Sous-lot d'intégration L14 ↔ L6 : injection du rappel mémoire dans le convention file à l'activation d'un agent (
LaunchAgentcomposeMemoryRecall). Voir §14.5.4.
14.5.1 LOT A (fin) — contrats application + app-tauri
Use cases application/memory (miroir de application/skill, chacun Arc<dyn MemoryStore>, execute(Input) -> Result<Output, AppError>) :
// CreateMemory — crée/écrit une note + upsert index. Émet MemorySaved.
struct CreateMemoryInput { project_root: ProjectPath, name: String, // slug brut → MemorySlug::new
description: String, r#type: MemoryType, content: String }
struct CreateMemoryOutput { memory: Memory }
// UpdateMemory — remplace une note existante (revalide invariants). Émet MemorySaved.
struct UpdateMemoryInput { project_root: ProjectPath, slug: MemorySlug,
description: String, r#type: MemoryType, content: String }
struct UpdateMemoryOutput { memory: Memory }
// ListMemories — liste les notes (pilotée par l'index).
struct ListMemoriesInput { project_root: ProjectPath }
struct ListMemoriesOutput { memories: Vec<Memory> }
// GetMemory — une note par slug.
struct GetMemoryInput { project_root: ProjectPath, slug: MemorySlug }
struct GetMemoryOutput { memory: Memory }
// DeleteMemory — supprime une note (retire la ligne d'index). Émet MemoryDeleted.
struct DeleteMemoryInput { project_root: ProjectPath, slug: MemorySlug } // -> ()
// ReadMemoryIndex — lit MEMORY.md structuré (pour l'affichage graphique de la mémoire).
struct ReadMemoryIndexInput { project_root: ProjectPath }
struct ReadMemoryIndexOutput { entries: Vec<MemoryIndexEntry> }
// ResolveMemoryLinks — liens [[slug]] sortants résolus (liens cassés ignorés).
struct ResolveMemoryLinksInput { project_root: ProjectPath, slug: MemorySlug }
struct ResolveMemoryLinksOutput { links: Vec<MemoryLink> }
Décision : on expose les 7 use cases (CRUD complet +
ReadMemoryIndex+ResolveMemoryLinks).ReadMemoryIndexalimente la vue graphique de la mémoire ;ResolveMemoryLinksalimente la navigation par liens.CreateMemory/UpdateMemoryportent en plusArc<dyn EventBus>.
Émission d'events (ports MemoryStore + EventBus ; events déjà définis dans domain::events) :
| Use case | Event émis |
|---|---|
CreateMemory, UpdateMemory |
DomainEvent::MemorySaved { slug } |
DeleteMemory |
DomainEvent::MemoryDeleted { slug } |
ListMemories, GetMemory, ReadMemoryIndex, ResolveMemoryLinks |
(aucun — lectures) |
MemoryIndexRebuilt { project_id }n'est pas émis par les use cases CRUD (l'index est réécrit en interne par le store à chaque save/delete, pas un rebuild distinct). Le réserver à un futur use caseRebuildMemoryIndex(reconstruction explicite depuis les.md) — non requis pour clore LOT A.
Mapping From<MemoryError> for AppError (à ajouter dans application/src/error.rs, calqué sur From<StoreError>) :
impl From<MemoryError> for AppError {
fn from(e: MemoryError) -> Self {
match e {
MemoryError::NotFound => Self::NotFound("memory note".to_owned()),
MemoryError::Frontmatter(m) => Self::Invalid(m), // donnée malformée = invariant
other /* Io | Serialization */ => Self::Store(other.to_string()),
}
}
}
Commandes app-tauri ↔ DTO (miroir des commandes skills ; resolve_project(project_id) → project.root ; slug parsé via MemorySlug::new qui renvoie INVALID si invalide) :
| Commande Tauri | Request DTO (camelCase) | Réponse |
|---|---|---|
create_memory |
{ projectId, name, description, type, content } |
MemoryDto |
update_memory |
{ projectId, slug, description, type, content } |
MemoryDto |
list_memories |
{ projectId } |
MemoryListDto |
get_memory |
{ projectId, slug } |
MemoryDto |
delete_memory |
{ projectId, slug } |
() |
read_memory_index |
{ projectId } |
MemoryIndexDto |
resolve_memory_links |
{ projectId, slug } |
MemoryLinksDto |
DTO de réponse (Memory/MemoryIndexEntry/MemoryLink dérivent déjà Serialize côté domaine pour les types persistés ; sinon mapping explicite façon SkillDto) : MemoryDto(Memory), MemoryListDto(Vec<MemoryDto>), MemoryIndexDto(Vec<MemoryIndexEntry>), MemoryLinksDto(Vec<String>) (slugs cibles). Enregistrer les 7 commandes dans tauri::generate_handler! (app-tauri/src/lib.rs). Câbler les 7 use cases dans le composition root (state.rs) : FsMemoryStore (déjà exporté) injecté en Arc<dyn MemoryStore>, partagé par tous les use cases, EventBus pour Create/Update/Delete.
Note d'implémentation :
FsMemoryStoreprend lerootpar appel (commeSkillStore) ⇒ une seule instance store partagée, comme pour les skills.
14.5.2 LOT B — port MemoryRecall + adapter naïf
/// Rappel adaptatif d'un sous-ensemble pertinent de notes pour une requête.
#[async_trait]
pub trait MemoryRecall: Send + Sync {
/// Renvoie les notes les plus pertinentes pour `query`, limitées à `budget`.
/// Contrat : best-effort, jamais d'erreur bloquante sur mémoire vide
/// (renvoie une liste vide) ; l'adapter naïf ignore la pertinence et
/// renvoie l'index/les notes dans l'ordre, tronqué au budget.
async fn recall(
&self,
root: &ProjectPath,
query: &MemoryQuery,
) -> Result<Vec<MemoryIndexEntry>, MemoryError>;
}
pub struct MemoryQuery {
pub text: String, // requête (souvent le contexte courant de l'agent)
pub token_budget: usize, // budget au-delà duquel l'étage 2 prendrait le relais
}
- Adapter
NaiveMemoryRecall(infrastructure) : composeArc<dyn MemoryStore>, litread_indexet tronque au budget. Aucune dépendance externe. C'est le défaut. - Use case
RecallMemory(application/memory) :RecallMemoryInput { project_root, text, token_budget } -> RecallMemoryOutput { entries: Vec<MemoryIndexEntry> }, portArc<dyn MemoryRecall>. Pas d'event. - Contrat de substituabilité (Liskov) :
NaiveMemoryRecalletVectorMemoryRecall(LOT C) sont interchangeables ; un budget nul ⇒ liste vide ; mémoire absente ⇒ liste vide, jamais d'erreur.
14.5.3 LOT C — port Embedder + profils + adapter vectoriel + bascule
/// Produit des vecteurs d'embedding, piloté par un profil déclaratif (façon §9).
#[async_trait]
pub trait Embedder: Send + Sync {
fn id(&self) -> &str; // ex. "local-onnx-minilm"
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError>;
fn dimension(&self) -> usize; // taille des vecteurs produits
}
- Profils déclaratifs
embedder.json(store global IDE, commeprofiles.json) :{ id, name, strategy: "localOnnx"|"localServer"|"api"|"none", model?, endpoint?, apiKeyEnv?, dimension }. Open/Closed : ajouter un moteur d'embedding = une donnée, pas du code. - Adapter
VectorMemoryRecall(infrastructure) : composeArc<dyn Embedder>+Arc<dyn MemoryStore>+ un store de vecteurs dérivé sous.ideai/memory/.index/(reconstructible, ajouté au.gitignoreau même titre querun/). ImplémenteMemoryRecall. - Logique de bascule (dans le use case
RecallMemoryou unAdaptiveMemoryRecallqui compose les deux adapters) : si taille mémoire ≤token_budgetou stratégie embedder =none⇒NaiveMemoryRecall; sinon ⇒VectorMemoryRecall. Décision objective, testable sans I/O (la mesure de taille est une fonction pure de l'index). EmbedderError: nouveau type d'erreur par port (façonMemoryError), mappé enAppError(none/indisponible ⇒ dégrade vers naïf, jamais d'échec dur du rappel).- Garde-fou produit : défaut
none⇒ LOT C n'impose aucune dépendance ; un utilisateur sans embedder a une mémoire pleinement fonctionnelle (étage 1 + rappel naïf).
14.5.4 Injection de la mémoire à l'activation d'un agent (intégration L14 ↔ L6)
Problème : la mémoire (MemoryStore + MemoryRecall) existe et est consultable par commandes, mais aucun agent ne la lit. À l'activation d'un agent, IdeA génère le convention file (CLAUDE.md/AGENTS.md…) dans le run dir et y injecte la persona + les skills assignés (§14.1/§14.2) ; il faut y ajouter le rappel mémoire du projet, exactement comme les skills.
Décision (où injecter) : le rappel est composé dans la même fonction pure compose_convention_file, qui devient :
pub(crate) fn compose_convention_file(
project_root: &str,
agent_md: &str,
skills: &[Skill],
memory: &[MemoryIndexEntry], // nouveau : rappel mémoire (peut être vide)
) -> String
- On passe les
MemoryIndexEntrydéjà résolus (pas une&strpré-rendue ni le port) : la fonction reste pure et I/O-free, donc unit-testable sans fake, cohérent avec le traitement des skills (le port est appelé en amont, pas dans la fonction de composition). LaunchAgentgagne une nouvelle dépendance portrecall: Arc<dyn MemoryRecall>(déjà un port domaine, déjà câblé enNaiveMemoryRecalldansstate.rs). Aucun couplage à un adapter concret : Liskov garantit qu'unVectorMemoryRecall/AdaptiveMemoryRecall(LOT C) se substitue sans toucher àLaunchAgent.- La résolution suit le modèle
resolve_skills: une méthoderesolve_memory(&self, root) -> Vec<MemoryIndexEntry>best-effort appelée juste avantapply_injection, dont le résultat est passé àapply_injectionpuis àcompose_convention_file. Signature interne :
async fn resolve_memory(&self, root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, AppError>;
// puis :
async fn apply_injection(
&self, project: &Project, context_rel_path: &str,
content: &MarkdownDoc, skills: &[Skill],
memory: &[MemoryIndexEntry], // nouveau
spec: &mut SpawnSpec,
) -> Result<(), AppError>;
Décision (quoi injecter) : l'index/les hooks, pas le corps des notes. On rappelle via MemoryRecall::recall (donc read_index tronqué au budget pour l'adapter naïf), et on rend une section :
---
# Mémoire projet
- [Titre](slug.md) — hook (type)
- …
Une ligne par MemoryIndexEntry (title, slug, hook, r#type). C'est le « léger par défaut » : l'agent reçoit les pointeurs vers le savoir projet (et peut lire le .md cible via son cwd = project root logique) ; l'étage 2 (corps/sémantique) reste hors convention file. Cohérent avec « léger par défaut, étage 2 seulement au-delà du seuil ».
- Budget : constante
applicationconst AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;(tokens approx.), passée dansMemoryQuery { text, token_budget }. Valeur interne et documentée, pas encore exposée en config (évolutif : pourra devenir un champ de réglage projet plus tard sans changer le contrat).text= la persona de l'agent (content.as_str()) : sans pertinence sémantique pour le naïf, mais déjà la bonne requête pour le vectoriel (LOT C) — zéro refactor au passage étage 2.
Décision (best-effort / dégradation) : mémoire vide, absente, ou budget produisant 0 entrée ⇒ aucune section # Mémoire projet (omise entièrement, comme # Skills quand skills est vide) ⇒ document identique à l'actuel. resolve_memory ne bloque jamais un launch : le contrat de MemoryRecall est déjà « mémoire absente ⇒ liste vide, jamais d'erreur » ; une éventuelle AppError::Store inattendue est traitée comme les skills (best-effort — on dégrade vers liste vide plutôt que d'échouer l'activation). Confirmé : un projet sans .ideai/memory/ lance ses agents exactement comme aujourd'hui.
Décision (universalité) : l'injection passe uniquement par le contenu du convention file (ContextInjectionPlan::File), donc valable pour toute stratégie conventionFile (Claude/Codex/Gemini…), sans flag ni commande propriétaire. Pour env/stdin/args : pas d'injection mémoire pour l'instant, strictement aligné sur les skills (lesquels ne sont composés que dans la branche File de apply_injection). Rationale : l'uniformité avec les skills prime ; étendre aux autres stratégies serait une décision séparée (et pour env, la mémoire n'a pas de fichier unique à pointer). À tracer comme point ouvert si un profil non-conventionFile devait bénéficier du rappel.
Conformité hexagonale/SOLID : LaunchAgent ne parle qu'à des ports (+ Arc<dyn MemoryRecall>) ; la composition reste une fonction pure ; aucun adapter concret référencé ; ISP respectée (une dépendance de plus, pour la seule tranche « rappel »). Substituabilité LOT C gratuite.
Découpage dev/test (ordre) :
crates/application/src/agent/lifecycle.rs— dev :- ajouter le champ
recall: Arc<dyn MemoryRecall>au structLaunchAgent+ paramètre dansnew(en fin de liste, aprèsids) ; const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;;async fn resolve_memory(&self, root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, AppError>(best-effort, dégrade versvec![]) ;execute: appelerresolve_memoryaprèsresolve_skillset passer le résultat àapply_injection;apply_injection: nouvel argumentmemory: &[MemoryIndexEntry], transmis àcompose_convention_file(brancheFileuniquement) ;compose_convention_file: nouvel argumentmemory, section# Mémoire projet(omise si vide), rendu d'une ligne par entrée.
- ajouter le champ
crates/application/src/agent/lifecycle.rs(#[cfg(test)]) — test : étendre les tests purs decompose_convention_file(section présente/ordonnée ; absente si vide ; document inchangé sans mémoire).crates/app-tauri/src/state.rs— dev : passerArc::clone(&memory_recall_port)(existant l.509-510, à hisser avant la construction deLaunchAgentl.379) en dernier argument deLaunchAgent::new.- Câblage des tests existants (
LaunchAgent::newà 6 sites) — test/dev : fournir un fakeMemoryRecall(unFakeRecallrenvoyantvec![]par défaut, configurable pour un cas non-vide). Sites à mettre à jour :crates/application/tests/agent_lifecycle.rs(×5, dont l'helper de construction l.666) ;crates/application/tests/orchestrator_service.rs(l.407) ;crates/infrastructure/tests/orchestrator_watcher.rs(l.298).
- Test d'intégration (
agent_lifecycle.rs) — test : avec unFakeRecallnon-vide, asserter que le convention file écrit parFakeFscontient la section# Mémoire projetet les hooks ; avec recall vide, asserter son absence (document identique au baseline persona+skills).
Note de réutilisation : la résolution préfère le port MemoryRecall (rappel borné) plutôt qu'un read_index brut, pour hériter directement de la bascule étage 1/étage 2 (LOT C) sans retoucher LaunchAgent.
14.5.5 Câblage final & UI mémoire — clôture du sujet mémoire
Deux dernières pièces ferment L14 : le câblage adaptatif backend (rendre la bascule étage 1↔2 « live » au composition root, défaut none conservé) et le contrat MemoryGateway + panneau mémoire côté front (miroir du modèle skills L12).
Pièce 1 — wiring AdaptiveMemoryRecall dans app-tauri (backend).
- Décision :
state.rscâble désormaisArc<dyn MemoryRecall>via un helper de compositionbuild_memory_recall(fs, memory_store_port, embedder_profile) -> Arc<dyn MemoryRecall>plutôt queNaiveMemoryRecallen dur. Le profil embedder est chargé depuisembedder.jsonglobal viaFsEmbedderProfileStore(déjà existant), avec fallbackEmbedderProfile::none()si le fichier est absent ou vide — esprit « rien d'imposé, zéro dépendance ». - Défaut strictement identique au naïf : pour
EmbedderProfile::none(),embedder_from_profileretourneNone. Dans ce cas le helper renvoie directementNaiveMemoryRecall(pas d'AdaptiveMemoryRecall, pas deVectorMemoryRecall, doncStubEmbedderjamais instancié). L'AdaptiveMemoryRecalln'est construit que lorsqu'un embedder concret existe (stratégie ≠none) ; sa logiqueshould_use_vectorgarantit de toute façon le repli naïf tant que la mémoire ne dépasse pas le budget, et le repli best-effort si l'embedder échoue (Liskov). Comportement par défaut = byte-for-byte le naïf actuel (couvert paradaptive_none_strategy_matches_naive_exactly). - Instance unique partagée : le
Arc<dyn MemoryRecall>produit est injecté à l'identique dansLaunchAgent(injection §14.5.4) et dansRecallMemory(commanderecall_memory) — une seule instance, donc l'UI et l'activation d'agent voient le même rappel. Aucune régression sur les 7 commandes mémoire ni sur les tests existants : seul le type concret derrière le port change, et il resteNaiveMemoryRecallpar défaut. - Chargement async dans un
buildsync :embedder.jsonest lu viatauri::async_runtime::block_ondansAppState::build(déjà appelé dans un contextesetupTauri), cohérent avec leblock_ondu hook de shutdown. Le composition root reste le seul endroit qui touche au runtime. - Conformité :
LaunchAgent/RecallMemoryne dépendent que du portMemoryRecall;build_memory_recallest la seule fonction qui connaît les adapters concrets (DIP). Zéro dépendance lourde tirée au défaut.
Pièce 2 — contrat MemoryGateway + panneau mémoire (frontend).
- Décision : un nouveau port UI
MemoryGateway(dansports/index.ts), miroir des 7 commandes backend (create_memory/update_memory/list_memories/get_memory/delete_memory/read_memory_index/resolve_memory_links) +recall_memory(optionnel UI). Identité = slug (kebab-case), pas d'UUID. Payloads camelCase,type ∈ user|feedback|project|reference. AdapterTauriMemoryGateway(adapters/memory.ts) +MockMemoryGateway(adapters/mock/index.ts), enregistrés dansGateways. - Types domaine TS ajoutés (
domain/index.ts) :MemoryType("user"|"feedback"|"project"|"reference"),Memory({ name; description; type; content }, miroirMemoryDto),MemoryIndexEntry({ slug; title; hook; type }),MemoryLink(aliasstringcible, miroirMemoryLinksDto). - UI : feature
features/memory/calquée surfeatures/skills/—useMemory.ts(view-model : liste depuis l'index, create/update/delete, resolve links),MemoryPanel.tsx(liste l'index, boutons New/Edit/Delete),MemoryEditor.tsx(slug+description+type+contenu en create, contenu+description+type en edit),index.ts,memory.test.tsx. Montée dansProjectsViewvia un nouvel onglet sidebar"memory". Périmètre sobre, alignéSkillsPanel, sans design system avancé. Affichage des liens[[ ]]viaresolveLinksdans l'éditeur (lecture seule). - Conformité : aucun couplage UI↔Tauri hors
TauriMemoryGateway; les composants ne consomment queMemoryGateway(DIP), testables avecMockMemoryGateway. Impacts tests : étendre le mock gateway, ajoutermemory.test.tsx, et compléter le testProjectsView(nouvel onglet + montage du panneau).
Sujet mémoire (L14) clos une fois ces deux pièces livrées et vertes : domaine + adapters (A/B/C), use cases + commandes (LOT A/B), injection à l'activation (§14.5.4), bascule adaptative live (§14.5.5 pièce 1) et UI complète (§14.5.5 pièce 2). Évolutions ultérieures (vrais embedders ONNX/HTTP derrière feature, réglage du budget/seuil en config projet) restent des follow-ups indépendants, hors périmètre de clôture.
Anomalies de conformité relevées sur l'existant (à traiter par les agents dev)
- Doublon
DomainError::MalformedFrontmattervsMemoryError::Frontmatter:error.rsdéfinitDomainError::MalformedFrontmatter { reason }, mais le domainememory.rsne l'utilise jamais (il lèveEmptyField/InvalidSlug) et l'adapter parse viaMemoryError::Frontmatter. La varianteDomainError::MalformedFrontmatterest morte. Action : la supprimer (le parsing de frontmatter est une responsabilité d'adapter →MemoryError::Frontmatter), sauf si un futur parseur de frontmatter dans le domaine est prévu (il ne l'est pas — le domaine reste format-neutral, cf. doc-modulememory.rs). Anomalie mineure, sans impact fonctionnel. MemoryErrornon encore mappé dansAppError: normal (la couche application mémoire n'existe pas encore) ; couvert par LOT A (§14.5.1).MemoryIndexRebuilt/MemorySaved/MemoryDeleteddéjà câblés bout-en-bout (domaine event + DTOapp-tauri+ relais) mais non encore émis faute de use cases : attendu, résolu par LOT A. RAS sur le sens des dépendances : domaine I/O-free, adapter composeFileSystem, application ne référence que les ports. Conforme hexagonal/SOLID.
13. Risques techniques & points ouverts (spikes)
- PTY cross-platform : portable-pty + xterm.js OK sur les 3 OS, mais signaux/resize/exit codes diffèrent (Windows ConPTY). Spike L3.
- AppImage multi-distro : libgit2/openssl/glibc liés dynamiquement → risque de non-portabilité. Spike : vendoring statique (
git2features,rustlspour russh au lieu d'OpenSSL), test sur ≥3 distros (Ubuntu/Fedora/Arch). L11. - Drag d'onglet entre fenêtres Tauri : Tauri v2 multi-webview/multi-window + DnD natif inter-fenêtres est délicat (le DnD HTML ne traverse pas les fenêtres OS). Spike : protocole « detach » (créer une
WebviewWindow, transférer l'état via store + event, fermer l'onglet source). L10. - Git sur FS distant : libgit2 ne lit pas un FS SSH/WSL directement. Décision : fallback git CLI (
RemoteGitRepository) côté distant viaProcessSpawner. À valider (perf, parsing). L9. - Synchro temps réel UI ↔ PTY : volume d'octets élevé ; backpressure des Channels Tauri, throttling/coalescing côté front. Spike L3.
InjectionRésolu (§14.1) : cwd isolé par agent dansconventionFile: symlink vs copie du.mdversCLAUDE.md/AGENTS.md; conflits si fichier existant, .gitignore, droits Windows (symlinks)..ideai/run/<id>/— plus de conflit à la racine, convention file généré par copie simple.- SSH auth : agent/clé/mot de passe/known_hosts ; choix russh (rustls) vs ssh2 (libssh2/OpenSSL — impacte point 2). Décision à figer début L9.
- WSL chemins : conversion
/mnt/c/...↔\\wsl$\..., distros multiples, perf I/O cross-boundary. Spike L9. - Détection d'édition hors-app des
.md/templates (content hash) et résolution de conflit lors du sync. L7.
Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.