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.
|
||||
///
|
||||
|
||||
183
crates/domain/tests/embedder_profile.rs
Normal file
183
crates/domain/tests/embedder_profile.rs
Normal file
@ -0,0 +1,183 @@
|
||||
//! Tests for the declarative [`EmbedderProfile`] / [`EmbedderStrategy`] (LOT C,
|
||||
//! étage 2 vectoriel): serde camelCase round-trip, the validated `new`
|
||||
//! constructor, and the dependency-free `none()` default.
|
||||
|
||||
use domain::{DomainError, EmbedderProfile, EmbedderStrategy};
|
||||
|
||||
fn roundtrip<T>(value: &T) -> T
|
||||
where
|
||||
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
|
||||
{
|
||||
let json = serde_json::to_string(value).expect("serialize");
|
||||
let back: T = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(&back, value, "round-trip mismatch via {json}");
|
||||
back
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmbedderStrategy: camelCase wire form (`localOnnx` / `localServer` / `api` /
|
||||
// `none`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn strategy_serializes_camel_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EmbedderStrategy::LocalOnnx).unwrap(),
|
||||
"\"localOnnx\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EmbedderStrategy::LocalServer).unwrap(),
|
||||
"\"localServer\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EmbedderStrategy::Api).unwrap(),
|
||||
"\"api\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EmbedderStrategy::None).unwrap(),
|
||||
"\"none\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_deserializes_camel_case() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<EmbedderStrategy>("\"localOnnx\"").unwrap(),
|
||||
EmbedderStrategy::LocalOnnx
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<EmbedderStrategy>("\"localServer\"").unwrap(),
|
||||
EmbedderStrategy::LocalServer
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<EmbedderStrategy>("\"api\"").unwrap(),
|
||||
EmbedderStrategy::Api
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<EmbedderStrategy>("\"none\"").unwrap(),
|
||||
EmbedderStrategy::None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_roundtrips() {
|
||||
for s in [
|
||||
EmbedderStrategy::LocalOnnx,
|
||||
EmbedderStrategy::LocalServer,
|
||||
EmbedderStrategy::Api,
|
||||
EmbedderStrategy::None,
|
||||
] {
|
||||
roundtrip(&s);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmbedderProfile: camelCase fields and optional-field skipping.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn profile_roundtrips_with_all_fields() {
|
||||
let p = EmbedderProfile::new(
|
||||
"local-onnx-minilm",
|
||||
"Local MiniLM",
|
||||
EmbedderStrategy::LocalOnnx,
|
||||
Some("all-MiniLM-L6-v2".to_owned()),
|
||||
Some("http://localhost:1234".to_owned()),
|
||||
Some("OPENAI_API_KEY".to_owned()),
|
||||
384,
|
||||
)
|
||||
.unwrap();
|
||||
let back = roundtrip(&p);
|
||||
assert_eq!(back.dimension, 384);
|
||||
assert_eq!(back.strategy, EmbedderStrategy::LocalOnnx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_uses_camel_case_keys_and_skips_none_options() {
|
||||
let p = EmbedderProfile::new(
|
||||
"id-1",
|
||||
"Name",
|
||||
EmbedderStrategy::Api,
|
||||
None,
|
||||
Some("https://api.example/embed".to_owned()),
|
||||
Some("MY_KEY".to_owned()),
|
||||
16,
|
||||
)
|
||||
.unwrap();
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(json.contains("\"apiKeyEnv\":\"MY_KEY\""), "camelCase apiKeyEnv: {json}");
|
||||
assert!(json.contains("\"strategy\":\"api\""), "camelCase strategy: {json}");
|
||||
// `model` is None ⇒ skipped from the wire form.
|
||||
assert!(!json.contains("\"model\""), "None model must be skipped: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_deserializes_from_camel_case_document() {
|
||||
let json = r#"{
|
||||
"id": "srv",
|
||||
"name": "Server",
|
||||
"strategy": "localServer",
|
||||
"endpoint": "http://127.0.0.1:9000",
|
||||
"dimension": 512
|
||||
}"#;
|
||||
let p: EmbedderProfile = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(p.id, "srv");
|
||||
assert_eq!(p.strategy, EmbedderStrategy::LocalServer);
|
||||
assert_eq!(p.endpoint.as_deref(), Some("http://127.0.0.1:9000"));
|
||||
assert_eq!(p.dimension, 512);
|
||||
assert!(p.model.is_none());
|
||||
assert!(p.api_key_env.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// none(): the dependency-free default.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn none_profile_has_none_strategy() {
|
||||
let p = EmbedderProfile::none();
|
||||
assert_eq!(p.strategy, EmbedderStrategy::None);
|
||||
assert_eq!(p.id, "none");
|
||||
assert!(p.dimension > 0, "even the none profile keeps a non-zero dimension");
|
||||
// And it round-trips like any other profile.
|
||||
roundtrip(&p);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// new(): validation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_rejects_empty_id() {
|
||||
assert!(matches!(
|
||||
EmbedderProfile::new("", "Name", EmbedderStrategy::None, None, None, None, 8),
|
||||
Err(DomainError::EmptyField { field: "embedder.id" })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_empty_name() {
|
||||
assert!(matches!(
|
||||
EmbedderProfile::new("id", "", EmbedderStrategy::None, None, None, None, 8),
|
||||
Err(DomainError::EmptyField {
|
||||
field: "embedder.name"
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_dimension() {
|
||||
assert!(matches!(
|
||||
EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 0),
|
||||
Err(DomainError::EmptyField {
|
||||
field: "embedder.dimension"
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_accepts_valid_minimal_profile() {
|
||||
let p = EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 1).unwrap();
|
||||
assert_eq!(p.dimension, 1);
|
||||
assert_eq!(p.strategy, EmbedderStrategy::None);
|
||||
}
|
||||
187
crates/domain/tests/memory.rs
Normal file
187
crates/domain/tests/memory.rs
Normal file
@ -0,0 +1,187 @@
|
||||
//! Domain tests for the [`Memory`] entity and its value objects (LOT A, étage 1):
|
||||
//! [`MemorySlug`] validation, `[[slug]]` link scanning, frontmatter serde
|
||||
//! round-trip, and the derived index entry.
|
||||
|
||||
use domain::{
|
||||
DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType,
|
||||
};
|
||||
|
||||
fn fm(slug: &str, description: &str, ty: MemoryType) -> MemoryFrontmatter {
|
||||
MemoryFrontmatter {
|
||||
name: MemorySlug::new(slug).unwrap(),
|
||||
description: description.to_string(),
|
||||
r#type: ty,
|
||||
}
|
||||
}
|
||||
|
||||
fn note(slug: &str, body: &str) -> Memory {
|
||||
Memory::new(
|
||||
fm(slug, "a hook", MemoryType::Project),
|
||||
MarkdownDoc::new(body),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MemorySlug
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn slug_accepts_kebab_case() {
|
||||
assert_eq!(
|
||||
MemorySlug::new("my-note-42").unwrap().as_str(),
|
||||
"my-note-42"
|
||||
);
|
||||
assert!(MemorySlug::new("abc").is_ok());
|
||||
assert!(MemorySlug::new("a-b-c-1-2-3").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_uppercase() {
|
||||
assert!(matches!(
|
||||
MemorySlug::new("MyNote").unwrap_err(),
|
||||
DomainError::InvalidSlug { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_spaces() {
|
||||
assert!(matches!(
|
||||
MemorySlug::new("my note").unwrap_err(),
|
||||
DomainError::InvalidSlug { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_dot_and_traversal() {
|
||||
assert!(matches!(
|
||||
MemorySlug::new("..").unwrap_err(),
|
||||
DomainError::InvalidSlug { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
MemorySlug::new("a.b").unwrap_err(),
|
||||
DomainError::InvalidSlug { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_empty() {
|
||||
assert!(matches!(
|
||||
MemorySlug::new("").unwrap_err(),
|
||||
DomainError::InvalidSlug { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_underscore_and_slash() {
|
||||
assert!(MemorySlug::new("a_b").is_err());
|
||||
assert!(MemorySlug::new("a/b").is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory invariants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn memory_rejects_empty_description() {
|
||||
let r = Memory::new(
|
||||
fm("ok-slug", "", MemoryType::User),
|
||||
MarkdownDoc::new("body"),
|
||||
);
|
||||
assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_rejects_empty_body() {
|
||||
let r = Memory::new(fm("ok-slug", "hook", MemoryType::User), MarkdownDoc::new(""));
|
||||
assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// outgoing_links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn outgoing_links_none() {
|
||||
assert!(note("n", "plain body, no links").outgoing_links().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_links_multiple() {
|
||||
let m = note("n", "see [[alpha]] and also [[beta-2]] here");
|
||||
let targets: Vec<_> = m
|
||||
.outgoing_links()
|
||||
.into_iter()
|
||||
.map(|l| l.target.as_str().to_string())
|
||||
.collect();
|
||||
assert_eq!(targets, vec!["alpha".to_string(), "beta-2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_links_preserves_duplicates() {
|
||||
let m = note("n", "[[alpha]] then again [[alpha]]");
|
||||
assert_eq!(m.outgoing_links().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_links_skips_invalid_targets() {
|
||||
// `[[Not Valid]]` is not a kebab-case slug -> skipped.
|
||||
let m = note("n", "[[Not Valid]] but [[good-one]]");
|
||||
let targets: Vec<_> = m
|
||||
.outgoing_links()
|
||||
.into_iter()
|
||||
.map(|l| l.target.as_str().to_string())
|
||||
.collect();
|
||||
assert_eq!(targets, vec!["good-one".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_links_ignores_unterminated() {
|
||||
let m = note("n", "dangling [[oops without close");
|
||||
assert!(m.outgoing_links().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// index_entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn index_entry_carries_slug_hook_and_type() {
|
||||
let m = Memory::new(
|
||||
fm("topic-x", "a useful hook", MemoryType::Feedback),
|
||||
MarkdownDoc::new("body"),
|
||||
)
|
||||
.unwrap();
|
||||
let e = m.index_entry();
|
||||
assert_eq!(e.slug.as_str(), "topic-x");
|
||||
assert_eq!(e.hook, "a useful hook");
|
||||
assert_eq!(e.title, "topic-x");
|
||||
assert_eq!(e.r#type, MemoryType::Feedback);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontmatter serde round-trip (the `type` field name + camelCase variants).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn frontmatter_serde_roundtrip() {
|
||||
let original = fm("round-trip", "desc", MemoryType::Reference);
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
// `type` field name (not `kind`), camelCase enum value.
|
||||
assert!(json.contains("\"type\":\"reference\""));
|
||||
assert!(json.contains("\"name\":\"round-trip\""));
|
||||
let back: MemoryFrontmatter = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_type_serde_is_camel_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&MemoryType::User).unwrap(),
|
||||
"\"user\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&MemoryType::Project).unwrap(),
|
||||
"\"project\""
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user