feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé

- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:24:51 +02:00
parent 32398827fb
commit 785e9935fd
118 changed files with 5793 additions and 866 deletions

View File

@ -17,7 +17,8 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::events::DomainEvent;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::ids::SkillId;
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
@ -25,7 +26,6 @@ use domain::ports::{
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::ids::SkillId;
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
@ -65,7 +65,10 @@ impl FakeContexts {
let me = Self::new();
{
let mut inner = me.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(agent));
inner
.manifest
.entries
.push(ManifestEntry::from_agent(agent));
inner
.contents
.insert(agent.context_path.clone(), content.to_owned());
@ -160,7 +163,11 @@ impl ProfileStore for FakeProfiles {
struct FakeSkills;
#[async_trait]
impl SkillStore for FakeSkills {
async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
async fn list(
&self,
_scope: SkillScope,
_root: &ProjectPath,
) -> Result<Vec<Skill>, StoreError> {
Ok(Vec::new())
}
async fn get(
@ -206,7 +213,11 @@ struct RecordingSkills(Arc<Mutex<Vec<Skill>>>);
#[async_trait]
impl SkillStore for RecordingSkills {
async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
async fn list(
&self,
_scope: SkillScope,
_root: &ProjectPath,
) -> Result<Vec<Skill>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn get(
@ -287,14 +298,19 @@ impl FileSystem for FakeFs {
struct FakePty {
next_id: SessionId,
kills: Arc<Mutex<Vec<SessionId>>>,
spawns: Arc<Mutex<Vec<SessionId>>>,
}
impl FakePty {
fn new(next_id: SessionId) -> Self {
Self {
next_id,
kills: Arc::new(Mutex::new(Vec::new())),
spawns: Arc::new(Mutex::new(Vec::new())),
}
}
fn spawns(&self) -> Vec<SessionId> {
self.spawns.lock().unwrap().clone()
}
fn kills(&self) -> Vec<SessionId> {
self.kills.lock().unwrap().clone()
}
@ -302,6 +318,7 @@ impl FakePty {
#[async_trait]
impl PtyPort for FakePty {
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
self.spawns.lock().unwrap().push(self.next_id);
Ok(PtyHandle {
session_id: self.next_id,
})
@ -368,6 +385,9 @@ fn aid(n: u128) -> AgentId {
fn sid(n: u128) -> SessionId {
SessionId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn project() -> Project {
Project::new(
@ -430,6 +450,7 @@ fn fixture(contexts: FakeContexts) -> Fixture {
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(
@ -496,11 +517,9 @@ async fn spawn_unknown_agent_creates_then_launches() {
assert_eq!(manifest.entries[0].name, "dev-backend");
assert!(fx.sessions.session(&sid(777)).is_some());
let launched = fx
.bus
.events()
.into_iter()
.any(|e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777)));
let launched = fx.bus.events().into_iter().any(
|e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777)),
);
assert!(launched, "AgentLaunched must be published");
}
@ -523,6 +542,57 @@ async fn spawn_known_agent_launches_without_recreating() {
assert!(fx.sessions.session(&sid(777)).is_some());
}
#[tokio::test]
async fn agent_run_existing_agent_does_not_require_profile() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
fx.service
.dispatch(
&project(),
cmd(r#"{ "type":"agent.run", "targetAgent":"architect", "task":"Analyse" }"#),
)
.await
.expect("dispatch ok");
assert_eq!(fx.contexts.manifest().entries.len(), 1);
assert!(fx.sessions.session(&sid(777)).is_some());
}
#[tokio::test]
async fn agent_run_visible_reattaches_existing_session() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
let first_cell = nid(10);
let next_cell = nid(20);
fx.service
.dispatch(
&project(),
cmd(&format!(
r#"{{ "action":"spawn_agent", "name":"architect", "profile":"claude", "visibility":"visible", "nodeId":"{first_cell}" }}"#
)),
)
.await
.expect("first launch ok");
let out = fx
.service
.dispatch(
&project(),
cmd(&format!(
r#"{{ "type":"agent.run", "targetAgent":"architect", "visibility":"visible", "attachToCell":"{next_cell}" }}"#
)),
)
.await
.expect("reattach ok");
assert!(out.detail.contains("attached agent architect"));
let session = fx.sessions.session(&sid(777)).expect("live session");
assert_eq!(session.node_id, next_cell);
assert_eq!(fx.pty.spawns().len(), 1, "reattach must not respawn");
}
#[tokio::test]
async fn stop_agent_kills_the_right_session() {
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");