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

@ -67,7 +67,10 @@ impl IdeaiContextStore {
/// Absolute path of the manifest file for a project.
fn manifest_path(project: &Project) -> RemotePath {
RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{AGENTS_FILE}")))
RemotePath::new(Self::join(
&project.root,
&format!("{IDEAI_DIR}/{AGENTS_FILE}"),
))
}
/// Absolute path of an agent context `.md` from its (`.ideai/`-relative)

View File

@ -23,10 +23,27 @@
//! repeatable vectors from a hashing bag-of-words, good enough for tests and for a
//! trivial offline fallback).
use async_trait::async_trait;
use std::path::PathBuf;
use std::sync::Arc;
use domain::ports::{Embedder, EmbedderError};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ports::{
Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderPromptDismissal,
EmbedderPromptStore, FileSystem, FsError, RemotePath, StoreError,
};
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use domain::project::ProjectPath;
/// Whether this binary was compiled with the HTTP embedding capability
/// (`localServer`/`api` real engines + Ollama detection). Reported honestly to the
/// C2/C3 UI so it only offers strategies actually wired into *this* build.
pub const VECTOR_HTTP_ENABLED: bool = cfg!(feature = "vector-http");
/// Whether this binary was compiled with the in-process ONNX embedding capability
/// (`localOnnx` real engine via `fastembed`). Reported honestly to the C2/C3 UI.
pub const VECTOR_ONNX_ENABLED: bool = cfg!(feature = "vector-onnx");
/// Builds the concrete [`Embedder`] for a profile, or `None` when the strategy is
/// [`EmbedderStrategy::None`] (recall stays naïve).
@ -50,7 +67,10 @@ pub fn embedder_from_profile(
EmbedderStrategy::LocalOnnx => {
#[cfg(feature = "vector-onnx")]
{
Some(Box::new(OnnxEmbedder::from_profile(profile, onnx_cache_dir)))
Some(Box::new(OnnxEmbedder::from_profile(
profile,
onnx_cache_dir,
)))
}
#[cfg(not(feature = "vector-onnx"))]
{
@ -325,10 +345,9 @@ impl Embedder for HttpEmbedder {
}
}
let response = request
.send()
.await
.map_err(|e| EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint)))?;
let response = request.send().await.map_err(|e| {
EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint))
})?;
if !response.status().is_success() {
return Err(EmbedderError::Unavailable(format!(
@ -464,6 +483,189 @@ pub fn onnx_model_is_cached(cache_dir: &std::path::Path, model: &str) -> bool {
false
}
// ---------------------------------------------------------------------------
// EmbedderEnvProbe — best-effort local-environment inspector (LOT C2/C3).
// ---------------------------------------------------------------------------
/// Default base URL of a local Ollama-style embedding server, probed by
/// [`EmbedderEnvProbe`] to detect an already-installed local engine (the
/// Linux-spirit "detect the existing first" rule). The base (no path): the probe
/// appends `/api/tags` via [`detect_ollama`].
pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434";
/// Concrete [`EmbedderEnvInspector`]: a **best-effort, never-failing** probe of the
/// local embedding environment.
///
/// - `onnx_cached_models`: for each model in [`RECOMMENDED_ONNX_MODELS`], reports
/// the ids already present in `onnx_cache_dir` (pure FS, no `fastembed`
/// dependency). The blocking `read_dir` runs on a `spawn_blocking` thread so it
/// never stalls the async runtime.
/// - `ollama_detected`: `true` only when built with the `vector-http` capability
/// **and** an Ollama-style server answers at `ollama_base_url`; `false` otherwise.
///
/// Construction is cheap and infallible; nothing touches disk or the network until
/// [`inspect`](EmbedderEnvInspector::inspect) is called.
#[derive(Debug, Clone)]
pub struct EmbedderEnvProbe {
onnx_cache_dir: PathBuf,
/// Only read under `vector-http` (by [`detect_ollama`]); retained unconditionally
/// so the constructor signature is stable across build configs.
#[cfg_attr(not(feature = "vector-http"), allow(dead_code))]
ollama_base_url: String,
}
impl EmbedderEnvProbe {
/// Builds the probe from the ONNX cache directory (`<app_data_dir>/embedders/onnx`,
/// see [`ONNX_CACHE_SUBDIR`]) and the base URL of the local Ollama-style server.
#[must_use]
pub fn new(onnx_cache_dir: PathBuf, ollama_base_url: impl Into<String>) -> Self {
Self {
onnx_cache_dir,
ollama_base_url: ollama_base_url.into(),
}
}
}
#[async_trait]
impl EmbedderEnvInspector for EmbedderEnvProbe {
async fn inspect(&self) -> EmbedderEnvReport {
// Pure-FS cache scan off the runtime: `onnx_model_is_cached` calls blocking
// `read_dir`, so run the whole scan on a blocking thread.
let cache_dir = self.onnx_cache_dir.clone();
let onnx_cached_models = tokio::task::spawn_blocking(move || {
RECOMMENDED_ONNX_MODELS
.iter()
.filter(|m| onnx_model_is_cached(&cache_dir, m.id))
.map(|m| m.id.to_owned())
.collect::<Vec<String>>()
})
.await
.unwrap_or_default();
#[cfg(feature = "vector-http")]
let ollama_detected = detect_ollama(&self.ollama_base_url).await;
#[cfg(not(feature = "vector-http"))]
let ollama_detected = false;
EmbedderEnvReport {
ollama_detected,
onnx_cached_models,
}
}
}
// ---------------------------------------------------------------------------
// FsEmbedderPromptStore — per-project embedder-suggestion state (LOT C3).
// ---------------------------------------------------------------------------
/// `.ideai/` directory name inside a project root.
const PROMPT_IDEAI_DIR: &str = ".ideai";
/// Memory sub-dir (the suggestion state lives beside the memory it concerns).
const PROMPT_MEMORY_DIR: &str = "memory";
/// State file name (dot-prefixed: derived/local state, like the vector index).
const PROMPT_FILE: &str = ".embedder-prompt.json";
/// Wire form of the suggestion-dismissal choice (camelCase).
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
enum DismissalWire {
Later,
Never,
}
impl From<EmbedderPromptDismissal> for DismissalWire {
fn from(d: EmbedderPromptDismissal) -> Self {
match d {
EmbedderPromptDismissal::Later => Self::Later,
EmbedderPromptDismissal::Never => Self::Never,
}
}
}
impl From<DismissalWire> for EmbedderPromptDismissal {
fn from(w: DismissalWire) -> Self {
match w {
DismissalWire::Later => Self::Later,
DismissalWire::Never => Self::Never,
}
}
}
/// On-disk shape of `.ideai/memory/.embedder-prompt.json`:
/// `{ "dismissed": "later" | "never" }`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
struct PromptDoc {
dismissed: DismissalWire,
}
/// File-backed [`EmbedderPromptStore`] (LOT C3): persists the per-project response
/// to the embedder suggestion under `.ideai/memory/.embedder-prompt.json`, through
/// the [`FileSystem`] port (so it is location-neutral, SSH/WSL unchanged).
///
/// Reads are **best-effort**: a missing file ⇒ `Ok(None)` (never answered); a
/// malformed file also degrades to `Ok(None)` rather than surfacing an error, so a
/// hand-corrupted state never blocks the suggestion logic.
#[derive(Clone)]
pub struct FsEmbedderPromptStore {
fs: Arc<dyn FileSystem>,
}
impl FsEmbedderPromptStore {
/// Builds the store from the [`FileSystem`] port.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// `<root>/.ideai/memory`.
fn memory_dir(root: &ProjectPath) -> String {
let base = root.as_str().trim_end_matches(['/', '\\']);
format!("{base}/{PROMPT_IDEAI_DIR}/{PROMPT_MEMORY_DIR}")
}
/// `<memory-dir>/.embedder-prompt.json`.
fn prompt_path(root: &ProjectPath) -> RemotePath {
RemotePath::new(format!("{}/{PROMPT_FILE}", Self::memory_dir(root)))
}
}
#[async_trait]
impl EmbedderPromptStore for FsEmbedderPromptStore {
async fn read(
&self,
root: &ProjectPath,
) -> Result<Option<EmbedderPromptDismissal>, StoreError> {
match self.fs.read(&Self::prompt_path(root)).await {
Ok(bytes) => Ok(serde_json::from_slice::<PromptDoc>(&bytes)
.ok()
.map(|doc| doc.dismissed.into())),
// Never answered (or unreadable) ⇒ no recorded dismissal.
Err(FsError::NotFound(_)) | Err(_) => Ok(None),
}
}
async fn write(
&self,
root: &ProjectPath,
dismissal: EmbedderPromptDismissal,
) -> Result<(), StoreError> {
let dir = RemotePath::new(Self::memory_dir(root));
self.fs
.create_dir_all(&dir)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
let doc = PromptDoc {
dismissed: dismissal.into(),
};
let bytes = serde_json::to_vec_pretty(&doc)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
self.fs
.write(&Self::prompt_path(root), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}
#[cfg(feature = "vector-onnx")]
mod onnx {
use std::path::{Path, PathBuf};
@ -546,9 +748,7 @@ mod onnx {
.get_or_try_init(|| async {
let cache_dir = self.cache_dir.clone();
let built = tokio::task::spawn_blocking(move || {
TextEmbedding::try_new(
InitOptions::new(model).with_cache_dir(cache_dir),
)
TextEmbedding::try_new(InitOptions::new(model).with_cache_dir(cache_dir))
})
.await
.map_err(|e| EmbedderError::Io(format!("onnx init task failed: {e}")))?

View File

@ -14,14 +14,15 @@ mod template;
mod vector;
pub use context::IdeaiContextStore;
pub use embedder::{
embedder_from_profile, onnx_model_is_cached, HashEmbedder, OnnxModelInfo, StubEmbedder,
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
};
#[cfg(feature = "vector-http")]
pub use embedder::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
#[cfg(feature = "vector-onnx")]
pub use embedder::OnnxEmbedder;
#[cfg(feature = "vector-http")]
pub use embedder::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
pub use embedder::{
embedder_from_profile, onnx_model_is_cached, EmbedderEnvProbe, FsEmbedderPromptStore,
HashEmbedder, OnnxModelInfo, StubEmbedder, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
pub use project::FsProjectStore;

View File

@ -23,7 +23,7 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ids::ProfileId;
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError};
use domain::ports::{EmbedderProfileStore, FileSystem, ProfileStore, RemotePath, StoreError};
use domain::profile::{AgentProfile, EmbedderProfile};
/// File name of the profiles store inside the app-data dir.
@ -181,10 +181,11 @@ impl Default for EmbedderDoc {
/// calqué on [`FsProfileStore`]: `embedder.json` in the global IDE app-data dir,
/// mirroring `profiles.json`.
///
/// No domain `EmbedderProfileStore` port exists yet — embedder profiles are pure
/// configuration loaded at the composition root, so this is a concrete loader. A
/// dedicated port (parallel to [`ProfileStore`]) is an easy follow-up the day the
/// UI needs CRUD over embedder profiles.
/// Implements the domain [`EmbedderProfileStore`] port (parallel to [`ProfileStore`]).
/// The inherent `list`/`save`/`delete` methods are kept so the composition root can
/// load the configured profile *before* type-erasing to `Arc<dyn EmbedderProfileStore>`
/// (e.g. inside a one-shot blocking runtime in `state.rs`); the trait impl simply
/// delegates to them.
///
/// Cheap to clone (everything behind `Arc`).
#[derive(Clone)]
@ -270,3 +271,18 @@ impl FsEmbedderProfileStore {
self.write_doc(&doc).await
}
}
#[async_trait]
impl EmbedderProfileStore for FsEmbedderProfileStore {
async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError> {
FsEmbedderProfileStore::list(self).await
}
async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> {
FsEmbedderProfileStore::save(self, profile).await
}
async fn delete(&self, id: &str) -> Result<(), StoreError> {
FsEmbedderProfileStore::delete(self, id).await
}
}

View File

@ -76,8 +76,9 @@ impl FsProjectStore {
async fn read_registry(&self) -> Result<Registry, StoreError> {
let path = self.path(REGISTRY_FILE);
match self.fs.read(&path).await {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| StoreError::Serialization(e.to_string())),
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
Err(domain::ports::FsError::NotFound(_)) => Ok(Registry {
version: REGISTRY_VERSION,
projects: Vec::new(),

View File

@ -183,8 +183,13 @@ impl FsSkillStore {
})?;
let content =
String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
Skill::new(entry.id, entry.name.clone(), MarkdownDoc::new(content), scope)
.map_err(|e| StoreError::Serialization(e.to_string()))
Skill::new(
entry.id,
entry.name.clone(),
MarkdownDoc::new(content),
scope,
)
.map_err(|e| StoreError::Serialization(e.to_string()))
}
}

View File

@ -235,10 +235,7 @@ impl VectorMemoryRecall {
/// the `?` plumbing compiles; in practice [`AdaptiveMemoryRecall`] guards this
/// path and falls back to naïve before any such error reaches a use case.
async fn recall_embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
self.embedder
.embed(texts)
.await
.map_err(map_embedder_error)
self.embedder.embed(texts).await.map_err(map_embedder_error)
}
}