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

@ -0,0 +1,24 @@
//! Embedder configuration use cases (LOT C2 — §14.5.3).
//!
//! CRUD over the declarative [`domain::profile::EmbedderProfile`]s
//! ([`ListEmbedderProfiles`], [`SaveEmbedderProfile`], [`DeleteEmbedderProfile`])
//! plus a read-only description of the available engines ([`DescribeEmbedderEngines`]):
//! the recommended local ONNX models, the detected local environment, and which
//! strategies are actually compiled into this binary.
//!
//! A changed embedder takes effect at the **next IDE start** (the composition root
//! freezes the recall for the session): these use cases only persist/inspect config,
//! they never reconfigure a live recall.
mod suggestion;
mod usecases;
pub use suggestion::{
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
DismissChoice, DismissEmbedderSuggestion, DismissEmbedderSuggestionInput, SuggestedThisSession,
};
pub use usecases::{
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines,
EmbedderEnginesView, ListEmbedderProfiles, ListEmbedderProfilesOutput, OnnxModelView,
SaveEmbedderProfile, SaveEmbedderProfileInput, SaveEmbedderProfileOutput,
};

View File

@ -0,0 +1,269 @@
//! Contextual embedder-suggestion use cases (LOT C3, ARCHITECTURE §14.5.5).
//!
//! - [`CheckEmbedderSuggestion`] — the **best-effort** check run when an agent
//! reads the project memory at activation: the *first* time a project's memory
//! outgrows the recall budget while no embedder is configured (strategy `none`),
//! it publishes a one-time [`DomainEvent::EmbedderSuggested`]. Anti-spam: at most
//! once per session per project, and never again once the user chose
//! [`EmbedderPromptDismissal::Never`].
//! - [`DismissEmbedderSuggestion`] — persists the user's "plus tard" / "ne plus
//! demander" response.
//!
//! By construction this never blocks the activation flow: every error degrades to
//! "no suggestion", and a `none`-strategy/under-budget project is a cheap no-op.
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use domain::ports::{
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore,
EventBus, MemoryStore,
};
use domain::profile::EmbedderStrategy;
use domain::{DomainEvent, ProjectId, ProjectPath};
use crate::error::AppError;
/// In-memory "already suggested this session" guard, shared with the composition
/// root (held in `AppState`). One [`ProjectId`] per project that has already seen
/// the suggestion since the IDE started. Plain std types so the application stays
/// dependency-free and the guard is trivially testable.
pub type SuggestedThisSession = Arc<Mutex<HashSet<ProjectId>>>;
/// Sums the approximate token size of the index, mirroring the infrastructure
/// `index_token_size` so the application does not depend on the infra crate. One
/// entry's payload is its rendered index line (title + hook + slug), and the rule
/// only needs a *monotone* measure consistent with the recall's budget unit
/// (~1 token / 4 chars), so we count characters / 4 (min 1 per entry).
fn index_token_size(entries: &[domain::MemoryIndexEntry]) -> usize {
entries
.iter()
.map(|e| {
let chars = e.title.len() + e.hook.len() + e.slug.as_str().len();
(chars / 4).max(1)
})
.sum()
}
// ---------------------------------------------------------------------------
// CheckEmbedderSuggestion
// ---------------------------------------------------------------------------
/// Input for [`CheckEmbedderSuggestion::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckEmbedderSuggestionInput {
/// The project whose memory is being read at agent activation.
pub project_id: ProjectId,
/// That project's root (where its `.ideai/memory/` lives).
pub project_root: ProjectPath,
}
/// Output of [`CheckEmbedderSuggestion::execute`]: whether a suggestion event was
/// published on this call (useful for tests; the caller ignores it — the flow is
/// best-effort).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CheckEmbedderSuggestionOutput {
/// `true` iff an [`DomainEvent::EmbedderSuggested`] was published.
pub suggested: bool,
}
/// Publishes a one-time embedder suggestion the first time a project's memory
/// outgrows the recall budget while no embedder is configured (LOT C3).
///
/// Composes — each port only for its slice (Interface Segregation):
/// - [`EmbedderProfileStore`] to read the active strategy (no non-`none` profile ⇒
/// strategy `none`),
/// - [`MemoryStore`] to measure the memory index size,
/// - [`EmbedderPromptStore`] for the persistent dismissal (`never` ⇒ silence),
/// - [`EmbedderEnvInspector`] to enrich the event with the detected environment,
/// - [`EventBus`] to publish, plus the shared [`SuggestedThisSession`] guard.
///
/// **Never blocks the caller**: any port error degrades to "no suggestion".
pub struct CheckEmbedderSuggestion {
profiles: Arc<dyn EmbedderProfileStore>,
memories: Arc<dyn MemoryStore>,
prompts: Arc<dyn EmbedderPromptStore>,
inspector: Arc<dyn EmbedderEnvInspector>,
events: Arc<dyn EventBus>,
suggested_this_session: SuggestedThisSession,
/// Recall token budget the memory must exceed (injected so it stays aligned
/// with [`crate::AGENT_MEMORY_RECALL_BUDGET`] without re-hardcoding it).
budget: usize,
/// Compiled-in HTTP capability flag (carried into the event).
vector_http_enabled: bool,
/// Compiled-in ONNX capability flag (carried into the event).
vector_onnx_enabled: bool,
}
impl CheckEmbedderSuggestion {
/// Builds the use case from its ports + the session guard + the budget and
/// compiled-capability flags (the latter injected at the composition root, the
/// application stays infra-free).
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
profiles: Arc<dyn EmbedderProfileStore>,
memories: Arc<dyn MemoryStore>,
prompts: Arc<dyn EmbedderPromptStore>,
inspector: Arc<dyn EmbedderEnvInspector>,
events: Arc<dyn EventBus>,
suggested_this_session: SuggestedThisSession,
budget: usize,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
) -> Self {
Self {
profiles,
memories,
prompts,
inspector,
events,
suggested_this_session,
budget,
vector_http_enabled,
vector_onnx_enabled,
}
}
/// Whether any configured profile selects a non-`none` strategy. No profile (or
/// a store error) ⇒ `none` (the dependency-free default posture).
async fn strategy_is_none(&self) -> bool {
match self.profiles.list().await {
Ok(profiles) => !profiles
.iter()
.any(|p| p.strategy != EmbedderStrategy::None),
// A store failure must not turn into a suggestion: behave as configured
// (i.e. *not* `none`) so we stay silent.
Err(_) => false,
}
}
/// Runs the check best-effort.
///
/// Order (each step short-circuits to "no suggestion"):
/// 1. already suggested this session ⇒ stop (no I/O beyond the guard);
/// 2. a non-`none` strategy is configured ⇒ stop;
/// 3. the persisted dismissal is `never` ⇒ stop;
/// 4. the memory index size does not exceed the budget ⇒ stop;
/// 5. otherwise mark the session guard, probe the environment, and publish
/// [`DomainEvent::EmbedderSuggested`].
///
/// # Errors
/// Never in practice — the signature keeps a uniform `Result` with the other
/// use cases; every failing port read degrades to `Ok(suggested: false)`.
pub async fn execute(
&self,
input: CheckEmbedderSuggestionInput,
) -> Result<CheckEmbedderSuggestionOutput, AppError> {
let not_suggested = Ok(CheckEmbedderSuggestionOutput { suggested: false });
// 1. Once per session per project (cheap guard check before any I/O).
if self
.suggested_this_session
.lock()
.map(|set| set.contains(&input.project_id))
.unwrap_or(true)
{
return not_suggested;
}
// 2. Only when no embedder is configured (strategy `none`).
if !self.strategy_is_none().await {
return not_suggested;
}
// 3. Persistent "ne plus demander" silences the suggestion forever.
if matches!(
self.prompts.read(&input.project_root).await,
Ok(Some(EmbedderPromptDismissal::Never))
) {
return not_suggested;
}
// 4. Memory must have outgrown the recall budget (a fresh project has 0).
let size = match self.memories.read_index(&input.project_root).await {
Ok(entries) => index_token_size(&entries),
Err(_) => return not_suggested,
};
if size <= self.budget {
return not_suggested;
}
// 5. Mark the guard *before* publishing so a concurrent activation cannot
// double-fire; if another thread won the race, stay silent.
match self.suggested_this_session.lock() {
Ok(mut set) => {
if !set.insert(input.project_id) {
return not_suggested;
}
}
Err(_) => return not_suggested,
}
let report = self.inspector.inspect().await;
self.events.publish(DomainEvent::EmbedderSuggested {
project_id: input.project_id,
ollama_detected: report.ollama_detected,
onnx_cached: report.onnx_cached_models,
vector_http_enabled: self.vector_http_enabled,
vector_onnx_enabled: self.vector_onnx_enabled,
});
Ok(CheckEmbedderSuggestionOutput { suggested: true })
}
}
// ---------------------------------------------------------------------------
// DismissEmbedderSuggestion
// ---------------------------------------------------------------------------
/// The user's response to the embedder suggestion popup.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DismissChoice {
/// "Plus tard" — re-proposable on a future session.
Later,
/// "Ne plus demander" — never again (persistent).
Never,
}
impl From<DismissChoice> for EmbedderPromptDismissal {
fn from(c: DismissChoice) -> Self {
match c {
DismissChoice::Later => Self::Later,
DismissChoice::Never => Self::Never,
}
}
}
/// Input for [`DismissEmbedderSuggestion::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DismissEmbedderSuggestionInput {
/// The project the suggestion concerned.
pub project_root: ProjectPath,
/// The user's choice.
pub choice: DismissChoice,
}
/// Persists the user's dismissal of the embedder suggestion (LOT C3).
pub struct DismissEmbedderSuggestion {
prompts: Arc<dyn EmbedderPromptStore>,
}
impl DismissEmbedderSuggestion {
/// Builds the use case from the [`EmbedderPromptStore`] port.
#[must_use]
pub fn new(prompts: Arc<dyn EmbedderPromptStore>) -> Self {
Self { prompts }
}
/// Writes the dismissal state. A `later` keeps the suggestion re-proposable on
/// a future session; a `never` silences it for good.
///
/// # Errors
/// [`AppError::Store`] on a persistence failure.
pub async fn execute(&self, input: DismissEmbedderSuggestionInput) -> Result<(), AppError> {
self.prompts
.write(&input.project_root, input.choice.into())
.await?;
Ok(())
}
}

View File

@ -0,0 +1,245 @@
//! Embedder configuration use cases (LOT C2). Each is a single-responsibility
//! struct carrying its ports as `Arc<dyn Port>` and exposing one `execute`.
//!
//! - [`ListEmbedderProfiles`] / [`SaveEmbedderProfile`] / [`DeleteEmbedderProfile`]
//! — CRUD over the persisted [`EmbedderProfile`]s through the
//! [`EmbedderProfileStore`] port.
//! - [`DescribeEmbedderEngines`] — a read-only view of the engines available to the
//! "configure an embedder?" UI: the recommended ONNX model catalogue, a best-effort
//! snapshot of the local environment ([`EmbedderEnvInspector`]), and which strategies
//! are actually compiled into this binary.
//!
//! Hexagonal boundary: the application depends on the domain ports and on plain
//! data only. The static engine catalogue and the compiled-capability flags are
//! **injected** at the composition root (the infrastructure owns `reqwest`/`fastembed`,
//! never the application).
use std::sync::Arc;
use domain::ports::{EmbedderEnvInspector, EmbedderProfileStore};
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use crate::error::AppError;
// ---------------------------------------------------------------------------
// ListEmbedderProfiles
// ---------------------------------------------------------------------------
/// Output of [`ListEmbedderProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListEmbedderProfilesOutput {
/// All configured embedder profiles (empty when none configured ⇒ `none`).
pub profiles: Vec<EmbedderProfile>,
}
/// Lists the configured embedder profiles from the store.
pub struct ListEmbedderProfiles {
store: Arc<dyn EmbedderProfileStore>,
}
impl ListEmbedderProfiles {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Lists configured embedder profiles.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self) -> Result<ListEmbedderProfilesOutput, AppError> {
Ok(ListEmbedderProfilesOutput {
profiles: self.store.list().await?,
})
}
}
// ---------------------------------------------------------------------------
// SaveEmbedderProfile
// ---------------------------------------------------------------------------
/// Input for [`SaveEmbedderProfile::execute`]: the raw fields of the profile to
/// upsert (validated into an [`EmbedderProfile`] entity).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveEmbedderProfileInput {
/// Stable identifier (e.g. `"local-onnx-minilm"`). Non-empty.
pub id: String,
/// Display name. Non-empty.
pub name: String,
/// Embedding strategy driving which concrete adapter is used.
pub strategy: EmbedderStrategy,
/// Model identifier, when the strategy needs one.
pub model: Option<String>,
/// Endpoint URL for a server/API strategy.
pub endpoint: Option<String>,
/// Name of the env var carrying the API key (never the key itself).
pub api_key_env: Option<String>,
/// Length of the vectors this engine produces. Non-zero.
pub dimension: usize,
}
/// Output of [`SaveEmbedderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveEmbedderProfileOutput {
/// The saved (validated) profile, echoed back.
pub profile: EmbedderProfile,
}
/// Persists (creates or replaces by id) a single embedder profile, after building
/// and validating the entity.
pub struct SaveEmbedderProfile {
store: Arc<dyn EmbedderProfileStore>,
}
impl SaveEmbedderProfile {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Validates then saves the profile.
///
/// # Errors
/// - [`AppError::Invalid`] if the profile's invariants are violated (empty
/// `id`/`name`, zero `dimension`),
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: SaveEmbedderProfileInput,
) -> Result<SaveEmbedderProfileOutput, AppError> {
let profile = EmbedderProfile::new(
input.id,
input.name,
input.strategy,
input.model,
input.endpoint,
input.api_key_env,
input.dimension,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.store.save(&profile).await?;
Ok(SaveEmbedderProfileOutput { profile })
}
}
// ---------------------------------------------------------------------------
// DeleteEmbedderProfile
// ---------------------------------------------------------------------------
/// Input for [`DeleteEmbedderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteEmbedderProfileInput {
/// Id of the embedder profile to delete.
pub id: String,
}
/// Deletes an embedder profile by id.
pub struct DeleteEmbedderProfile {
store: Arc<dyn EmbedderProfileStore>,
}
impl DeleteEmbedderProfile {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Deletes the profile.
///
/// # Errors
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
/// persistence failure.
pub async fn execute(&self, input: DeleteEmbedderProfileInput) -> Result<(), AppError> {
self.store.delete(&input.id).await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// DescribeEmbedderEngines
// ---------------------------------------------------------------------------
/// A recommendable local ONNX model, as a plain application value (mirrors the
/// infrastructure `OnnxModelInfo` data, injected at the composition root so the
/// application never depends on the infrastructure crate).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnnxModelView {
/// 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,
}
/// A read-only description of the embedding engines available to the
/// "configure an embedder?" UI (C2/C3).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmbedderEnginesView {
/// The curated catalogue of recommendable local ONNX models.
pub recommended_onnx: Vec<OnnxModelView>,
/// 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,
}
/// Describes the engines available to the embedder-configuration UI: the static
/// ONNX catalogue + compiled-capability flags (injected at construction), enriched
/// with a best-effort live snapshot of the local environment via the
/// [`EmbedderEnvInspector`] port. Read-only — emits no event, never fails on the
/// environment probe (the port is best-effort by contract).
pub struct DescribeEmbedderEngines {
inspector: Arc<dyn EmbedderEnvInspector>,
recommended_onnx: Vec<OnnxModelView>,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
}
impl DescribeEmbedderEngines {
/// Builds the use case from the [`EmbedderEnvInspector`] port plus the static
/// engine catalogue and compiled-capability flags (data owned by infrastructure
/// and injected at the composition root, so the application stays infra-free).
#[must_use]
pub fn new(
inspector: Arc<dyn EmbedderEnvInspector>,
recommended_onnx: Vec<OnnxModelView>,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
) -> Self {
Self {
inspector,
recommended_onnx,
vector_http_enabled,
vector_onnx_enabled,
}
}
/// Returns the engines view. Infallible in practice: the environment probe is
/// best-effort and degrades to "nothing detected".
///
/// # Errors
/// Never; the `Result` keeps the call site uniform with the other use cases.
#[allow(clippy::unused_async)]
pub async fn execute(&self) -> Result<EmbedderEnginesView, AppError> {
let report = self.inspector.inspect().await;
Ok(EmbedderEnginesView {
recommended_onnx: self.recommended_onnx.clone(),
ollama_detected: report.ollama_detected,
onnx_cached_models: report.onnx_cached_models,
vector_http_enabled: self.vector_http_enabled,
vector_onnx_enabled: self.vector_onnx_enabled,
})
}
}