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

@ -827,6 +827,183 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
}
}
// ---------------------------------------------------------------------------
// Embedder profiles & engines (LOT C2 — §14.5.3)
// ---------------------------------------------------------------------------
use application::{
DismissChoice, DismissEmbedderSuggestionInput, EmbedderEnginesView, ListEmbedderProfilesOutput,
OnnxModelView, SaveEmbedderProfileInput, SaveEmbedderProfileOutput,
};
use domain::profile::EmbedderProfile;
/// An embedder profile crossing the wire. [`EmbedderProfile`] already serialises
/// camelCase (`id, name, strategy, model?, endpoint?, apiKeyEnv?, dimension`), so we
/// embed it directly — the TS mirror matches this shape.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EmbedderProfileDto(pub EmbedderProfile);
/// A list of embedder profiles (camelCase array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct EmbedderProfileListDto(pub Vec<EmbedderProfileDto>);
impl From<Vec<EmbedderProfile>> for EmbedderProfileListDto {
fn from(v: Vec<EmbedderProfile>) -> Self {
Self(v.into_iter().map(EmbedderProfileDto).collect())
}
}
impl From<ListEmbedderProfilesOutput> for EmbedderProfileListDto {
fn from(out: ListEmbedderProfilesOutput) -> Self {
out.profiles.into()
}
}
impl From<SaveEmbedderProfileOutput> for EmbedderProfileDto {
fn from(out: SaveEmbedderProfileOutput) -> Self {
Self(out.profile)
}
}
/// Request DTO for `save_embedder_profile`: the embedder profile to upsert.
///
/// Carries the [`EmbedderProfile`] fields directly (the entity validates them in the
/// use case). Deserialised camelCase to match the persisted/domain shape.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveEmbedderProfileRequestDto {
/// The embedder profile to upsert.
pub profile: EmbedderProfile,
}
impl From<SaveEmbedderProfileRequestDto> for SaveEmbedderProfileInput {
fn from(dto: SaveEmbedderProfileRequestDto) -> Self {
let EmbedderProfile {
id,
name,
strategy,
model,
endpoint,
api_key_env,
dimension,
} = dto.profile;
Self {
id,
name,
strategy,
model,
endpoint,
api_key_env,
dimension,
}
}
}
/// One recommendable local ONNX model on the wire (camelCase).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OnnxModelInfoDto {
/// Stable model id accepted by a `localOnnx` profile's `model` field.
pub id: String,
/// Human-readable name for the UI.
pub display_name: String,
/// Length of the vectors this model produces.
pub dimension: usize,
/// Approximate download/disk size in megabytes.
pub approx_size_mb: u32,
/// Whether this is the recommended default model.
pub recommended: bool,
}
impl From<OnnxModelView> for OnnxModelInfoDto {
fn from(m: OnnxModelView) -> Self {
Self {
id: m.id,
display_name: m.display_name,
dimension: m.dimension,
approx_size_mb: m.approx_size_mb,
recommended: m.recommended,
}
}
}
/// Response DTO for `describe_embedder_engines` (drives the "configure an embedder?"
/// UI). All-camelCase wire shape.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbedderEnginesDto {
/// The curated catalogue of recommendable local ONNX models.
pub recommended_onnx: Vec<OnnxModelInfoDto>,
/// Whether an Ollama-style local embedding server was detected (best-effort).
pub ollama_detected: bool,
/// Ids of the recommended ONNX models already present in the local cache.
pub onnx_cached_models: Vec<String>,
/// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary.
pub vector_http_enabled: bool,
/// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary.
pub vector_onnx_enabled: bool,
}
impl From<EmbedderEnginesView> for EmbedderEnginesDto {
fn from(v: EmbedderEnginesView) -> Self {
Self {
recommended_onnx: v.recommended_onnx.into_iter().map(Into::into).collect(),
ollama_detected: v.ollama_detected,
onnx_cached_models: v.onnx_cached_models,
vector_http_enabled: v.vector_http_enabled,
vector_onnx_enabled: v.vector_onnx_enabled,
}
}
}
// ---------------------------------------------------------------------------
// Embedder suggestion (LOT C3 — §14.5.5)
// ---------------------------------------------------------------------------
/// The user's response to the embedder suggestion, on the wire (camelCase:
/// `"later"` | `"never"`).
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DismissChoiceDto {
/// "Plus tard" — re-proposable next session.
Later,
/// "Ne plus demander" — never again.
Never,
}
impl From<DismissChoiceDto> for DismissChoice {
fn from(c: DismissChoiceDto) -> Self {
match c {
DismissChoiceDto::Later => Self::Later,
DismissChoiceDto::Never => Self::Never,
}
}
}
/// Request DTO for `dismiss_embedder_suggestion`. The `project_id` is resolved to a
/// project root by the command; `choice` is the user's dismissal.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DismissEmbedderSuggestionRequestDto {
/// The project the suggestion concerned (UUID string).
pub project_id: String,
/// The user's choice.
pub choice: DismissChoiceDto,
}
impl DismissEmbedderSuggestionRequestDto {
/// Builds the use-case input from a resolved project root + the DTO choice.
#[must_use]
pub fn into_input(self, project_root: domain::ProjectPath) -> DismissEmbedderSuggestionInput {
DismissEmbedderSuggestionInput {
project_root,
choice: self.choice.into(),
}
}
}
/// Builds a [`DeleteProfileInput`] from a raw profile-id string.
///
/// # Errors
@ -927,6 +1104,16 @@ pub struct UpdateAgentContextRequestDto {
pub content: String,
}
/// Request DTO for `update_project_context`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectContextRequestDto {
/// Id of the owning project.
pub project_id: String,
/// New project-level Markdown context, stored under `.ideai/CONTEXT.md`.
pub content: String,
}
/// Request DTO for `launch_agent`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -1015,6 +1202,9 @@ pub struct LiveAgentDto {
pub agent_id: String,
/// The hosting layout leaf's node id (UUID string).
pub node_id: String,
/// The live PTY session id, used to reattach a newly-opened cell without
/// respawning the agent.
pub session_id: String,
}
/// Response DTO for `list_live_agents` (transparent array on the wire).
@ -1023,21 +1213,35 @@ pub struct LiveAgentDto {
pub struct LiveAgentListDto(pub Vec<LiveAgentDto>);
impl LiveAgentListDto {
/// Builds the wire list from the registry's `(AgentId, NodeId)` pairs.
/// Builds the wire list from the registry's `(AgentId, NodeId, SessionId)` tuples.
#[must_use]
pub fn from_pairs(pairs: Vec<(AgentId, NodeId)>) -> Self {
pub fn from_pairs(pairs: Vec<(AgentId, NodeId, domain::SessionId)>) -> Self {
Self(
pairs
.into_iter()
.map(|(agent_id, node_id)| LiveAgentDto {
.map(|(agent_id, node_id, session_id)| LiveAgentDto {
agent_id: agent_id.to_string(),
node_id: node_id.to_string(),
session_id: session_id.to_string(),
})
.collect(),
)
}
}
/// Request DTO for `attach_live_agent`: bind an already-running agent session to
/// a visible layout cell without spawning a new process.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachLiveAgentRequestDto {
/// Id of the owning project (validated for symmetry and future scoping).
pub project_id: String,
/// Id of the already-running agent.
pub agent_id: String,
/// Layout leaf that should display the live session.
pub node_id: String,
}
/// Parses an agent-id string (UUID) coming from the frontend.
///
/// # Errors
@ -1056,9 +1260,9 @@ pub fn parse_agent_id(raw: &str) -> Result<AgentId, ErrorDto> {
// ---------------------------------------------------------------------------
use application::{
AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput,
CreateTemplateOutput, DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput,
UpdateTemplateInput, UpdateTemplateOutput,
AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput,
DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput,
UpdateTemplateOutput,
};
use domain::{AgentTemplate, TemplateId};
@ -1253,9 +1457,7 @@ pub struct SyncAgentWithTemplateRequestDto {
// Git (L8)
// ---------------------------------------------------------------------------
use application::{
GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput,
};
use application::{GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput};
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
/// One changed path returned by `git_status`.
@ -1284,7 +1486,12 @@ pub struct GitStatusListDto(pub Vec<GitFileStatusDto>);
impl From<GitStatusOutput> for GitStatusListDto {
fn from(out: GitStatusOutput) -> Self {
Self(out.entries.into_iter().map(GitFileStatusDto::from).collect())
Self(
out.entries
.into_iter()
.map(GitFileStatusDto::from)
.collect(),
)
}
}
@ -1655,13 +1862,23 @@ pub struct MemoryIndexDto(pub Vec<MemoryIndexEntryDto>);
impl From<ReadMemoryIndexOutput> for MemoryIndexDto {
fn from(out: ReadMemoryIndexOutput) -> Self {
Self(out.entries.into_iter().map(MemoryIndexEntryDto::from).collect())
Self(
out.entries
.into_iter()
.map(MemoryIndexEntryDto::from)
.collect(),
)
}
}
impl From<RecallMemoryOutput> for MemoryIndexDto {
fn from(out: RecallMemoryOutput) -> Self {
Self(out.entries.into_iter().map(MemoryIndexEntryDto::from).collect())
Self(
out.entries
.into_iter()
.map(MemoryIndexEntryDto::from)
.collect(),
)
}
}