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:
@ -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)
|
||||
|
||||
@ -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}")))?
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user