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

@ -69,7 +69,10 @@ struct FakeContexts(Arc<Mutex<(AgentManifest, HashMap<String, String>)>>);
impl FakeContexts {
fn new(entries: Vec<ManifestEntry>) -> Self {
Self(Arc::new(Mutex::new((
AgentManifest { version: 1, entries },
AgentManifest {
version: 1,
entries,
},
HashMap::new(),
))))
}
@ -92,13 +95,11 @@ impl FakeContexts {
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_p: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
async fn read_context(&self, _p: &Project, agent: &AgentId) -> Result<MarkdownDoc, StoreError> {
let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
self.content(&md).map(MarkdownDoc::new).ok_or(StoreError::NotFound)
self.content(&md)
.map(MarkdownDoc::new)
.ok_or(StoreError::NotFound)
}
async fn write_context(
&self,
@ -107,7 +108,11 @@ impl AgentContextStore for FakeContexts {
md: &MarkdownDoc,
) -> Result<(), StoreError> {
let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
self.0.lock().unwrap().1.insert(path, md.as_str().to_owned());
self.0
.lock()
.unwrap()
.1
.insert(path, md.as_str().to_owned());
Ok(())
}
async fn load_manifest(&self, _p: &Project) -> Result<AgentManifest, StoreError> {
@ -186,7 +191,16 @@ fn template(id: TemplateId, name: &str, content: &str, version: u64) -> AgentTem
}
/// A synchronized, template-backed manifest entry synced at `synced`.
fn synced_entry(agent: AgentId, md: &str, template: TemplateId, synced: u64) -> ManifestEntry {
ManifestEntry::new(agent, "A", md, pid(1), Some(template), true, Some(v(synced))).unwrap()
ManifestEntry::new(
agent,
"A",
md,
pid(1),
Some(template),
true,
Some(v(synced)),
)
.unwrap()
}
// ---------------------------------------------------------------------------
@ -234,13 +248,16 @@ async fn update_template_bumps_version_and_publishes_event() {
#[tokio::test]
async fn update_unknown_template_is_not_found() {
let err = UpdateTemplate::new(Arc::new(FakeTemplates::default()), Arc::new(SpyBus::default()))
.execute(UpdateTemplateInput {
template_id: tid(404),
content: "x".to_owned(),
})
.await
.unwrap_err();
let err = UpdateTemplate::new(
Arc::new(FakeTemplates::default()),
Arc::new(SpyBus::default()),
)
.execute(UpdateTemplateInput {
template_id: tid(404),
content: "x".to_owned(),
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
@ -279,7 +296,10 @@ async fn create_agent_from_template_links_origin_and_seeds_context() {
}
);
// Context seeded with the template content under the agent's md path.
assert_eq!(contexts.content(&out.agent.context_path).as_deref(), Some("# body"));
assert_eq!(
contexts.content(&out.agent.context_path).as_deref(),
Some("# body")
);
assert_eq!(contexts.manifest().entries.len(), 1);
}
@ -295,8 +315,16 @@ async fn detect_drift_flags_only_synchronized_agents_behind() {
// a2: synchronized, synced at v3 → up to date, no drift.
// a3: from template but NOT synchronized → ignored.
// a4: scratch (no template) → ignored.
let a3 = ManifestEntry::new(aid(3), "A3", "agents/a3.md", pid(1), Some(tid(1)), false, Some(v(1)))
.unwrap();
let a3 = ManifestEntry::new(
aid(3),
"A3",
"agents/a3.md",
pid(1),
Some(tid(1)),
false,
Some(v(1)),
)
.unwrap();
let a4 = ManifestEntry::new(aid(4), "A4", "agents/a4.md", pid(1), None, false, None).unwrap();
let contexts = FakeContexts::new(vec![
synced_entry(aid(1), "agents/a1.md", tid(1), 1),
@ -306,14 +334,10 @@ async fn detect_drift_flags_only_synchronized_agents_behind() {
]);
let bus = SpyBus::default();
let out = DetectAgentDrift::new(
Arc::new(store),
Arc::new(contexts),
Arc::new(bus.clone()),
)
.execute(DetectAgentDriftInput { project: project() })
.await
.unwrap();
let out = DetectAgentDrift::new(Arc::new(store), Arc::new(contexts), Arc::new(bus.clone()))
.execute(DetectAgentDriftInput { project: project() })
.await
.unwrap();
assert_eq!(out.drifts.len(), 1, "only a1 drifts");
assert_eq!(out.drifts[0].agent_id, aid(1));
@ -375,7 +399,10 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() {
assert!(out.synced);
assert_eq!(out.version, Some(v(3)));
// Context replaced by the template content.
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("newest body"));
assert_eq!(
contexts.content("agents/a1.md").as_deref(),
Some("newest body")
);
// Manifest synced version advanced to 3.
let entry = &contexts.manifest().entries[0];
assert_eq!(entry.synced_template_version, Some(v(3)));
@ -392,9 +419,16 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() {
async fn sync_ignores_non_synchronized_agent() {
let store = FakeTemplates::with(vec![template(tid(1), "T", "body", 3)]);
// Non-synchronized agent from a template.
let entry =
ManifestEntry::new(aid(1), "A", "agents/a1.md", pid(1), Some(tid(1)), false, Some(v(1)))
.unwrap();
let entry = ManifestEntry::new(
aid(1),
"A",
"agents/a1.md",
pid(1),
Some(tid(1)),
false,
Some(v(1)),
)
.unwrap();
let contexts = FakeContexts::new(vec![entry]);
contexts
.write_context(&project(), &aid(1), &MarkdownDoc::new("keep me"))
@ -417,5 +451,8 @@ async fn sync_ignores_non_synchronized_agent() {
assert!(!out.synced, "non-synchronized agent is left untouched");
assert_eq!(out.version, None);
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("keep me"));
assert!(bus.events().is_empty(), "no sync event for an ignored agent");
assert!(
bus.events().is_empty(),
"no sync event for an ignored agent"
);
}