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

@ -22,7 +22,9 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};
use domain::memory::{
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
};
use domain::ports::{
DirEntry, FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath,
};
@ -114,7 +116,10 @@ fn query(budget: usize) -> MemoryQuery {
}
fn slugs(entries: &[MemoryIndexEntry]) -> Vec<String> {
entries.iter().map(|e| e.slug.as_str().to_string()).collect()
entries
.iter()
.map(|e| e.slug.as_str().to_string())
.collect()
}
// ---------------------------------------------------------------------------
@ -161,10 +166,7 @@ impl MemoryStore for CountingStore {
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
panic!("recall must not call delete")
}
async fn read_index(
&self,
_root: &ProjectPath,
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
self.read_index_calls.fetch_add(1, Ordering::SeqCst);
Ok(Vec::new())
}
@ -224,17 +226,27 @@ async fn ample_budget_returns_all_entries_in_index_order() {
#[tokio::test]
async fn intermediate_budget_truncates_at_exact_boundary() {
let notes = [note("aaaa", "bbbb"), note("cccc", "dddd"), note("eeee", "ffff")];
let notes = [
note("aaaa", "bbbb"),
note("cccc", "dddd"),
note("eeee", "ffff"),
];
let recall = recall_over(&notes).await;
// Budget 1 < 2 ⇒ first entry already exceeds ⇒ none.
assert!(recall.recall(&root(), &query(1)).await.unwrap().is_empty());
// Budget 2 == cost(1) ⇒ exactly the first entry; second (would reach 4) dropped.
assert_eq!(slugs(&recall.recall(&root(), &query(2)).await.unwrap()), vec!["aaaa"]);
assert_eq!(
slugs(&recall.recall(&root(), &query(2)).await.unwrap()),
vec!["aaaa"]
);
// Budget 3 < 4 ⇒ still only the first.
assert_eq!(slugs(&recall.recall(&root(), &query(3)).await.unwrap()), vec!["aaaa"]);
assert_eq!(
slugs(&recall.recall(&root(), &query(3)).await.unwrap()),
vec!["aaaa"]
);
// Budget 4 == cost(1)+cost(2) ⇒ first two; third (would reach 6) dropped.
assert_eq!(
@ -266,9 +278,15 @@ async fn entry_cost_rounds_up_via_ceil() {
let recall = recall_over(&[note("ab", "c"), note("de", "fgh")]).await;
// Budget 1 covers only the 1-token first entry; the 2-token second is dropped.
assert_eq!(slugs(&recall.recall(&root(), &query(1)).await.unwrap()), vec!["ab"]);
assert_eq!(
slugs(&recall.recall(&root(), &query(1)).await.unwrap()),
vec!["ab"]
);
// Budget 2 still cannot fit the second on top of the first (1 + 2 = 3 > 2).
assert_eq!(slugs(&recall.recall(&root(), &query(2)).await.unwrap()), vec!["ab"]);
assert_eq!(
slugs(&recall.recall(&root(), &query(2)).await.unwrap()),
vec!["ab"]
);
// Budget 3 fits both (1 + 2 == 3).
assert_eq!(
slugs(&recall.recall(&root(), &query(3)).await.unwrap()),