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:
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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