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

@ -37,9 +37,7 @@ fn map_io(path: &RemotePath, err: &io::Error) -> FsError {
#[async_trait]
impl FileSystem for LocalFileSystem {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
fs::read(path.as_str())
.await
.map_err(|e| map_io(path, &e))
fs::read(path.as_str()).await.map_err(|e| map_io(path, &e))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
@ -69,11 +67,7 @@ impl FileSystem for LocalFileSystem {
.map_err(|e| map_io(path, &e))?;
while let Some(entry) = read_dir.next_entry().await.map_err(|e| map_io(path, &e))? {
let is_dir = entry
.file_type()
.await
.map(|t| t.is_dir())
.unwrap_or(false);
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
entries.push(DirEntry {
name: entry.file_name().to_string_lossy().into_owned(),
is_dir,

View File

@ -177,11 +177,7 @@ impl GitPort for Git2Repository {
Ok(())
}
async fn log(
&self,
root: &ProjectPath,
limit: usize,
) -> Result<Vec<GitCommitInfo>, GitError> {
async fn log(&self, root: &ProjectPath, limit: usize) -> Result<Vec<GitCommitInfo>, GitError> {
let repo = open(root)?;
let mut revwalk = repo.revwalk().map_err(op)?;
// No commits yet ⇒ nothing to walk.

View File

@ -224,9 +224,7 @@ fn parse_transcript(body: &[u8]) -> ConversationDetails {
let output = usage.output_tokens.unwrap_or(0);
if input != 0 || output != 0 {
saw_usage = true;
token_total = token_total
.saturating_add(input)
.saturating_add(output);
token_total = token_total.saturating_add(input).saturating_add(output);
}
}
}
@ -345,10 +343,7 @@ mod tests {
#[test]
fn parse_returns_none_when_no_topic_or_usage() {
let body = concat!(
r#"{"role":"assistant","content":"no usage here"}"#,
"\n",
);
let body = concat!(r#"{"role":"assistant","content":"no usage here"}"#, "\n",);
let d = parse_transcript(body.as_bytes());
assert_eq!(d.last_topic, None);
assert_eq!(d.token_count, None);

View File

@ -39,13 +39,15 @@ pub use process::LocalProcessSpawner;
pub use pty::PortablePtyAdapter;
pub use remote::{remote_host, LocalHost};
pub use runtime::CliAgentRuntime;
pub use store::{
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
AdaptiveMemoryRecall, FsEmbedderProfileStore, FsMemoryStore, FsProfileStore, FsProjectStore,
FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
};
#[cfg(feature = "vector-http")]
pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
#[cfg(feature = "vector-onnx")]
pub use store::OnnxEmbedder;
#[cfg(feature = "vector-http")]
pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
pub use store::{
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore,
FsMemoryStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, HashEmbedder,
IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
VECTOR_ONNX_ENABLED,
};

View File

@ -205,7 +205,10 @@ async fn scan_once(
/// Whether `path` is a request file to process: a `.json` that is not a
/// `.response.json` sibling we wrote ourselves.
fn is_request_file(path: &Path) -> bool {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
name.ends_with(".json") && !name.ends_with(".response.json")
}
@ -242,14 +245,21 @@ async fn dispatch_file(
Ok(r) => r,
Err(e) => return OrchestratorResponse::failure(None, format!("invalid json: {e}")),
};
let action = request.action.clone();
let action = request
.request_type
.as_ref()
.or(request.action.as_ref())
.cloned();
let command = match request.validate() {
Ok(c) => c,
Err(e) => return OrchestratorResponse::failure(Some(action), e.to_string()),
Err(e) => return OrchestratorResponse::failure(action, e.to_string()),
};
match service.dispatch(project, command).await {
Ok(out) => OrchestratorResponse::success(action, out.detail),
Err(e) => OrchestratorResponse::failure(Some(action), e.to_string()),
Ok(out) => OrchestratorResponse::success(
action.unwrap_or_else(|| "unknown".to_owned()),
out.detail,
),
Err(e) => OrchestratorResponse::failure(action, e.to_string()),
}
}

View File

@ -256,9 +256,7 @@ impl PtyPort for PortablePtyAdapter {
live.writer
.write_all(data)
.map_err(|e| PtyError::Io(e.to_string()))?;
live.writer
.flush()
.map_err(|e| PtyError::Io(e.to_string()))
live.writer.flush().map_err(|e| PtyError::Io(e.to_string()))
}
fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> {
@ -313,10 +311,7 @@ impl PtyPort for PortablePtyAdapter {
// Ask the child to terminate, then wait for its real status.
let _ = live.child.kill();
let status = live
.child
.wait()
.map_err(|e| PtyError::Io(e.to_string()))?;
let status = live.child.wait().map_err(|e| PtyError::Io(e.to_string()))?;
// Dropping master/writer closes the PTY; the reader thread then sees EOF.
// Dropping the broadcast hub drops every subscriber's sender, so any

View File

@ -70,8 +70,7 @@ impl CliAgentRuntime {
let args = tokens.map(str::to_owned).collect();
// Detection runs in a neutral cwd; "." is a safe relative placeholder the
// spawner resolves against the process cwd.
let cwd = ProjectPath::new("/")
.map_err(|e| RuntimeError::Detection(e.to_string()))?;
let cwd = ProjectPath::new("/").map_err(|e| RuntimeError::Detection(e.to_string()))?;
Ok(SpawnSpec {
command,
args,
@ -90,7 +89,10 @@ impl CliAgentRuntime {
/// `{projectRoot}` is still substituted with the same base for backwards
/// compatibility (the caller always passes the run dir now). An empty template
/// defaults to the base itself.
fn resolve_cwd(profile: &AgentProfile, base: &ProjectPath) -> Result<ProjectPath, RuntimeError> {
fn resolve_cwd(
profile: &AgentProfile,
base: &ProjectPath,
) -> Result<ProjectPath, RuntimeError> {
let template = profile.cwd_template.trim();
if template.is_empty() {
return Ok(base.clone());
@ -117,10 +119,7 @@ impl CliAgentRuntime {
/// (e.g. `-f` → `["-f", "<path>"]`); a flag *with* `{path}` is split on
/// whitespace after substitution (e.g. `--context-file {path}` →
/// `["--context-file", "<path>"]`).
fn injection_plan(
injection: &ContextInjection,
ctx: &PreparedContext,
) -> ContextInjectionPlan {
fn injection_plan(injection: &ContextInjection, ctx: &PreparedContext) -> ContextInjectionPlan {
match injection {
ContextInjection::ConventionFile { target } => ContextInjectionPlan::File {
target: target.clone(),

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)
}
}