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>
287 lines
9.9 KiB
Rust
287 lines
9.9 KiB
Rust
//! AI runtime profile and its context-injection strategy.
|
|
//!
|
|
//! A profile is *declarative configuration* (see CONTEXT.md §9): adding an AI
|
|
//! means adding data, not code (Open/Closed). The [`crate::ports::AgentRuntime`]
|
|
//! port is parameterised by an [`AgentProfile`].
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error::DomainError;
|
|
use crate::ids::ProfileId;
|
|
|
|
/// Strategy for injecting an agent's `.md` context into the launched CLI.
|
|
///
|
|
/// Invariants:
|
|
/// - `ConventionFile.target` is a relative file name without `..` and not absolute,
|
|
/// - `Env.var` is a valid environment-variable identifier,
|
|
/// - `Flag.flag` is non-empty.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "strategy")]
|
|
pub enum ContextInjection {
|
|
/// Write/symlink the `.md` to a conventional file (e.g. `CLAUDE.md`).
|
|
ConventionFile {
|
|
/// Relative target file name.
|
|
target: String,
|
|
},
|
|
/// Pass the context file path through a CLI flag.
|
|
Flag {
|
|
/// The flag template (e.g. `--context-file {path}` or `-f`).
|
|
flag: String,
|
|
},
|
|
/// Pipe the Markdown content on stdin.
|
|
Stdin,
|
|
/// Pass the context via an environment variable.
|
|
Env {
|
|
/// Environment variable name.
|
|
var: String,
|
|
},
|
|
}
|
|
|
|
impl ContextInjection {
|
|
/// Validated `ConventionFile` constructor.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`DomainError::PathNotRelativeSafe`] if `target` is absolute or
|
|
/// contains `..`.
|
|
pub fn convention_file(target: impl Into<String>) -> Result<Self, DomainError> {
|
|
let target = target.into();
|
|
crate::validation::relative_safe(&target)?;
|
|
Ok(Self::ConventionFile { target })
|
|
}
|
|
|
|
/// Validated `Flag` constructor.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`DomainError::EmptyField`] if `flag` is empty.
|
|
pub fn flag(flag: impl Into<String>) -> Result<Self, DomainError> {
|
|
let flag = flag.into();
|
|
crate::validation::non_empty(&flag, "contextInjection.flag")?;
|
|
Ok(Self::Flag { flag })
|
|
}
|
|
|
|
/// `Stdin` constructor (no validation needed).
|
|
#[must_use]
|
|
pub const fn stdin() -> Self {
|
|
Self::Stdin
|
|
}
|
|
|
|
/// Validated `Env` constructor.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`DomainError::InvalidEnvVar`] if `var` is not a valid identifier.
|
|
pub fn env(var: impl Into<String>) -> Result<Self, DomainError> {
|
|
let var = var.into();
|
|
crate::validation::valid_env_var(&var)?;
|
|
Ok(Self::Env { var })
|
|
}
|
|
}
|
|
|
|
/// Declares how IdeA assigns and resumes a CLI conversation, without parsing
|
|
/// the model's output (universal, declarative — see CONTEXT.md §9).
|
|
///
|
|
/// Optional on a profile: a profile without a `session` block behaves as today
|
|
/// (no resume).
|
|
///
|
|
/// Invariants:
|
|
/// - `resume_flag` is non-empty,
|
|
/// - if `assign_flag` is `Some`, it is non-empty.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionStrategy {
|
|
/// Flag passed on the FIRST launch with a UUID generated by IdeA
|
|
/// (e.g. `"--session-id"`). `None` => degraded mode (resume without id).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub assign_flag: Option<String>,
|
|
/// Resume flag (e.g. `"--resume"` or `"--continue"`).
|
|
pub resume_flag: String,
|
|
}
|
|
|
|
impl SessionStrategy {
|
|
/// Builds a validated session strategy.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`DomainError::EmptyField`] if `resume_flag` is empty, or if
|
|
/// `assign_flag` is `Some` but empty.
|
|
pub fn new(
|
|
assign_flag: Option<String>,
|
|
resume_flag: impl Into<String>,
|
|
) -> Result<Self, DomainError> {
|
|
let resume_flag = resume_flag.into();
|
|
crate::validation::non_empty(&resume_flag, "session.resumeFlag")?;
|
|
if let Some(flag) = &assign_flag {
|
|
crate::validation::non_empty(flag, "session.assignFlag")?;
|
|
}
|
|
Ok(Self {
|
|
assign_flag,
|
|
resume_flag,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Declarative runtime configuration for one AI CLI.
|
|
///
|
|
/// Invariants:
|
|
/// - `name` and `command` non-empty,
|
|
/// - `context_injection` is itself valid (guaranteed by its constructors),
|
|
/// - `session` is itself valid (guaranteed by its constructor).
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AgentProfile {
|
|
/// Stable identifier.
|
|
pub id: ProfileId,
|
|
/// Display name.
|
|
pub name: String,
|
|
/// Launch command (e.g. `claude`, `codex`, `gemini`, `aider`).
|
|
pub command: String,
|
|
/// Static arguments.
|
|
pub args: Vec<String>,
|
|
/// Context-injection strategy.
|
|
pub context_injection: ContextInjection,
|
|
/// Optional detection command (e.g. `claude --version`).
|
|
pub detect: Option<String>,
|
|
/// Working-directory template. Always `"{agentRunDir}"` (ARCHITECTURE §14.1):
|
|
/// an agent's PTY cwd is its isolated `.ideai/run/<agent-id>/` directory,
|
|
/// **never** the project root, so that N agents of the same profile never
|
|
/// collide on a single conventional file (`CLAUDE.md`, …) at the root.
|
|
pub cwd_template: String,
|
|
/// Optional conversation assign/resume strategy. Absent (legacy / no resume)
|
|
/// keeps today's behaviour.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
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.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`DomainError::EmptyField`] if `name` or `command` is empty.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
id: ProfileId,
|
|
name: impl Into<String>,
|
|
command: impl Into<String>,
|
|
args: Vec<String>,
|
|
context_injection: ContextInjection,
|
|
detect: Option<String>,
|
|
cwd_template: impl Into<String>,
|
|
session: Option<SessionStrategy>,
|
|
) -> Result<Self, DomainError> {
|
|
let name = name.into();
|
|
let command = command.into();
|
|
let cwd_template = cwd_template.into();
|
|
crate::validation::non_empty(&name, "profile.name")?;
|
|
crate::validation::non_empty(&command, "profile.command")?;
|
|
Ok(Self {
|
|
id,
|
|
name,
|
|
command,
|
|
args,
|
|
context_injection,
|
|
detect,
|
|
cwd_template,
|
|
session,
|
|
})
|
|
}
|
|
}
|