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

@ -16,6 +16,7 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::DomainEvent;
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
@ -24,7 +25,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;
@ -94,7 +94,13 @@ impl AgentContextStore for FakeContexts {
) -> Result<MarkdownDoc, StoreError> {
let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
Ok(MarkdownDoc::new(
self.0.lock().unwrap().contents.get(&md).cloned().unwrap_or_default(),
self.0
.lock()
.unwrap()
.contents
.get(&md)
.cloned()
.unwrap_or_default(),
))
}
async fn write_context(
@ -150,7 +156,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(
@ -321,6 +331,7 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
bus.clone(),
Arc::new(SeqIds(Mutex::new(1))),
Arc::new(FakeRecall),
None,
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
@ -342,7 +353,11 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
}
fn read_response(request_path: &std::path::Path) -> OrchestratorResponse {
let mut name = request_path.file_name().unwrap().to_string_lossy().into_owned();
let mut name = request_path
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
name.push_str(".response.json");
let response_path = request_path.with_file_name(name);
let bytes = std::fs::read(&response_path).expect("response file written");
@ -375,6 +390,30 @@ async fn valid_spawn_request_succeeds_and_is_consumed() {
assert_eq!(contexts.entries()[0].name, "dev-backend");
}
#[tokio::test]
async fn valid_agent_run_request_succeeds_and_reports_type() {
let tmp = TempDir::new();
let contexts = FakeContexts::new();
let service = build_service(contexts.clone());
let req = tmp.0.join("req-agent-run.json");
std::fs::write(
&req,
br#"{ "type": "agent.run", "requestedBy": "Main", "targetAgent": "architect", "profile": "claude-code", "task": "Analyse", "visibility": "background" }"#,
)
.unwrap();
let response = process_request_file(&req, &project(), &service).await;
assert!(response.ok, "expected ok, got {response:?}");
assert_eq!(response.action.as_deref(), Some("agent.run"));
assert!(!req.exists(), "request file must be removed");
let on_disk = read_response(&req);
assert!(on_disk.ok);
assert_eq!(contexts.entries().len(), 1);
assert_eq!(contexts.entries()[0].name, "architect");
}
#[tokio::test]
async fn valid_create_skill_request_succeeds_and_is_consumed() {
let tmp = TempDir::new();
@ -408,7 +447,11 @@ async fn invalid_json_request_yields_error_response() {
assert!(!response.ok);
assert!(
response.error.as_deref().unwrap_or_default().contains("invalid json"),
response
.error
.as_deref()
.unwrap_or_default()
.contains("invalid json"),
"got {response:?}"
);
assert!(!req.exists(), "poisoned request must still be removed");
@ -427,5 +470,9 @@ async fn unknown_action_yields_error_response() {
assert!(!response.ok);
assert_eq!(response.action.as_deref(), Some("explode"));
assert!(response.error.as_deref().unwrap_or_default().contains("unknown orchestrator action"));
assert!(response
.error
.as_deref()
.unwrap_or_default()
.contains("unknown orchestrator action"));
}