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

@ -6,8 +6,8 @@ mod helpers;
use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion,
WeightedChild,
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth,
TemplateVersion, WeightedChild,
};
use helpers::{node, session};
use uuid::Uuid;
@ -72,7 +72,14 @@ fn project_uses_camel_case_and_tagged_remote() {
#[test]
fn remote_ssh_roundtrip_and_tags() {
let r = RemoteRef::ssh("host", 2222, "me", SshAuth::Key { path: "/k".into() }, "/srv").unwrap();
let r = RemoteRef::ssh(
"host",
2222,
"me",
SshAuth::Key { path: "/k".into() },
"/srv",
)
.unwrap();
assert_eq!(roundtrip(&r), r);
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("\"kind\":\"ssh\""), "json was {json}");
@ -118,9 +125,12 @@ fn profile_roundtrip_all_injection_variants() {
#[test]
fn context_injection_strategy_tag_is_camel_case() {
let json = serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap())
.unwrap();
assert!(json.contains("\"strategy\":\"conventionFile\""), "json was {json}");
let json =
serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap()).unwrap();
assert!(
json.contains("\"strategy\":\"conventionFile\""),
"json was {json}"
);
let json = serde_json::to_string(&ContextInjection::stdin()).unwrap();
assert!(json.contains("\"strategy\":\"stdin\""), "json was {json}");
}
@ -203,7 +213,10 @@ fn session_assign_flag_omitted_when_none() {
let session = SessionStrategy::new(None, "--continue").unwrap();
let json = serde_json::to_string(&session).unwrap();
assert!(!json.contains("assignFlag"), "json was {json}");
assert!(json.contains("\"resumeFlag\":\"--continue\""), "json was {json}");
assert!(
json.contains("\"resumeFlag\":\"--continue\""),
"json was {json}"
);
assert_eq!(roundtrip(&session), session);
}
@ -244,13 +257,22 @@ fn agent_roundtrip_from_template() {
let json = serde_json::to_string(&a).unwrap();
assert!(json.contains("\"contextPath\""), "json was {json}");
// AgentOrigin tagged with `type`, camelCased.
assert!(json.contains("\"type\":\"fromTemplate\""), "json was {json}");
assert!(
json.contains("\"type\":\"fromTemplate\""),
"json was {json}"
);
// Inner fields must be camelCased per ARCHITECTURE §9.1:
// { "type":"fromTemplate", "templateId":"...", "syncedTemplateVersion":N }.
assert!(json.contains("\"templateId\""), "json was {json}");
assert!(json.contains("\"syncedTemplateVersion\":4"), "json was {json}");
assert!(
json.contains("\"syncedTemplateVersion\":4"),
"json was {json}"
);
assert!(!json.contains("\"template_id\""), "json was {json}");
assert!(!json.contains("\"synced_template_version\""), "json was {json}");
assert!(
!json.contains("\"synced_template_version\""),
"json was {json}"
);
assert!(!json.contains("\"synced_version\""), "json was {json}");
}
@ -285,14 +307,18 @@ fn manifest_roundtrip_and_camel_case() {
Some(TemplateVersion(5)),
)
.unwrap();
let e2 = ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap();
let e2 =
ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap();
let m = AgentManifest::new(1, vec![e1, e2]).unwrap();
assert_eq!(roundtrip(&m), m);
let json = serde_json::to_string(&m).unwrap();
// entries are serialized under "agents".
assert!(json.contains("\"agents\":["), "json was {json}");
assert!(json.contains("\"mdPath\""), "json was {json}");
assert!(json.contains("\"syncedTemplateVersion\":5"), "json was {json}");
assert!(
json.contains("\"syncedTemplateVersion\":5"),
"json was {json}"
);
// Non-synchronized entry omits optional template fields (skip_serializing_if).
assert!(!json.contains("\"templateId\":null"), "json was {json}");
}
@ -307,13 +333,25 @@ fn sid(n: u128) -> SkillId {
#[test]
fn skill_roundtrip_and_camel_case_scope() {
let s = Skill::new(sid(1), "code-review", MarkdownDoc::new("body"), SkillScope::Global).unwrap();
let s = Skill::new(
sid(1),
"code-review",
MarkdownDoc::new("body"),
SkillScope::Global,
)
.unwrap();
assert_eq!(roundtrip(&s), s);
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"scope\":\"global\""), "json was {json}");
assert!(json.contains("\"contentMd\""), "json was {json}");
let p = Skill::new(sid(2), "simplify", MarkdownDoc::new("b"), SkillScope::Project).unwrap();
let p = Skill::new(
sid(2),
"simplify",
MarkdownDoc::new("b"),
SkillScope::Project,
)
.unwrap();
let pj = serde_json::to_string(&p).unwrap();
assert!(pj.contains("\"scope\":\"project\""), "json was {pj}");
}
@ -412,7 +450,10 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
}
let json = serde_json::to_string(&tree).unwrap();
// agent present when set
assert!(json.contains("\"agent\""), "agent field should be present when set; json was {json}");
assert!(
json.contains("\"agent\""),
"agent field should be present when set; json was {json}"
);
// null variant omitted
let tree_no_agent = LayoutTree::new(LayoutNode::Leaf(LeafCell {
id: node(2),
@ -422,5 +463,8 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
agent_was_running: false,
}));
let json2 = serde_json::to_string(&tree_no_agent).unwrap();
assert!(!json2.contains("\"agent\""), "agent field should be omitted when None; json was {json2}");
assert!(
!json2.contains("\"agent\""),
"agent field should be omitted when None; json was {json2}"
);
}