feat(memory): système de mémoire projet model-agnostic (L14, LOT A+B+C)
Base de connaissance persistante par projet, indépendante de tout modèle/CLU
et de git. Cadrage archi en §14.5 (ARCHITECTURE.md), cycle Archi→Dev→Test.
LOT A — étage 1 (.md, source de vérité)
- domaine: entité Memory (+ MemorySlug, MemoryType, MemoryFrontmatter,
MemoryLink, MemoryIndexEntry), liens [[slug]], index MEMORY.md dérivé
- port MemoryStore + MemoryError, adapter FsMemoryStore (.ideai/memory/)
- application: 7 use cases (Create/Update/List/Get/Delete/ReadIndex/
ResolveLinks), From<MemoryError> for AppError
- app-tauri: commandes + DTO, events MemorySaved/MemoryDeleted
- suppression de la variante morte DomainError::MalformedFrontmatter
LOT B — rappel adaptatif (étage 1)
- port MemoryRecall + MemoryQuery, adapter NaiveMemoryRecall (troncature
au budget de tokens, court-circuit budget-0), use case RecallMemory
LOT C — étage 2 vectoriel (structure complète, zéro dépendance lourde)
- port Embedder + EmbedderError, profils déclaratifs EmbedderProfile/
EmbedderStrategy (embedder.json)
- VectorMemoryRecall (cosinus, cache .ideai/memory/.index/ gitignoré)
- AdaptiveMemoryRecall (bascule pure should_use_vector), défaut none
- HashEmbedder (déterministe, tests), StubEmbedder (onnx/server/api)
Tests: 57 binaires verts, build + clippy --workspace sans warning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -65,6 +65,13 @@ pub enum DomainError {
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// A memory slug was not valid kebab-case (`[a-z0-9-]`, non-empty).
|
||||
#[error("`{value}` is not a valid kebab-case slug")]
|
||||
InvalidSlug {
|
||||
/// The offending value.
|
||||
value: String,
|
||||
},
|
||||
|
||||
/// A generic invariant violation with an explanatory message.
|
||||
#[error("invariant violated: {0}")]
|
||||
Invariant(String),
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
//! presentation layer (ARCHITECTURE §3.2).
|
||||
|
||||
use crate::ids::{AgentId, ProjectId, SessionId, SkillId, TemplateId};
|
||||
use crate::memory::MemorySlug;
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
/// Events emitted by the domain/application as state changes occur.
|
||||
@ -90,6 +91,21 @@ pub enum DomainEvent {
|
||||
/// Whether IdeA handled it successfully.
|
||||
ok: bool,
|
||||
},
|
||||
/// A memory note was created or updated (`.md` written, index upserted).
|
||||
MemorySaved {
|
||||
/// The saved note's slug.
|
||||
slug: MemorySlug,
|
||||
},
|
||||
/// A memory note was deleted.
|
||||
MemoryDeleted {
|
||||
/// The deleted note's slug.
|
||||
slug: MemorySlug,
|
||||
},
|
||||
/// The aggregated `MEMORY.md` index was rebuilt for a project.
|
||||
MemoryIndexRebuilt {
|
||||
/// The project whose index was rebuilt.
|
||||
project_id: ProjectId,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
|
||||
@ -37,6 +37,7 @@ pub mod git;
|
||||
pub mod ids;
|
||||
pub mod layout;
|
||||
pub mod markdown;
|
||||
pub mod memory;
|
||||
pub mod orchestrator;
|
||||
pub mod ports;
|
||||
pub mod profile;
|
||||
@ -67,10 +68,16 @@ pub use skill::{Skill, SkillRef, SkillScope};
|
||||
|
||||
pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
pub use profile::{AgentProfile, ContextInjection, SessionStrategy};
|
||||
pub use profile::{
|
||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy,
|
||||
};
|
||||
|
||||
pub use markdown::MarkdownDoc;
|
||||
|
||||
pub use memory::{
|
||||
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
|
||||
};
|
||||
|
||||
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
||||
|
||||
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
@ -87,9 +94,11 @@ pub use events::DomainEvent;
|
||||
pub use orchestrator::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
|
||||
|
||||
pub use ports::{
|
||||
AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, Embedder,
|
||||
EmbedderError, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
|
||||
IdGenerator, Output, OutputStream, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
|
||||
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
|
||||
SpawnSpec, StoreError, TemplateStore,
|
||||
IdGenerator, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream,
|
||||
PreparedContext, ProcessError,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError,
|
||||
RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore,
|
||||
};
|
||||
|
||||
194
crates/domain/src/memory.rs
Normal file
194
crates/domain/src/memory.rs
Normal file
@ -0,0 +1,194 @@
|
||||
//! Memory entity — the persistent, model-agnostic knowledge base of a project
|
||||
//! (LOT A, étage 1: `.md` files as the single source of truth).
|
||||
//!
|
||||
//! A [`Memory`] is one Markdown note stored under `.ideai/memory/<slug>.md`. Its
|
||||
//! frontmatter carries the structured metadata (a kebab-case [`MemorySlug`], a
|
||||
//! human-readable description, and a [`MemoryType`]); its body is opaque
|
||||
//! [`MarkdownDoc`] text. Notes cross-reference one another via `[[slug]]` wiki
|
||||
//! links, scanned by [`Memory::outgoing_links`].
|
||||
//!
|
||||
//! The aggregated `.ideai/memory/MEMORY.md` index (one [`MemoryIndexEntry`] line
|
||||
//! per note) is derived data: [`Memory::index_entry`] produces a note's row. The
|
||||
//! adapter (`FsMemoryStore`) owns the on-disk YAML frontmatter and index file
|
||||
//! formats; the domain stays I/O-free and format-neutral.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::markdown::MarkdownDoc;
|
||||
|
||||
/// A kebab-case identifier for a memory note (`[a-z0-9]` plus `-`), used both as
|
||||
/// the on-disk file stem (`<slug>.md`) and as the `[[slug]]` link target.
|
||||
///
|
||||
/// Invariants enforced by [`MemorySlug::new`]:
|
||||
/// - non-empty,
|
||||
/// - only lowercase ASCII letters, ASCII digits, and `-`,
|
||||
/// - therefore no uppercase, no whitespace, and no `.` (so no `..` traversal).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct MemorySlug(String);
|
||||
|
||||
impl MemorySlug {
|
||||
/// Builds a validated kebab-case slug.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`DomainError::InvalidSlug`] if `raw` is empty or contains any character
|
||||
/// outside `[a-z0-9-]`.
|
||||
pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let raw = raw.into();
|
||||
let invalid = || DomainError::InvalidSlug {
|
||||
value: raw.clone(),
|
||||
};
|
||||
if raw.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
if raw
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
Ok(Self(raw))
|
||||
} else {
|
||||
Err(invalid())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the slug as a string slice.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MemorySlug {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of a memory note, driving how it is surfaced and prioritised.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MemoryType {
|
||||
/// A user-authored preference or instruction.
|
||||
User,
|
||||
/// Feedback captured from a prior interaction.
|
||||
Feedback,
|
||||
/// A project-level fact or decision.
|
||||
Project,
|
||||
/// A reference / external knowledge note.
|
||||
Reference,
|
||||
}
|
||||
|
||||
/// The structured frontmatter of a memory note.
|
||||
///
|
||||
/// Serialised with `type` (not `kind`) as the discriminator field name, matching
|
||||
/// the on-disk YAML `metadata.type` (the adapter maps the nesting). Invariant:
|
||||
/// `description` is non-empty (enforced at [`Memory::new`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MemoryFrontmatter {
|
||||
/// Stable kebab-case identifier (also the file stem).
|
||||
pub name: MemorySlug,
|
||||
/// Human-readable one-line description (the index hook). Non-empty.
|
||||
pub description: String,
|
||||
/// The note's kind.
|
||||
#[serde(rename = "type")]
|
||||
pub r#type: MemoryType,
|
||||
}
|
||||
|
||||
/// A `[[slug]]` wiki link found in a note's body, pointing at another note.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct MemoryLink {
|
||||
/// The linked note's slug.
|
||||
pub target: MemorySlug,
|
||||
}
|
||||
|
||||
/// One row of the aggregated `MEMORY.md` index: the `- [Title](slug.md) — hook`
|
||||
/// line, decomposed into its parts.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemoryIndexEntry {
|
||||
/// The note's slug.
|
||||
pub slug: MemorySlug,
|
||||
/// The note's display title (currently the slug; titles are derived later).
|
||||
pub title: String,
|
||||
/// The one-line hook (the frontmatter description).
|
||||
pub hook: String,
|
||||
/// The note's kind.
|
||||
pub r#type: MemoryType,
|
||||
}
|
||||
|
||||
/// A memory note: validated frontmatter plus an opaque Markdown body.
|
||||
///
|
||||
/// Invariants enforced by [`Memory::new`]:
|
||||
/// - `frontmatter.description` non-empty,
|
||||
/// - `body` non-empty (an empty note carries no knowledge).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Memory {
|
||||
/// Structured metadata.
|
||||
pub frontmatter: MemoryFrontmatter,
|
||||
/// Markdown body of the note.
|
||||
pub body: MarkdownDoc,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
/// Builds a validated memory note.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`DomainError::EmptyField`] if `frontmatter.description` is empty,
|
||||
/// - [`DomainError::EmptyField`] if `body` is empty.
|
||||
pub fn new(frontmatter: MemoryFrontmatter, body: MarkdownDoc) -> Result<Self, DomainError> {
|
||||
crate::validation::non_empty(&frontmatter.description, "memory.description")?;
|
||||
if body.is_empty() {
|
||||
return Err(DomainError::EmptyField {
|
||||
field: "memory.body",
|
||||
});
|
||||
}
|
||||
Ok(Self { frontmatter, body })
|
||||
}
|
||||
|
||||
/// Returns the note's slug (its identity).
|
||||
#[must_use]
|
||||
pub fn slug(&self) -> &MemorySlug {
|
||||
&self.frontmatter.name
|
||||
}
|
||||
|
||||
/// Scans the body for `[[slug]]` wiki links, in order of appearance.
|
||||
///
|
||||
/// Tokens whose inner text is not a valid [`MemorySlug`] are skipped (a
|
||||
/// malformed link is not a hard error here). Duplicates are preserved — the
|
||||
/// caller dedups if it wants a unique link set. No regex: a small linear scan.
|
||||
#[must_use]
|
||||
pub fn outgoing_links(&self) -> Vec<MemoryLink> {
|
||||
let text = self.body.as_str();
|
||||
let bytes = text.as_bytes();
|
||||
let mut links = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 1 < bytes.len() {
|
||||
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
|
||||
// Find the closing `]]`.
|
||||
if let Some(close) = text[i + 2..].find("]]") {
|
||||
let inner = &text[i + 2..i + 2 + close];
|
||||
if let Ok(target) = MemorySlug::new(inner) {
|
||||
links.push(MemoryLink { target });
|
||||
}
|
||||
i = i + 2 + close + 2;
|
||||
continue;
|
||||
}
|
||||
// No closing token: stop scanning further `[[`.
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
links
|
||||
}
|
||||
|
||||
/// Produces this note's row in the aggregated `MEMORY.md` index.
|
||||
#[must_use]
|
||||
pub fn index_entry(&self) -> MemoryIndexEntry {
|
||||
MemoryIndexEntry {
|
||||
slug: self.frontmatter.name.clone(),
|
||||
title: self.frontmatter.name.as_str().to_string(),
|
||||
hook: self.frontmatter.description.clone(),
|
||||
r#type: self.frontmatter.r#type,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,7 @@ use crate::agent::AgentManifest;
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{AgentId, SessionId};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use crate::profile::AgentProfile;
|
||||
use crate::project::{Project, ProjectPath};
|
||||
use crate::remote::RemoteKind;
|
||||
@ -257,6 +258,46 @@ pub enum StoreError {
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Errors from the [`MemoryStore`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum MemoryError {
|
||||
/// The requested note was not found.
|
||||
#[error("memory not found")]
|
||||
NotFound,
|
||||
/// A note's frontmatter could not be parsed or validated.
|
||||
#[error("memory frontmatter error: {0}")]
|
||||
Frontmatter(String),
|
||||
/// Underlying I/O error.
|
||||
#[error("memory io failed: {0}")]
|
||||
Io(String),
|
||||
/// (De)serialisation of the index or another structured part failed.
|
||||
#[error("memory serialization failed: {0}")]
|
||||
Serialization(String),
|
||||
}
|
||||
|
||||
/// Errors from an [`Embedder`] (LOT C, étage 2 vectoriel).
|
||||
///
|
||||
/// Best-effort by contract: a [`MemoryRecall`] that composes an embedder must
|
||||
/// **degrade**, never fail hard, on any of these — an unavailable engine or an
|
||||
/// unimplemented strategy maps to a fallback on the naïve recall (it must never
|
||||
/// surface as a hard recall error). See [`Embedder`] and the `VectorMemoryRecall`
|
||||
/// / `AdaptiveMemoryRecall` adapters.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum EmbedderError {
|
||||
/// The embedding engine is not installed / not reachable (e.g. a local ONNX
|
||||
/// model file is missing, or a remote embedding server / API is unreachable).
|
||||
#[error("embedder unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
/// The requested strategy is not implemented yet (the concrete `localOnnx` /
|
||||
/// `localServer` / `api` backends ship as documented stubs returning this —
|
||||
/// never a panic). Real ONNX/HTTP integration is an explicit follow-up.
|
||||
#[error("embedder strategy unsupported: {0}")]
|
||||
Unsupported(String),
|
||||
/// An I/O failure while producing embeddings.
|
||||
#[error("embedder io failed: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Errors from [`RemoteHost`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum RemoteError {
|
||||
@ -561,6 +602,130 @@ pub trait SkillStore: Send + Sync {
|
||||
) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// CRUD for project [`Memory`] notes — the `.md` knowledge base under
|
||||
/// `.ideai/memory/` (LOT A, étage 1). Notes are the single source of truth; the
|
||||
/// aggregated `MEMORY.md` index is derived and kept in sync on every write.
|
||||
///
|
||||
/// `root` identifies the project whose `.ideai/memory/` to use; it is supplied
|
||||
/// **per call** (mirroring [`SkillStore`]) so a single store instance serves
|
||||
/// every open project.
|
||||
#[async_trait]
|
||||
pub trait MemoryStore: Send + Sync {
|
||||
/// Lists all memory notes for `root`'s project.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError`] on failure (e.g. a malformed note's frontmatter).
|
||||
async fn list(&self, root: &ProjectPath) -> Result<Vec<Memory>, MemoryError>;
|
||||
|
||||
/// Gets a memory note by slug.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError::NotFound`] if absent; [`MemoryError::Frontmatter`] if its
|
||||
/// frontmatter is malformed.
|
||||
async fn get(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError>;
|
||||
|
||||
/// Saves (creates or replaces by slug) a note: writes `<slug>.md` and upserts
|
||||
/// its line in `MEMORY.md` idempotently.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError`] on failure.
|
||||
async fn save(&self, root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError>;
|
||||
|
||||
/// Deletes a note by slug, removing its line from `MEMORY.md`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError::NotFound`] if absent.
|
||||
async fn delete(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError>;
|
||||
|
||||
/// Reads the aggregated `MEMORY.md` index as structured entries (empty if the
|
||||
/// index does not exist yet).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError`] on an I/O failure.
|
||||
async fn read_index(&self, root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError>;
|
||||
|
||||
/// Resolves the `[[slug]]` links emanating from `slug`'s note, **ignoring
|
||||
/// broken links** (targets that do not resolve to an existing note).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError::NotFound`] if `slug` itself does not exist.
|
||||
async fn resolve_links(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
slug: &MemorySlug,
|
||||
) -> Result<Vec<MemoryLink>, MemoryError>;
|
||||
}
|
||||
|
||||
/// A recall request: the query text plus the token budget bounding the result
|
||||
/// (LOT B, étage 1). `text` is typically the agent's current context; the naïve
|
||||
/// adapter ignores it, but a semantic [`MemoryRecall`] (LOT C) ranks against it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemoryQuery {
|
||||
/// The recall query (often the agent's current working context).
|
||||
pub text: String,
|
||||
/// Approximate token budget the returned entries must fit within. A budget of
|
||||
/// `0` yields an empty result.
|
||||
pub token_budget: usize,
|
||||
}
|
||||
|
||||
/// Adaptive recall of the most relevant subset of a project's memory index for a
|
||||
/// query, bounded by a token budget (LOT B, étage 1).
|
||||
///
|
||||
/// Contract (best-effort, never blocking):
|
||||
/// - an empty or absent memory yields an empty list, **never** an error;
|
||||
/// - a `token_budget` of `0` yields an empty list;
|
||||
/// - the naïve adapter ignores semantic relevance and returns the index entries
|
||||
/// in order, truncated to fit the budget.
|
||||
///
|
||||
/// **Liskov**: every implementation (`NaiveMemoryRecall`, the future
|
||||
/// `VectorMemoryRecall`) is substitutable — same emptiness/budget guarantees, only
|
||||
/// the relevance strategy differs.
|
||||
#[async_trait]
|
||||
pub trait MemoryRecall: Send + Sync {
|
||||
/// Returns the entries most relevant to `query` for `root`'s project, capped
|
||||
/// at `query.token_budget`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError`] only on an unexpected I/O failure of the underlying store;
|
||||
/// an empty or missing memory is **not** an error (returns an empty list).
|
||||
async fn recall(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
query: &MemoryQuery,
|
||||
) -> Result<Vec<MemoryIndexEntry>, MemoryError>;
|
||||
}
|
||||
|
||||
/// Produces embedding vectors for texts, driven by a declarative
|
||||
/// [`crate::profile::EmbedderProfile`] (façon §9 — adding an engine is *data*,
|
||||
/// not code: Open/Closed). The étage-2 vector recall (LOT C) composes this port
|
||||
/// to rank memory notes semantically.
|
||||
///
|
||||
/// Contract:
|
||||
/// - [`embed`](Self::embed) returns **one vector per input text**, in the same
|
||||
/// order, each of length [`dimension`](Self::dimension);
|
||||
/// - it is **best-effort from the caller's standpoint**: any failure is an
|
||||
/// [`EmbedderError`], on which a composing [`MemoryRecall`] degrades to the
|
||||
/// naïve recall — it must never bubble up as a hard recall error;
|
||||
/// - implementations are **substitutable** (Liskov): a deterministic test
|
||||
/// embedder and a real ONNX/HTTP one differ only in vector quality, not in the
|
||||
/// shape of their guarantees.
|
||||
#[async_trait]
|
||||
pub trait Embedder: Send + Sync {
|
||||
/// Stable identifier of this embedder (e.g. `"local-onnx-minilm"`).
|
||||
fn id(&self) -> &str;
|
||||
|
||||
/// Embeds each text into a `dimension()`-length vector, preserving order.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`EmbedderError::Unavailable`] if the engine is not installed/reachable,
|
||||
/// - [`EmbedderError::Unsupported`] if the strategy is not implemented yet,
|
||||
/// - [`EmbedderError::Io`] on an I/O failure.
|
||||
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError>;
|
||||
|
||||
/// The length of every vector produced by [`embed`](Self::embed).
|
||||
fn dimension(&self) -> usize;
|
||||
}
|
||||
|
||||
/// Persistence of the known-projects registry and the workspace.
|
||||
#[async_trait]
|
||||
pub trait ProjectStore: Send + Sync {
|
||||
|
||||
@ -150,6 +150,107 @@ pub struct AgentProfile {
|
||||
pub session: Option<SessionStrategy>,
|
||||
}
|
||||
|
||||
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
|
||||
///
|
||||
/// Declarative, Open/Closed: a new engine family is a new variant *only* if it
|
||||
/// changes the adapter's dispatch; otherwise it is pure data on the profile
|
||||
/// (`model`, `endpoint`, …). The default product posture is [`None`](Self::None):
|
||||
/// no embedding ⇒ no heavy dependency, recall stays the naïve étage 1.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EmbedderStrategy {
|
||||
/// A local ONNX model run in-process (real integration is a follow-up).
|
||||
LocalOnnx,
|
||||
/// A local embedding server reached over HTTP (real integration is a follow-up).
|
||||
LocalServer,
|
||||
/// A remote embedding API (real integration is a follow-up).
|
||||
Api,
|
||||
/// No embedding engine: recall stays the dependency-free naïve étage 1.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Declarative configuration for one embedding engine (LOT C, étage 2).
|
||||
///
|
||||
/// Stored in the global IDE store as `embedder.json`, mirroring `profiles.json`
|
||||
/// for [`AgentProfile`]s: adding an engine is **data, not code** (Open/Closed).
|
||||
///
|
||||
/// Invariants:
|
||||
/// - `id` and `name` non-empty,
|
||||
/// - `dimension` is non-zero (an embedder always produces fixed-length vectors).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EmbedderProfile {
|
||||
/// Stable identifier (e.g. `"local-onnx-minilm"`).
|
||||
pub id: String,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Embedding strategy driving which concrete adapter is used.
|
||||
pub strategy: EmbedderStrategy,
|
||||
/// Model identifier (e.g. an ONNX model name), when the strategy needs one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// Endpoint URL for a server/API strategy.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
/// Name of the environment variable carrying the API key (never the key
|
||||
/// itself), for an `api` strategy.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key_env: Option<String>,
|
||||
/// Length of the vectors this engine produces.
|
||||
pub dimension: usize,
|
||||
}
|
||||
|
||||
impl EmbedderProfile {
|
||||
/// Builds a validated embedder profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`DomainError::EmptyField`] if `id` or `name` is empty,
|
||||
/// - [`DomainError::EmptyField`] (`"embedder.dimension"`) if `dimension` is `0`.
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
strategy: EmbedderStrategy,
|
||||
model: Option<String>,
|
||||
endpoint: Option<String>,
|
||||
api_key_env: Option<String>,
|
||||
dimension: usize,
|
||||
) -> Result<Self, DomainError> {
|
||||
let id = id.into();
|
||||
let name = name.into();
|
||||
crate::validation::non_empty(&id, "embedder.id")?;
|
||||
crate::validation::non_empty(&name, "embedder.name")?;
|
||||
if dimension == 0 {
|
||||
return Err(DomainError::EmptyField {
|
||||
field: "embedder.dimension",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
strategy,
|
||||
model,
|
||||
endpoint,
|
||||
api_key_env,
|
||||
dimension,
|
||||
})
|
||||
}
|
||||
|
||||
/// A dependency-free default profile: strategy [`EmbedderStrategy::None`].
|
||||
/// Recall stays the naïve étage 1 — nothing is imposed.
|
||||
#[must_use]
|
||||
pub fn none() -> Self {
|
||||
Self {
|
||||
id: "none".to_owned(),
|
||||
name: "None".to_owned(),
|
||||
strategy: EmbedderStrategy::None,
|
||||
model: None,
|
||||
endpoint: None,
|
||||
api_key_env: None,
|
||||
dimension: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentProfile {
|
||||
/// Builds a validated profile.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user