feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé

- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:24:51 +02:00
parent 32398827fb
commit 785e9935fd
118 changed files with 5793 additions and 866 deletions

View File

@ -14,17 +14,18 @@
use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator,
MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan,
SkillStore, SpawnSpec, StoreError,
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError,
IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath,
SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, Project, ProfileId,
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project,
ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
};
use crate::error::AppError;
use crate::project::project_context_path;
use crate::terminal::TerminalSessions;
/// Directory (relative to `.ideai/`) under which agent contexts are written.
@ -35,7 +36,7 @@ const AGENTS_SUBDIR: &str = "agents";
/// (étage 1) handed to the agent. Internal and intentionally **not yet exposed in
/// config**: it may later become a per-project setting without changing the
/// contract.
const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;
pub const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;
// ---------------------------------------------------------------------------
// CreateAgentFromScratch
@ -115,7 +116,9 @@ impl CreateAgentFromScratch {
entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
// Now the path resolves: write the initial context.
let md = MarkdownDoc::new(input.initial_content.unwrap_or_default());
@ -171,7 +174,10 @@ impl ListAgents {
let agents = manifest
.entries
.iter()
.map(|e| e.to_agent().map_err(|err| AppError::Invalid(err.to_string())))
.map(|e| {
e.to_agent()
.map_err(|err| AppError::Invalid(err.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(ListAgentsOutput { agents })
}
@ -311,7 +317,9 @@ impl DeleteAgent {
}
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id,
});
@ -380,6 +388,11 @@ pub struct LaunchAgent {
/// file at activation (ARCHITECTURE §14.5.4). Best-effort by contract: an absent
/// or empty memory yields an empty list, never blocking a launch.
recall: Arc<dyn MemoryRecall>,
/// Optional contextual embedder-suggestion check (LOT C3, §14.5.5), run
/// best-effort right after the memory recall at activation — the moment an agent
/// reads the project memory. `None` keeps the launcher independent of it (legacy
/// wiring / tests). A failure here never affects the launch.
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
}
impl LaunchAgent {
@ -397,6 +410,7 @@ impl LaunchAgent {
events: Arc<dyn EventBus>,
ids: Arc<dyn IdGenerator>,
recall: Arc<dyn MemoryRecall>,
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
) -> Self {
Self {
contexts,
@ -409,6 +423,7 @@ impl LaunchAgent {
events,
ids,
recall,
embedder_suggestion,
}
}
@ -426,7 +441,11 @@ impl LaunchAgent {
) -> Result<Vec<Skill>, AppError> {
let mut out = Vec::with_capacity(agent.skills.len());
for skill_ref in &agent.skills {
match self.skills.get(skill_ref.scope, root, skill_ref.skill_id).await {
match self
.skills
.get(skill_ref.scope, root, skill_ref.skill_id)
.await
{
Ok(skill) => out.push(skill),
Err(StoreError::NotFound) => {}
Err(e) => return Err(e.into()),
@ -453,6 +472,19 @@ impl LaunchAgent {
self.recall.recall(root, &query).await.unwrap_or_default()
}
/// Reads the shared project context from `.ideai/CONTEXT.md`.
///
/// A missing file is normal for existing projects and simply omits the
/// project-context section from the generated model context.
async fn resolve_project_context(&self, project: &Project) -> Result<String, AppError> {
match self.fs.read(&project_context_path(project)).await {
Ok(bytes) => String::from_utf8(bytes)
.map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}"))),
Err(FsError::NotFound(_)) => Ok(String::new()),
Err(e) => Err(AppError::FileSystem(e.to_string())),
}
}
/// Executes the launch.
///
/// Step order is contractually significant (and unit-tested): resolve the
@ -486,26 +518,22 @@ impl LaunchAgent {
// runs AFTER the NotFound resolution above (so an unknown agent still
// errors NotFound) but BEFORE any I/O (run dir, seed, spawn). If the
// agent already owns a live session:
// - on a *different* (or unspecified) node → refuse the launch with
// `AgentAlreadyRunning`, pointing at the existing host cell;
// - on the *same* node → idempotent (a concurrent double-launch of the
// very same cell): return the existing session without respawning,
// assigning no new conversation id.
// - with a requested node → rebind the live session to that cell and
// return it without respawning;
// - without a requested node → idempotent background/no-op launch:
// return the existing session without respawning.
// The resume path (agent dead ⇒ no live session) is unaffected.
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
let host_node = self
.sessions
.node_for_agent(&input.agent_id)
.expect("a live session always has a host node");
let same_node = input.node_id.as_ref() == Some(&host_node);
if !same_node {
return Err(AppError::AgentAlreadyRunning {
agent_id: input.agent_id,
node_id: host_node,
});
if let Some(node_id) = input.node_id {
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
});
}
}
// Same node: idempotent — hand back the already-registered session,
// no respawn, nothing new to persist.
// Idempotent — hand back the already-registered session, no respawn,
// nothing new to persist.
if let Some(session) = self.sessions.session(&existing_id) {
return Ok(LaunchAgentOutput {
session,
@ -525,9 +553,7 @@ impl LaunchAgent {
.await?
.into_iter()
.find(|p| p.id == agent.profile_id)
.ok_or_else(|| {
AppError::NotFound(format!("profile {} for agent", agent.profile_id))
})?;
.ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
// 3. Compute and create the agent's isolated run directory
// `<root>/.ideai/run/<agent-id>/` (ARCHITECTURE §14.1). The PTY cwd is
@ -571,13 +597,27 @@ impl LaunchAgent {
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
// the injection plan side effects *before* spawning.
let skills = self.resolve_skills(&agent, &input.project.root).await?;
let project_context = self.resolve_project_context(&input.project).await?;
let memory = self
.resolve_memory(&input.project.root, content.as_str())
.await;
// Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
// has just read the project memory, so this is the moment to check whether a
// semantic embedder would now help. Fully isolated from the launch outcome —
// an error or absence of the check never affects activation.
if let Some(check) = &self.embedder_suggestion {
let _ = check
.execute(crate::embedder::CheckEmbedderSuggestionInput {
project_id: input.project.id,
project_root: input.project.root.clone(),
})
.await;
}
self.apply_injection(
&input.project,
&agent.context_path,
&content,
&project_context,
&skills,
&memory,
&mut spec,
@ -598,9 +638,7 @@ impl LaunchAgent {
session_id,
node_id,
spec.cwd.clone(),
SessionKind::Agent {
agent_id: agent.id,
},
SessionKind::Agent { agent_id: agent.id },
size,
);
session.status = SessionStatus::Running;
@ -718,6 +756,7 @@ impl LaunchAgent {
project: &Project,
context_rel_path: &str,
content: &MarkdownDoc,
project_context: &str,
skills: &[Skill],
memory: &[MemoryIndexEntry],
spec: &mut SpawnSpec,
@ -733,6 +772,7 @@ impl LaunchAgent {
// persona `.md`, then the bodies of its assigned skills (§14.2).
let document = compose_convention_file(
project.root.as_str(),
project_context,
content.as_str(),
skills,
memory,
@ -769,7 +809,10 @@ fn join(base: &ProjectPath, rel: &str) -> String {
/// # Errors
/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is
/// not a valid [`ProjectPath`] (should not happen for an absolute project root).
pub(crate) fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath, domain::error::DomainError> {
pub(crate) fn agent_run_dir(
root: &ProjectPath,
agent_id: &AgentId,
) -> Result<ProjectPath, domain::error::DomainError> {
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
}
@ -841,8 +884,9 @@ fn json_escape(s: &str) -> String {
/// Composes the convention file IdeA writes into an agent's run directory: an
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
/// so it must be told where to work), the agent's persona `.md`, then the bodies
/// of its assigned `skills` under a `# Skills` section (ARCHITECTURE §14.2).
/// so it must be told where to work), the IdeA orchestration contract, the
/// agent's persona `.md`, then the bodies of its assigned `skills` under a
/// `# Skills` section (ARCHITECTURE §14.2).
///
/// Skills are emitted in the order given (the caller passes them in manifest
/// order, making the output deterministic); each is introduced by a `##` header
@ -858,6 +902,7 @@ fn json_escape(s: &str) -> String {
#[must_use]
pub(crate) fn compose_convention_file(
project_root: &str,
project_context: &str,
agent_md: &str,
skills: &[Skill],
memory: &[MemoryIndexEntry],
@ -871,6 +916,21 @@ pub(crate) fn compose_convention_file(
opère sur le project root, pas sur ce dossier.\n\n",
);
out.push_str("---\n\n");
out.push_str("# Orchestration IdeA\n\n");
out.push_str(
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \
`.ideai/requests/<ton-agent>/` ; IdeA lancera ou réattachera l'agent cible \
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
);
out.push_str("---\n\n");
if !project_context.trim().is_empty() {
out.push_str("# Contexte projet\n\n");
out.push_str(project_context.trim());
out.push_str("\n\n---\n\n");
}
out.push_str(agent_md);
if !skills.is_empty() {
@ -918,7 +978,11 @@ fn memory_type_label(kind: MemoryType) -> &'static str {
/// suffix when needed. Shared with the template-driven agent creation (L7).
pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String {
let slug = slugify(name);
let base = if slug.is_empty() { "agent".to_owned() } else { slug };
let base = if slug.is_empty() {
"agent".to_owned()
} else {
slug
};
let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md");
let mut n = 2;
while manifest.entries.iter().any(|e| e.md_path == candidate) {
@ -967,7 +1031,7 @@ mod tests {
#[test]
fn compose_convention_file_carries_root_then_persona() {
let doc =
compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[], &[]);
compose_convention_file("/abs/project/root", "", "# Persona\n\nDo things.", &[], &[]);
// Absolute project root present.
assert!(doc.contains("/abs/project/root"));
@ -982,6 +1046,26 @@ mod tests {
assert!(!doc.contains("# Skills"));
}
#[test]
fn compose_convention_file_includes_project_context_before_persona() {
let doc = compose_convention_file(
"/root",
"# Shared project context\n\nUse pnpm.",
"# Persona\n\nDo X.",
&[],
&[],
);
assert!(doc.contains("# Contexte projet"));
assert!(doc.contains("Use pnpm."));
let project_context_at = doc.find("# Contexte projet").unwrap();
let persona_at = doc.find("# Persona").unwrap();
assert!(
project_context_at < persona_at,
"shared project context must precede agent persona"
);
}
#[test]
fn compose_convention_file_appends_assigned_skills_in_order() {
let s = |n: u128, name: &str, body: &str| {
@ -995,8 +1079,12 @@ mod tests {
};
let doc = compose_convention_file(
"/root",
"",
"# Persona",
&[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")],
&[
s(1, "refactor", "REFAC_BODY"),
s(2, "review", "REVIEW_BODY"),
],
&[],
);
@ -1028,7 +1116,7 @@ mod tests {
fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
// An empty `memory` must yield exactly the previous document: no section,
// byte-for-byte identical to the no-skills/no-memory composition.
let with_empty = compose_convention_file("/root", "# Persona\n\nDo X.", &[], &[]);
let with_empty = compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[]);
assert!(
!with_empty.contains("# Mémoire projet"),
"no memory ⇒ no memory section"
@ -1037,7 +1125,7 @@ mod tests {
// 4-arg call; this pins the omission contract explicitly).
assert_eq!(
with_empty,
compose_convention_file("/root", "# Persona\n\nDo X.", &[], &[])
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[])
);
}
@ -1045,11 +1133,17 @@ mod tests {
fn compose_convention_file_appends_memory_entries_in_order() {
let doc = compose_convention_file(
"/root",
"",
"# Persona",
&[],
&[
mem("alpha-note", "Alpha", "the first hook", MemoryType::User),
mem("beta-note", "Beta", "the second hook", MemoryType::Reference),
mem(
"beta-note",
"Beta",
"the second hook",
MemoryType::Reference,
),
],
);
@ -1066,7 +1160,10 @@ mod tests {
// Deterministic order: first entry precedes the second.
let alpha_at = doc.find("[Alpha]").unwrap();
let beta_at = doc.find("[Beta]").unwrap();
assert!(alpha_at < beta_at, "memory entries emitted in the given order");
assert!(
alpha_at < beta_at,
"memory entries emitted in the given order"
);
}
#[test]
@ -1080,6 +1177,7 @@ mod tests {
.unwrap();
let doc = compose_convention_file(
"/root",
"",
"# Persona",
std::slice::from_ref(&skill),
&[mem("note", "Note", "a hook", MemoryType::Project)],

View File

@ -14,14 +14,12 @@ mod usecases;
pub(crate) use lifecycle::unique_md_path;
pub use catalogue::{reference_profile_id, reference_profiles};
pub use inspect::{
InspectConversation, InspectConversationInput, InspectConversationOutput,
};
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
pub use lifecycle::{
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext,
UpdateAgentContextInput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
};
pub use usecases::{
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,

View File

@ -0,0 +1,24 @@
//! Embedder configuration use cases (LOT C2 — §14.5.3).
//!
//! CRUD over the declarative [`domain::profile::EmbedderProfile`]s
//! ([`ListEmbedderProfiles`], [`SaveEmbedderProfile`], [`DeleteEmbedderProfile`])
//! plus a read-only description of the available engines ([`DescribeEmbedderEngines`]):
//! the recommended local ONNX models, the detected local environment, and which
//! strategies are actually compiled into this binary.
//!
//! A changed embedder takes effect at the **next IDE start** (the composition root
//! freezes the recall for the session): these use cases only persist/inspect config,
//! they never reconfigure a live recall.
mod suggestion;
mod usecases;
pub use suggestion::{
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
DismissChoice, DismissEmbedderSuggestion, DismissEmbedderSuggestionInput, SuggestedThisSession,
};
pub use usecases::{
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines,
EmbedderEnginesView, ListEmbedderProfiles, ListEmbedderProfilesOutput, OnnxModelView,
SaveEmbedderProfile, SaveEmbedderProfileInput, SaveEmbedderProfileOutput,
};

View File

@ -0,0 +1,269 @@
//! Contextual embedder-suggestion use cases (LOT C3, ARCHITECTURE §14.5.5).
//!
//! - [`CheckEmbedderSuggestion`] — the **best-effort** check run when an agent
//! reads the project memory at activation: the *first* time a project's memory
//! outgrows the recall budget while no embedder is configured (strategy `none`),
//! it publishes a one-time [`DomainEvent::EmbedderSuggested`]. Anti-spam: at most
//! once per session per project, and never again once the user chose
//! [`EmbedderPromptDismissal::Never`].
//! - [`DismissEmbedderSuggestion`] — persists the user's "plus tard" / "ne plus
//! demander" response.
//!
//! By construction this never blocks the activation flow: every error degrades to
//! "no suggestion", and a `none`-strategy/under-budget project is a cheap no-op.
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use domain::ports::{
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore,
EventBus, MemoryStore,
};
use domain::profile::EmbedderStrategy;
use domain::{DomainEvent, ProjectId, ProjectPath};
use crate::error::AppError;
/// In-memory "already suggested this session" guard, shared with the composition
/// root (held in `AppState`). One [`ProjectId`] per project that has already seen
/// the suggestion since the IDE started. Plain std types so the application stays
/// dependency-free and the guard is trivially testable.
pub type SuggestedThisSession = Arc<Mutex<HashSet<ProjectId>>>;
/// Sums the approximate token size of the index, mirroring the infrastructure
/// `index_token_size` so the application does not depend on the infra crate. One
/// entry's payload is its rendered index line (title + hook + slug), and the rule
/// only needs a *monotone* measure consistent with the recall's budget unit
/// (~1 token / 4 chars), so we count characters / 4 (min 1 per entry).
fn index_token_size(entries: &[domain::MemoryIndexEntry]) -> usize {
entries
.iter()
.map(|e| {
let chars = e.title.len() + e.hook.len() + e.slug.as_str().len();
(chars / 4).max(1)
})
.sum()
}
// ---------------------------------------------------------------------------
// CheckEmbedderSuggestion
// ---------------------------------------------------------------------------
/// Input for [`CheckEmbedderSuggestion::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckEmbedderSuggestionInput {
/// The project whose memory is being read at agent activation.
pub project_id: ProjectId,
/// That project's root (where its `.ideai/memory/` lives).
pub project_root: ProjectPath,
}
/// Output of [`CheckEmbedderSuggestion::execute`]: whether a suggestion event was
/// published on this call (useful for tests; the caller ignores it — the flow is
/// best-effort).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CheckEmbedderSuggestionOutput {
/// `true` iff an [`DomainEvent::EmbedderSuggested`] was published.
pub suggested: bool,
}
/// Publishes a one-time embedder suggestion the first time a project's memory
/// outgrows the recall budget while no embedder is configured (LOT C3).
///
/// Composes — each port only for its slice (Interface Segregation):
/// - [`EmbedderProfileStore`] to read the active strategy (no non-`none` profile ⇒
/// strategy `none`),
/// - [`MemoryStore`] to measure the memory index size,
/// - [`EmbedderPromptStore`] for the persistent dismissal (`never` ⇒ silence),
/// - [`EmbedderEnvInspector`] to enrich the event with the detected environment,
/// - [`EventBus`] to publish, plus the shared [`SuggestedThisSession`] guard.
///
/// **Never blocks the caller**: any port error degrades to "no suggestion".
pub struct CheckEmbedderSuggestion {
profiles: Arc<dyn EmbedderProfileStore>,
memories: Arc<dyn MemoryStore>,
prompts: Arc<dyn EmbedderPromptStore>,
inspector: Arc<dyn EmbedderEnvInspector>,
events: Arc<dyn EventBus>,
suggested_this_session: SuggestedThisSession,
/// Recall token budget the memory must exceed (injected so it stays aligned
/// with [`crate::AGENT_MEMORY_RECALL_BUDGET`] without re-hardcoding it).
budget: usize,
/// Compiled-in HTTP capability flag (carried into the event).
vector_http_enabled: bool,
/// Compiled-in ONNX capability flag (carried into the event).
vector_onnx_enabled: bool,
}
impl CheckEmbedderSuggestion {
/// Builds the use case from its ports + the session guard + the budget and
/// compiled-capability flags (the latter injected at the composition root, the
/// application stays infra-free).
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
profiles: Arc<dyn EmbedderProfileStore>,
memories: Arc<dyn MemoryStore>,
prompts: Arc<dyn EmbedderPromptStore>,
inspector: Arc<dyn EmbedderEnvInspector>,
events: Arc<dyn EventBus>,
suggested_this_session: SuggestedThisSession,
budget: usize,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
) -> Self {
Self {
profiles,
memories,
prompts,
inspector,
events,
suggested_this_session,
budget,
vector_http_enabled,
vector_onnx_enabled,
}
}
/// Whether any configured profile selects a non-`none` strategy. No profile (or
/// a store error) ⇒ `none` (the dependency-free default posture).
async fn strategy_is_none(&self) -> bool {
match self.profiles.list().await {
Ok(profiles) => !profiles
.iter()
.any(|p| p.strategy != EmbedderStrategy::None),
// A store failure must not turn into a suggestion: behave as configured
// (i.e. *not* `none`) so we stay silent.
Err(_) => false,
}
}
/// Runs the check best-effort.
///
/// Order (each step short-circuits to "no suggestion"):
/// 1. already suggested this session ⇒ stop (no I/O beyond the guard);
/// 2. a non-`none` strategy is configured ⇒ stop;
/// 3. the persisted dismissal is `never` ⇒ stop;
/// 4. the memory index size does not exceed the budget ⇒ stop;
/// 5. otherwise mark the session guard, probe the environment, and publish
/// [`DomainEvent::EmbedderSuggested`].
///
/// # Errors
/// Never in practice — the signature keeps a uniform `Result` with the other
/// use cases; every failing port read degrades to `Ok(suggested: false)`.
pub async fn execute(
&self,
input: CheckEmbedderSuggestionInput,
) -> Result<CheckEmbedderSuggestionOutput, AppError> {
let not_suggested = Ok(CheckEmbedderSuggestionOutput { suggested: false });
// 1. Once per session per project (cheap guard check before any I/O).
if self
.suggested_this_session
.lock()
.map(|set| set.contains(&input.project_id))
.unwrap_or(true)
{
return not_suggested;
}
// 2. Only when no embedder is configured (strategy `none`).
if !self.strategy_is_none().await {
return not_suggested;
}
// 3. Persistent "ne plus demander" silences the suggestion forever.
if matches!(
self.prompts.read(&input.project_root).await,
Ok(Some(EmbedderPromptDismissal::Never))
) {
return not_suggested;
}
// 4. Memory must have outgrown the recall budget (a fresh project has 0).
let size = match self.memories.read_index(&input.project_root).await {
Ok(entries) => index_token_size(&entries),
Err(_) => return not_suggested,
};
if size <= self.budget {
return not_suggested;
}
// 5. Mark the guard *before* publishing so a concurrent activation cannot
// double-fire; if another thread won the race, stay silent.
match self.suggested_this_session.lock() {
Ok(mut set) => {
if !set.insert(input.project_id) {
return not_suggested;
}
}
Err(_) => return not_suggested,
}
let report = self.inspector.inspect().await;
self.events.publish(DomainEvent::EmbedderSuggested {
project_id: input.project_id,
ollama_detected: report.ollama_detected,
onnx_cached: report.onnx_cached_models,
vector_http_enabled: self.vector_http_enabled,
vector_onnx_enabled: self.vector_onnx_enabled,
});
Ok(CheckEmbedderSuggestionOutput { suggested: true })
}
}
// ---------------------------------------------------------------------------
// DismissEmbedderSuggestion
// ---------------------------------------------------------------------------
/// The user's response to the embedder suggestion popup.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DismissChoice {
/// "Plus tard" — re-proposable on a future session.
Later,
/// "Ne plus demander" — never again (persistent).
Never,
}
impl From<DismissChoice> for EmbedderPromptDismissal {
fn from(c: DismissChoice) -> Self {
match c {
DismissChoice::Later => Self::Later,
DismissChoice::Never => Self::Never,
}
}
}
/// Input for [`DismissEmbedderSuggestion::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DismissEmbedderSuggestionInput {
/// The project the suggestion concerned.
pub project_root: ProjectPath,
/// The user's choice.
pub choice: DismissChoice,
}
/// Persists the user's dismissal of the embedder suggestion (LOT C3).
pub struct DismissEmbedderSuggestion {
prompts: Arc<dyn EmbedderPromptStore>,
}
impl DismissEmbedderSuggestion {
/// Builds the use case from the [`EmbedderPromptStore`] port.
#[must_use]
pub fn new(prompts: Arc<dyn EmbedderPromptStore>) -> Self {
Self { prompts }
}
/// Writes the dismissal state. A `later` keeps the suggestion re-proposable on
/// a future session; a `never` silences it for good.
///
/// # Errors
/// [`AppError::Store`] on a persistence failure.
pub async fn execute(&self, input: DismissEmbedderSuggestionInput) -> Result<(), AppError> {
self.prompts
.write(&input.project_root, input.choice.into())
.await?;
Ok(())
}
}

View File

@ -0,0 +1,245 @@
//! Embedder configuration use cases (LOT C2). Each is a single-responsibility
//! struct carrying its ports as `Arc<dyn Port>` and exposing one `execute`.
//!
//! - [`ListEmbedderProfiles`] / [`SaveEmbedderProfile`] / [`DeleteEmbedderProfile`]
//! — CRUD over the persisted [`EmbedderProfile`]s through the
//! [`EmbedderProfileStore`] port.
//! - [`DescribeEmbedderEngines`] — a read-only view of the engines available to the
//! "configure an embedder?" UI: the recommended ONNX model catalogue, a best-effort
//! snapshot of the local environment ([`EmbedderEnvInspector`]), and which strategies
//! are actually compiled into this binary.
//!
//! Hexagonal boundary: the application depends on the domain ports and on plain
//! data only. The static engine catalogue and the compiled-capability flags are
//! **injected** at the composition root (the infrastructure owns `reqwest`/`fastembed`,
//! never the application).
use std::sync::Arc;
use domain::ports::{EmbedderEnvInspector, EmbedderProfileStore};
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use crate::error::AppError;
// ---------------------------------------------------------------------------
// ListEmbedderProfiles
// ---------------------------------------------------------------------------
/// Output of [`ListEmbedderProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListEmbedderProfilesOutput {
/// All configured embedder profiles (empty when none configured ⇒ `none`).
pub profiles: Vec<EmbedderProfile>,
}
/// Lists the configured embedder profiles from the store.
pub struct ListEmbedderProfiles {
store: Arc<dyn EmbedderProfileStore>,
}
impl ListEmbedderProfiles {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Lists configured embedder profiles.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self) -> Result<ListEmbedderProfilesOutput, AppError> {
Ok(ListEmbedderProfilesOutput {
profiles: self.store.list().await?,
})
}
}
// ---------------------------------------------------------------------------
// SaveEmbedderProfile
// ---------------------------------------------------------------------------
/// Input for [`SaveEmbedderProfile::execute`]: the raw fields of the profile to
/// upsert (validated into an [`EmbedderProfile`] entity).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveEmbedderProfileInput {
/// Stable identifier (e.g. `"local-onnx-minilm"`). Non-empty.
pub id: String,
/// Display name. Non-empty.
pub name: String,
/// Embedding strategy driving which concrete adapter is used.
pub strategy: EmbedderStrategy,
/// Model identifier, when the strategy needs one.
pub model: Option<String>,
/// Endpoint URL for a server/API strategy.
pub endpoint: Option<String>,
/// Name of the env var carrying the API key (never the key itself).
pub api_key_env: Option<String>,
/// Length of the vectors this engine produces. Non-zero.
pub dimension: usize,
}
/// Output of [`SaveEmbedderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveEmbedderProfileOutput {
/// The saved (validated) profile, echoed back.
pub profile: EmbedderProfile,
}
/// Persists (creates or replaces by id) a single embedder profile, after building
/// and validating the entity.
pub struct SaveEmbedderProfile {
store: Arc<dyn EmbedderProfileStore>,
}
impl SaveEmbedderProfile {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Validates then saves the profile.
///
/// # Errors
/// - [`AppError::Invalid`] if the profile's invariants are violated (empty
/// `id`/`name`, zero `dimension`),
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: SaveEmbedderProfileInput,
) -> Result<SaveEmbedderProfileOutput, AppError> {
let profile = EmbedderProfile::new(
input.id,
input.name,
input.strategy,
input.model,
input.endpoint,
input.api_key_env,
input.dimension,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.store.save(&profile).await?;
Ok(SaveEmbedderProfileOutput { profile })
}
}
// ---------------------------------------------------------------------------
// DeleteEmbedderProfile
// ---------------------------------------------------------------------------
/// Input for [`DeleteEmbedderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteEmbedderProfileInput {
/// Id of the embedder profile to delete.
pub id: String,
}
/// Deletes an embedder profile by id.
pub struct DeleteEmbedderProfile {
store: Arc<dyn EmbedderProfileStore>,
}
impl DeleteEmbedderProfile {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Deletes the profile.
///
/// # Errors
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
/// persistence failure.
pub async fn execute(&self, input: DeleteEmbedderProfileInput) -> Result<(), AppError> {
self.store.delete(&input.id).await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// DescribeEmbedderEngines
// ---------------------------------------------------------------------------
/// A recommendable local ONNX model, as a plain application value (mirrors the
/// infrastructure `OnnxModelInfo` data, injected at the composition root so the
/// application never depends on the infrastructure crate).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnnxModelView {
/// Stable model id accepted by a `localOnnx` profile's `model` field.
pub id: String,
/// Human-readable name for the UI.
pub display_name: String,
/// Length of the vectors this model produces.
pub dimension: usize,
/// Approximate download/disk size in megabytes.
pub approx_size_mb: u32,
/// Whether this is the recommended default model.
pub recommended: bool,
}
/// A read-only description of the embedding engines available to the
/// "configure an embedder?" UI (C2/C3).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmbedderEnginesView {
/// The curated catalogue of recommendable local ONNX models.
pub recommended_onnx: Vec<OnnxModelView>,
/// Whether an Ollama-style local embedding server was detected (best-effort).
pub ollama_detected: bool,
/// Ids of the recommended ONNX models already present in the local cache.
pub onnx_cached_models: Vec<String>,
/// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary.
pub vector_http_enabled: bool,
/// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary.
pub vector_onnx_enabled: bool,
}
/// Describes the engines available to the embedder-configuration UI: the static
/// ONNX catalogue + compiled-capability flags (injected at construction), enriched
/// with a best-effort live snapshot of the local environment via the
/// [`EmbedderEnvInspector`] port. Read-only — emits no event, never fails on the
/// environment probe (the port is best-effort by contract).
pub struct DescribeEmbedderEngines {
inspector: Arc<dyn EmbedderEnvInspector>,
recommended_onnx: Vec<OnnxModelView>,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
}
impl DescribeEmbedderEngines {
/// Builds the use case from the [`EmbedderEnvInspector`] port plus the static
/// engine catalogue and compiled-capability flags (data owned by infrastructure
/// and injected at the composition root, so the application stays infra-free).
#[must_use]
pub fn new(
inspector: Arc<dyn EmbedderEnvInspector>,
recommended_onnx: Vec<OnnxModelView>,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
) -> Self {
Self {
inspector,
recommended_onnx,
vector_http_enabled,
vector_onnx_enabled,
}
}
/// Returns the engines view. Infallible in practice: the environment probe is
/// best-effort and degrades to "nothing detected".
///
/// # Errors
/// Never; the `Result` keeps the call site uniform with the other use cases.
#[allow(clippy::unused_async)]
pub async fn execute(&self) -> Result<EmbedderEnginesView, AppError> {
let report = self.inspector.inspect().await;
Ok(EmbedderEnginesView {
recommended_onnx: self.recommended_onnx.clone(),
ollama_detected: report.ollama_detected,
onnx_cached_models: report.onnx_cached_models,
vector_http_enabled: self.vector_http_enabled,
vector_onnx_enabled: self.vector_onnx_enabled,
})
}
}

View File

@ -6,7 +6,7 @@ mod usecases;
pub use usecases::{
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
GitStatusOutput, GitUnstage,
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
GitStatusInput, GitStatusOutput, GitUnstage,
};

View File

@ -47,12 +47,12 @@ pub struct HealthUseCase {
impl HealthUseCase {
/// Builds the use case from its injected ports.
#[must_use]
pub fn new(clock: Arc<dyn Clock>, ids: Arc<dyn IdGenerator>, events: Arc<dyn EventBus>) -> Self {
Self {
clock,
ids,
events,
}
pub fn new(
clock: Arc<dyn Clock>,
ids: Arc<dyn IdGenerator>,
events: Arc<dyn EventBus>,
) -> Self {
Self { clock, ids, events }
}
/// Executes the health check.

View File

@ -25,14 +25,14 @@ mod snapshot;
mod store;
mod usecases;
pub use snapshot::{
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
};
pub use management::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
};
pub use snapshot::{
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
};
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
pub use usecases::{
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,

View File

@ -151,7 +151,8 @@ pub async fn persist_doc(
) -> Result<(), AppError> {
let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
fs.create_dir_all(&ideai_dir).await?;
fs.write(&layouts_path(project), &to_json_bytes(doc)?).await?;
fs.write(&layouts_path(project), &to_json_bytes(doc)?)
.await?;
Ok(())
}

View File

@ -8,7 +8,10 @@
use std::sync::Arc;
use domain::ports::{EventBus, FileSystem, ProjectStore};
use domain::{AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, ProjectId, SessionId};
use domain::{
AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId,
ProjectId, SessionId,
};
use crate::error::AppError;
@ -69,6 +72,14 @@ pub enum LayoutOperation {
/// Session to host, or `None` to clear.
session: Option<SessionId>,
},
/// Attach an existing live/background session to a leaf, clearing any
/// previous visible host of the same session.
AttachSession {
/// The hosting leaf.
target: NodeId,
/// Session to make visible in this leaf.
session: SessionId,
},
/// Attach or detach an agent to/from a leaf (per-cell agent, feature #3).
SetCellAgent {
/// The hosting leaf.
@ -117,6 +128,7 @@ impl LayoutOperation {
Self::Resize { container, weights } => tree.resize(*container, weights),
Self::Move { from, to } => tree.move_session(*from, *to),
Self::SetSession { target, session } => tree.set_session(*target, *session),
Self::AttachSession { target, session } => tree.attach_session(*target, *session),
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
Self::SetCellConversation {
target,

View File

@ -12,6 +12,7 @@
#![warn(missing_docs)]
pub mod agent;
pub mod embedder;
pub mod error;
pub mod git;
pub mod health;
@ -35,17 +36,23 @@ pub use agent::{
ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile,
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET,
};
pub use embedder::{
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice,
DismissEmbedderSuggestion, DismissEmbedderSuggestionInput, EmbedderEnginesView,
ListEmbedderProfiles, ListEmbedderProfilesOutput, OnnxModelView, SaveEmbedderProfile,
SaveEmbedderProfileInput, SaveEmbedderProfileOutput, SuggestedThisSession,
};
pub use error::AppError;
pub use orchestrator::{OrchestratorOutcome, OrchestratorService};
pub use git::{
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
GitStatusOutput, GitUnstage,
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
GitStatusInput, GitStatusOutput, GitUnstage,
};
pub use health::{HealthInput, HealthReport, HealthUseCase};
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
pub use layout::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
@ -56,15 +63,25 @@ pub use layout::{
};
pub use memory::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput,
ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput,
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};
pub use orchestrator::{OrchestratorOutcome, OrchestratorService};
pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
OpenProjectInput, OpenProjectOutput, ProjectMeta,
OpenProjectInput, OpenProjectOutput, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
PROJECT_CONTEXT_FILE,
};
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
pub use skill::{
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
UpdateSkillOutput,
};
pub use template::{
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
@ -74,16 +91,9 @@ pub use template::{
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
UpdateTemplateOutput,
};
pub use skill::{
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
UpdateSkillOutput,
};
pub use terminal::{
LiveAgentRegistry,
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,
WriteToTerminalInput,
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, OpenTerminal,
OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions,
WriteToTerminal, WriteToTerminalInput,
};
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};

View File

@ -16,8 +16,8 @@ mod usecases;
pub use usecases::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput,
ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput,
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};

View File

@ -136,7 +136,8 @@ impl UpdateMemory {
let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content))
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.memories.save(&input.project_root, &memory).await?;
self.events.publish(DomainEvent::MemorySaved { slug: input.slug });
self.events
.publish(DomainEvent::MemorySaved { slug: input.slug });
Ok(UpdateMemoryOutput { memory })
}
}
@ -262,8 +263,11 @@ impl DeleteMemory {
/// - [`AppError::NotFound`] if no note carries that slug,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteMemoryInput) -> Result<(), AppError> {
self.memories.delete(&input.project_root, &input.slug).await?;
self.events.publish(DomainEvent::MemoryDeleted { slug: input.slug });
self.memories
.delete(&input.project_root, &input.slug)
.await?;
self.events
.publish(DomainEvent::MemoryDeleted { slug: input.slug });
Ok(())
}
}

View File

@ -16,7 +16,7 @@
use std::sync::Arc;
use domain::ports::ProfileStore;
use domain::{OrchestratorCommand, Project, ProfileId};
use domain::{OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
use crate::agent::{
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
@ -95,7 +95,11 @@ impl OrchestratorService {
name,
profile,
context,
} => self.spawn_agent(project, name, profile, context).await,
visibility,
} => {
self.spawn_agent(project, name, profile, context, visibility)
.await
}
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
OrchestratorCommand::UpdateAgentContext { name, context } => {
self.update_agent_context(project, name, context).await
@ -115,15 +119,19 @@ impl OrchestratorService {
&self,
project: &Project,
name: String,
profile: String,
profile: Option<String>,
context: Option<String>,
visibility: OrchestratorVisibility,
) -> Result<OrchestratorOutcome, AppError> {
let existing = self.find_agent_id_by_name(project, &name).await?;
let agent_id = match existing {
Some(id) => id,
None => {
let profile_id = self.resolve_profile(&profile).await?;
let profile = profile.as_deref().ok_or_else(|| {
AppError::Invalid("profile is required to create an agent".to_owned())
})?;
let profile_id = self.resolve_profile(profile).await?;
let created = self
.create_agent
.execute(CreateAgentInput {
@ -137,19 +145,54 @@ impl OrchestratorService {
}
};
if let Some(session_id) = self.sessions.session_for_agent(&agent_id) {
match visibility {
OrchestratorVisibility::Background => {
return Ok(OrchestratorOutcome {
detail: format!("agent {name} already running in background"),
});
}
OrchestratorVisibility::Visible { node_id } => {
let session = self
.sessions
.rebind_agent_node(&agent_id, node_id)
.ok_or_else(|| {
AppError::NotFound(format!(
"running session {session_id} for agent {name}"
))
})?;
return Ok(OrchestratorOutcome {
detail: format!("attached agent {name} to cell {}", session.node_id),
});
}
}
}
let node_id = match visibility {
OrchestratorVisibility::Background => None,
OrchestratorVisibility::Visible { node_id } => Some(node_id),
};
self.launch_agent
.execute(LaunchAgentInput {
project: project.clone(),
agent_id,
rows: DEFAULT_ROWS,
cols: DEFAULT_COLS,
node_id: None,
node_id,
conversation_id: None,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("launched agent {name}"),
detail: match visibility {
OrchestratorVisibility::Background => {
format!("launched agent {name} in background")
}
OrchestratorVisibility::Visible { node_id } => {
format!("launched agent {name} in cell {node_id}")
}
},
})
}

View File

@ -0,0 +1,115 @@
//! Project-level context stored under `.ideai/CONTEXT.md`.
//!
//! This context is model-agnostic and shared by every agent/profile launch. It is
//! deliberately project-local: deleting `.ideai/` removes the context together
//! with every other IdeA artefact for that project.
use std::sync::Arc;
use domain::ports::{FileSystem, FsError, RemotePath};
use domain::Project;
use crate::error::AppError;
use super::meta::{join_root, IDEAI_DIR};
/// Project-context file name inside `.ideai/`.
pub const PROJECT_CONTEXT_FILE: &str = "CONTEXT.md";
/// Input for [`ReadProjectContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadProjectContextInput {
/// Project whose `.ideai/CONTEXT.md` should be read.
pub project: Project,
}
/// Output of [`ReadProjectContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadProjectContextOutput {
/// Markdown content. Empty when the file does not exist yet.
pub content: String,
}
/// Reads `.ideai/CONTEXT.md` tolerantly.
pub struct ReadProjectContext {
fs: Arc<dyn FileSystem>,
}
impl ReadProjectContext {
/// Builds the use case.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// Executes the read.
///
/// # Errors
/// Returns [`AppError::FileSystem`] on non-missing filesystem failures, and
/// [`AppError::Store`] if the file is not valid UTF-8.
pub async fn execute(
&self,
input: ReadProjectContextInput,
) -> Result<ReadProjectContextOutput, AppError> {
match self.fs.read(&project_context_path(&input.project)).await {
Ok(bytes) => {
let content = String::from_utf8(bytes)
.map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}")))?;
Ok(ReadProjectContextOutput { content })
}
Err(FsError::NotFound(_)) => Ok(ReadProjectContextOutput {
content: String::new(),
}),
Err(e) => Err(AppError::FileSystem(e.to_string())),
}
}
}
/// Input for [`UpdateProjectContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProjectContextInput {
/// Project whose `.ideai/CONTEXT.md` should be overwritten.
pub project: Project,
/// New Markdown content.
pub content: String,
}
/// Overwrites `.ideai/CONTEXT.md`.
pub struct UpdateProjectContext {
fs: Arc<dyn FileSystem>,
}
impl UpdateProjectContext {
/// Builds the use case.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// Executes the update.
///
/// # Errors
/// Returns [`AppError::FileSystem`] if `.ideai/` cannot be created or the
/// context file cannot be written.
pub async fn execute(&self, input: UpdateProjectContextInput) -> Result<(), AppError> {
self.fs
.create_dir_all(&RemotePath::new(join_root(&input.project.root, IDEAI_DIR)))
.await?;
self.fs
.write(
&project_context_path(&input.project),
input.content.as_bytes(),
)
.await?;
Ok(())
}
}
/// Absolute path to `<root>/.ideai/CONTEXT.md`.
#[must_use]
pub fn project_context_path(project: &Project) -> RemotePath {
RemotePath::new(join_root(
&project.root,
&format!("{IDEAI_DIR}/{PROJECT_CONTEXT_FILE}"),
))
}

View File

@ -14,11 +14,18 @@
//! - [`ListProjects`] — list known projects from the registry.
mod close;
mod context;
mod create;
pub(crate) mod meta;
mod open;
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput};
pub use context::{
project_context_path, ReadProjectContext, ReadProjectContextInput, ReadProjectContextOutput,
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
};
pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput};
pub use meta::ProjectMeta;
pub use open::{ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput};
pub use open::{
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
};

View File

@ -7,9 +7,7 @@ use domain::{AgentManifest, Project, ProjectId};
use crate::error::AppError;
use super::meta::{
from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE,
};
use super::meta::{from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE};
/// Input for [`OpenProject::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]

View File

@ -249,13 +249,21 @@ impl AssignSkillToAgent {
// Mutate through the domain entity so the dedup invariant is enforced in
// one place, then fold the result back into the manifest entry.
let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?;
let mut agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
let changed = agent.assign_skill(input.skill);
*entry = domain::ManifestEntry::from_agent(&agent);
if changed {
self.persist_and_announce(&input.project, manifest, input.agent_id, input.skill.skill_id, true)
.await?;
self.persist_and_announce(
&input.project,
manifest,
input.agent_id,
input.skill.skill_id,
true,
)
.await?;
}
Ok(())
}
@ -320,14 +328,18 @@ impl UnassignSkillFromAgent {
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?;
let mut agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
let changed = agent.unassign_skill(input.skill_id);
*entry = domain::ManifestEntry::from_agent(&agent);
if changed {
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
self.events.publish(DomainEvent::SkillAssigned {
agent_id: input.agent_id,
skill_id: input.skill_id,

View File

@ -286,7 +286,9 @@ impl CreateAgentFromTemplate {
entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
// Seed the agent context with the template content.
self.contexts
@ -477,7 +479,9 @@ impl SyncAgentWithTemplate {
// Persist the manifest (revalidated) and overwrite the agent context.
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
self.contexts
.write_context(&input.project, &input.agent_id, &template.content_md)
.await?;

View File

@ -128,19 +128,21 @@ impl TerminalSessions {
})
}
/// Lists every currently-live agent and the cell hosting it.
/// Lists every currently-live agent, its current host cell and session id.
///
/// One `(AgentId, NodeId)` pair per session tagged [`SessionKind::Agent`].
/// One `(AgentId, NodeId, SessionId)` tuple per session tagged [`SessionKind::Agent`].
/// Used by the `list_live_agents` query so the UI can disable an agent that
/// is already running elsewhere (it cannot be launched in a second cell).
#[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId)> {
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
self.entries
.lock()
.map(|m| {
m.values()
.filter_map(|e| match e.session.kind {
SessionKind::Agent { agent_id } => Some((agent_id, e.session.node_id)),
SessionKind::Agent { agent_id } => {
Some((agent_id, e.session.node_id, e.session.id))
}
SessionKind::Plain => None,
})
.collect()
@ -148,6 +150,28 @@ impl TerminalSessions {
.unwrap_or_default()
}
/// Rebinds a live agent session to a new visible layout node without
/// respawning the CLI process.
///
/// This is the application-level "cell is a view" operation: closing a cell
/// can leave an agent running in the background, and later opening that agent
/// in another cell updates only the view binding (`node_id`). The PTY handle,
/// session id, scrollback and process stay untouched.
#[must_use]
pub fn rebind_agent_node(
&self,
agent_id: &AgentId,
node_id: NodeId,
) -> Option<TerminalSession> {
self.entries.lock().ok().and_then(|mut m| {
let entry = m.values_mut().find(
|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id),
)?;
entry.session.node_id = node_id;
Some(entry.session.clone())
})
}
/// Returns the [`PtyHandle`]s of every currently-registered session.
///
/// Used at application shutdown to kill all live PTYs cleanly (the

View File

@ -67,10 +67,7 @@ impl OpenTerminal {
/// # Errors
/// - [`AppError::Invalid`] for a non-absolute cwd or a zero-sized terminal,
/// - [`AppError::Process`] if the PTY fails to spawn.
pub async fn execute(
&self,
input: OpenTerminalInput,
) -> Result<OpenTerminalOutput, AppError> {
pub async fn execute(&self, input: OpenTerminalInput) -> Result<OpenTerminalOutput, AppError> {
let cwd = ProjectPath::new(input.cwd).map_err(|e| AppError::Invalid(e.to_string()))?;
let size =
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;