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

@ -13,13 +13,13 @@ use std::sync::Arc;
use async_trait::async_trait;
use domain::ids::ProfileId;
use domain::ports::{
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec,
};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::project::ProjectPath;
use domain::ids::ProfileId;
use domain::MarkdownDoc;
use infrastructure::CliAgentRuntime;
@ -98,7 +98,9 @@ fn prepare_convention_file_keeps_args_and_plans_file() {
);
let root = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
assert_eq!(spec.command, "mycli");
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged");
@ -124,7 +126,9 @@ fn prepare_flag_with_path_substitutes_and_splits() {
);
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
// static args first, then the substituted+split flag args.
assert_eq!(
@ -149,7 +153,9 @@ fn prepare_flag_without_path_is_switch_then_path() {
let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}");
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
assert_eq!(spec.args, vec!["--static", "arg", "-f", ".ideai/agent.md"]);
assert_eq!(
@ -170,9 +176,15 @@ fn prepare_stdin_keeps_args_and_plans_stdin() {
let p = profile(ContextInjection::stdin(), "{projectRoot}");
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for stdin");
assert_eq!(
spec.args,
vec!["--static", "arg"],
"args unchanged for stdin"
);
assert_eq!(spec.context_plan, Some(ContextInjectionPlan::Stdin));
}
@ -189,7 +201,9 @@ fn prepare_env_keeps_args_and_plans_env() {
);
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for env");
assert_eq!(
@ -207,13 +221,12 @@ fn prepare_env_keeps_args_and_plans_env() {
#[test]
fn prepare_substitutes_project_root_in_cwd_template() {
let rt = pure_runtime();
let p = profile(
ContextInjection::stdin(),
"{projectRoot}/subdir",
);
let p = profile(ContextInjection::stdin(), "{projectRoot}/subdir");
let root = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj/subdir");
}
@ -223,7 +236,9 @@ fn prepare_empty_cwd_template_defaults_to_base() {
let p = profile(ContextInjection::stdin(), "");
let base = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &base, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &base, &SessionPlan::None)
.unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj");
}
@ -232,10 +247,15 @@ fn prepare_substitutes_agent_run_dir_in_cwd_template() {
// The canonical template (ARCHITECTURE §14.1): `{agentRunDir}` resolves to the
// base cwd the launcher passes — the agent's isolated run directory.
let rt = pure_runtime();
let p = profile(ContextInjection::convention_file("CLAUDE.md").unwrap(), "{agentRunDir}");
let p = profile(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
"{agentRunDir}",
);
let run_dir = ProjectPath::new("/home/me/proj/.ideai/run/agent-1").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &run_dir, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &run_dir, &SessionPlan::None)
.unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj/.ideai/run/agent-1");
}
@ -362,8 +382,12 @@ fn session_none_profile_adds_nothing_for_any_plan() {
for plan in [
SessionPlan::None,
SessionPlan::Assign { conversation_id: "id-1".to_owned() },
SessionPlan::Resume { conversation_id: "id-1".to_owned() },
SessionPlan::Assign {
conversation_id: "id-1".to_owned(),
},
SessionPlan::Resume {
conversation_id: "id-1".to_owned(),
},
] {
let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
@ -383,7 +407,9 @@ fn session_assign_with_flag_emits_flag_and_id() {
&p,
&ctx(),
&root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
&SessionPlan::Assign {
conversation_id: "abc".to_owned(),
},
)
.unwrap();
@ -403,7 +429,9 @@ fn session_resume_with_flag_emits_resume_and_id() {
&p,
&ctx(),
&root(),
&SessionPlan::Resume { conversation_id: "abc".to_owned() },
&SessionPlan::Resume {
conversation_id: "abc".to_owned(),
},
)
.unwrap();
@ -421,7 +449,9 @@ fn session_resume_without_assign_flag_emits_resume_only() {
&p,
&ctx(),
&root(),
&SessionPlan::Resume { conversation_id: "abc".to_owned() },
&SessionPlan::Resume {
conversation_id: "abc".to_owned(),
},
)
.unwrap();
@ -454,7 +484,9 @@ fn session_assign_without_flag_adds_nothing() {
&p,
&ctx(),
&root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
&SessionPlan::Assign {
conversation_id: "abc".to_owned(),
},
)
.unwrap();
@ -471,8 +503,12 @@ fn no_session_profile_is_unaffected_by_any_plan() {
for plan in [
SessionPlan::None,
SessionPlan::Assign { conversation_id: "x".to_owned() },
SessionPlan::Resume { conversation_id: "x".to_owned() },
SessionPlan::Assign {
conversation_id: "x".to_owned(),
},
SessionPlan::Resume {
conversation_id: "x".to_owned(),
},
] {
let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
@ -500,7 +536,9 @@ fn session_args_come_after_context_injection_args() {
&p,
&ctx(),
&root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
&SessionPlan::Assign {
conversation_id: "abc".to_owned(),
},
)
.unwrap();