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

@ -5,8 +5,8 @@ mod helpers;
use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SessionStrategy,
Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef,
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
};
use helpers::{AtomicSeqIdGenerator, FixedClock};
use uuid::Uuid;
@ -203,8 +203,17 @@ fn profile_rejects_empty_command() {
#[test]
fn profile_rejects_empty_name() {
let err = AgentProfile::new(profile_id(), "", "claude", vec![], ci_stdin(), None, "{r}", None)
.unwrap_err();
let err = AgentProfile::new(
profile_id(),
"",
"claude",
vec![],
ci_stdin(),
None,
"{r}",
None,
)
.unwrap_err();
assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name"));
}
@ -331,7 +340,8 @@ fn template_version_next_increments() {
#[test]
fn template_rejects_empty_name() {
let err = AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err();
let err =
AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. }));
}
@ -345,9 +355,16 @@ fn agent_id(n: u128) -> domain::AgentId {
#[test]
fn manifest_entry_synchronized_requires_template_metadata() {
let err =
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, true, None)
.unwrap_err();
let err = ManifestEntry::new(
agent_id(1),
"A",
"agents/a.md",
profile_id(),
None,
true,
None,
)
.unwrap_err();
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
// template id present but version missing → still rejected.
@ -366,9 +383,16 @@ fn manifest_entry_synchronized_requires_template_metadata() {
#[test]
fn manifest_entry_rejects_empty_name() {
let err =
ManifestEntry::new(agent_id(1), " ", "agents/a.md", profile_id(), None, false, None)
.unwrap_err();
let err = ManifestEntry::new(
agent_id(1),
" ",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. }));
}
@ -416,24 +440,52 @@ fn manifest_entry_agent_roundtrip() {
#[test]
fn manifest_rejects_duplicate_md_path() {
let e1 =
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None)
.unwrap();
let e2 =
ManifestEntry::new(agent_id(2), "B", "agents/a.md", profile_id(), None, false, None)
.unwrap();
let e1 = ManifestEntry::new(
agent_id(1),
"A",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap();
let e2 = ManifestEntry::new(
agent_id(2),
"B",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap();
let err = AgentManifest::new(1, vec![e1, e2]).unwrap_err();
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
}
#[test]
fn manifest_unique_md_paths_ok() {
let e1 =
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None)
.unwrap();
let e2 =
ManifestEntry::new(agent_id(2), "B", "agents/b.md", profile_id(), None, false, None)
.unwrap();
let e1 = ManifestEntry::new(
agent_id(1),
"A",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap();
let e2 = ManifestEntry::new(
agent_id(2),
"B",
"agents/b.md",
profile_id(),
None,
false,
None,
)
.unwrap();
assert!(AgentManifest::new(1, vec![e1, e2]).is_ok());
}
@ -465,7 +517,12 @@ fn skill_rejects_empty_name() {
SkillScope::Project,
)
.unwrap_err();
assert_eq!(err, DomainError::EmptyField { field: "skill.name" });
assert_eq!(
err,
DomainError::EmptyField {
field: "skill.name"
}
);
}
#[test]