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

@ -180,8 +180,14 @@ fn lid(n: u128) -> LayoutId {
}
async fn register_project(store: &FakeStore, id: ProjectId) -> ProjectId {
let project =
Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap();
let project = Project::new(
id,
"Demo",
ProjectPath::new(ROOT).unwrap(),
RemoteRef::Local,
0,
)
.unwrap();
store.save_project(&project).await.unwrap();
id
}
@ -264,7 +270,10 @@ async fn load_migrates_a_legacy_layout_json() {
let fs = FakeFs::default();
let id = register_project(&store, pid(2)).await;
// Only the legacy single-layout file exists.
fs.put(LEGACY_PATH, &serde_json::to_vec(&single_leaf(nid(7))).unwrap());
fs.put(
LEGACY_PATH,
&serde_json::to_vec(&single_leaf(nid(7))).unwrap(),
);
let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone()));
let out = load
@ -300,7 +309,10 @@ async fn load_defaults_to_single_empty_leaf_when_absent() {
_ => panic!("expected a single default leaf"),
}
// Default was written through; two loads are deterministic.
assert_eq!(root_leaf_id(&read_active_tree(&fs)), root_leaf_id(&out.layout));
assert_eq!(
root_leaf_id(&read_active_tree(&fs)),
root_leaf_id(&out.layout)
);
assert!(fs.has_dir("/home/me/proj/.ideai"));
}
@ -415,7 +427,10 @@ async fn mutate_split_persists_camelcase_layout_and_announces() {
let tree = active_tree_json(&env.fs);
assert_eq!(tree["root"]["type"], "split", "tagged on `type`");
assert_eq!(tree["root"]["node"]["direction"], "row");
assert_eq!(tree["root"]["node"]["children"].as_array().unwrap().len(), 2);
assert_eq!(
tree["root"]["node"]["children"].as_array().unwrap().len(),
2
);
assert_eq!(
env.bus.events(),
@ -499,6 +514,62 @@ async fn mutate_set_session_attaches_and_clears() {
.is_none());
}
#[tokio::test]
async fn mutate_attach_session_moves_session_between_cells() {
let env = mut_env(pid(13)).await;
env.mutate
.execute(MutateLayoutInput {
project_id: env.project_id,
layout_id: None,
operation: LayoutOperation::Split {
target: nid(1),
direction: Direction::Row,
new_leaf: nid(2),
container: nid(9),
},
})
.await
.expect("split");
env.mutate
.execute(MutateLayoutInput {
project_id: env.project_id,
layout_id: None,
operation: LayoutOperation::SetSession {
target: nid(1),
session: Some(sid(77)),
},
})
.await
.expect("set initial session");
let out = env
.mutate
.execute(MutateLayoutInput {
project_id: env.project_id,
layout_id: None,
operation: LayoutOperation::AttachSession {
target: nid(2),
session: sid(77),
},
})
.await
.expect("attach existing session");
match &out.layout.root {
LayoutNode::Split(split) => {
match &split.children[0].node {
LayoutNode::Leaf(leaf) => assert!(leaf.session.is_none()),
_ => panic!("expected first leaf"),
}
match &split.children[1].node {
LayoutNode::Leaf(leaf) => assert_eq!(leaf.session, Some(sid(77))),
_ => panic!("expected second leaf"),
}
}
_ => panic!("expected split root"),
}
}
#[tokio::test]
async fn mutate_invalid_op_errors_and_does_not_persist() {
let env = mut_env(pid(14)).await;
@ -744,7 +815,9 @@ async fn create_layout_appends_and_activates_it() {
.unwrap();
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
.execute(ListLayoutsInput { project_id: pid(30) })
.execute(ListLayoutsInput {
project_id: pid(30),
})
.await
.unwrap();
assert_eq!(list.layouts.len(), 2, "Default + Backend");
@ -774,21 +847,19 @@ async fn create_layout_rejects_empty_name() {
#[tokio::test]
async fn rename_layout_changes_the_name() {
let (store, fs, bus) = mgmt_env(pid(32)).await;
RenameLayout::new(
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(bus),
)
.execute(RenameLayoutInput {
project_id: pid(32),
layout_id: lid(1),
name: "Main".to_owned(),
})
.await
.unwrap();
RenameLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
.execute(RenameLayoutInput {
project_id: pid(32),
layout_id: lid(1),
name: "Main".to_owned(),
})
.await
.unwrap();
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
.execute(ListLayoutsInput { project_id: pid(32) })
.execute(ListLayoutsInput {
project_id: pid(32),
})
.await
.unwrap();
assert_eq!(list.layouts[0].name, "Main");
@ -825,21 +896,23 @@ async fn delete_active_layout_reassigns_active() {
.await
.unwrap();
let out = DeleteLayout::new(
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(bus),
)
.execute(DeleteLayoutInput {
project_id: pid(34),
layout_id: created.layout_id,
})
.await
.unwrap();
assert_eq!(out.active_id, lid(1), "active fell back to the Default layout");
let out = DeleteLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
.execute(DeleteLayoutInput {
project_id: pid(34),
layout_id: created.layout_id,
})
.await
.unwrap();
assert_eq!(
out.active_id,
lid(1),
"active fell back to the Default layout"
);
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
.execute(ListLayoutsInput { project_id: pid(34) })
.execute(ListLayoutsInput {
project_id: pid(34),
})
.await
.unwrap();
assert_eq!(list.layouts.len(), 1);
@ -863,17 +936,13 @@ async fn set_active_layout_switches_and_load_follows() {
.unwrap();
// Switch back to the Default layout.
SetActiveLayout::new(
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(bus),
)
.execute(SetActiveLayoutInput {
project_id: pid(35),
layout_id: lid(1),
})
.await
.unwrap();
SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
.execute(SetActiveLayoutInput {
project_id: pid(35),
layout_id: lid(1),
})
.await
.unwrap();
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
.execute(LoadLayoutInput {