a3s 0.10.6

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
mod materialize;
mod model;
mod observer;
mod runtime;
mod store;

use std::path::{Path, PathBuf};

use a3s_memory::MemoryStore;
use anyhow::Context;

pub(crate) use model::{
    EvolutionCandidate, EvolutionKind, EvolutionMutationResult, EvolutionOverview, EvolutionState,
};
pub(crate) use observer::EvolutionMemoryObserver;

use materialize::{materialize_candidate, rollback_candidate};
use runtime::session_preference_prompt;
use store::{
    auto_materialize_eligible, mark_session_assets_activated, observe_batch, overview,
    pending_session_reload_count, read_catalog, reject_candidate, reopen_candidate, EvolutionPaths,
};

const MAX_MEMORY_SCAN_ITEMS: usize = 10_000;

#[derive(Debug, Clone)]
pub(crate) struct WorkspaceEvolution {
    paths: EvolutionPaths,
}

impl WorkspaceEvolution {
    pub(crate) fn new(workspace: impl AsRef<Path>) -> Self {
        Self {
            paths: EvolutionPaths::new(workspace),
        }
    }

    pub(crate) async fn observe(
        &self,
        observation: a3s_code_core::memory::MemoryObservation,
    ) -> anyhow::Result<()> {
        let evolution = self.clone();
        tokio::task::spawn_blocking(move || evolution.observe_sync(vec![observation]))
            .await
            .context("evolution observer task did not complete")??;
        Ok(())
    }

    pub(crate) async fn synchronize_memory_store(
        &self,
        memory_dir: impl Into<PathBuf>,
    ) -> anyhow::Result<usize> {
        let store = a3s_memory::FileMemoryStore::new(memory_dir.into()).await?;
        let count = store.count().await?.min(MAX_MEMORY_SCAN_ITEMS);
        let items = store.get_recent(count).await?;
        let observations = items
            .into_iter()
            .flat_map(memory_item_observations)
            .collect::<Vec<_>>();
        let observed = observations.len();
        let evolution = self.clone();
        tokio::task::spawn_blocking(move || evolution.observe_sync(observations))
            .await
            .context("evolution memory scan task did not complete")??;
        Ok(observed)
    }

    pub(crate) async fn overview(&self) -> anyhow::Result<EvolutionOverview> {
        let paths = self.paths.clone();
        tokio::task::spawn_blocking(move || overview(&paths))
            .await
            .context("evolution overview task did not complete")?
    }

    pub(crate) async fn materialize(
        &self,
        id: impl Into<String>,
        force: bool,
    ) -> anyhow::Result<EvolutionMutationResult> {
        let paths = self.paths.clone();
        let id = id.into();
        tokio::task::spawn_blocking(move || materialize_candidate(&paths, &id, force, false))
            .await
            .context("evolution materialization task did not complete")?
    }

    pub(crate) async fn reject(
        &self,
        id: impl Into<String>,
        reason: Option<String>,
    ) -> anyhow::Result<EvolutionCandidate> {
        let paths = self.paths.clone();
        let id = id.into();
        tokio::task::spawn_blocking(move || reject_candidate(&paths, &id, reason))
            .await
            .context("evolution rejection task did not complete")?
    }

    pub(crate) async fn reopen(&self, id: impl Into<String>) -> anyhow::Result<EvolutionCandidate> {
        let paths = self.paths.clone();
        let id = id.into();
        tokio::task::spawn_blocking(move || reopen_candidate(&paths, &id))
            .await
            .context("evolution reopen task did not complete")?
    }

    pub(crate) async fn rollback(
        &self,
        id: impl Into<String>,
        target_version: Option<u32>,
    ) -> anyhow::Result<EvolutionMutationResult> {
        let paths = self.paths.clone();
        let id = id.into();
        tokio::task::spawn_blocking(move || rollback_candidate(&paths, &id, target_version))
            .await
            .context("evolution rollback task did not complete")?
    }

    pub(crate) async fn pending_session_reload_count(&self) -> anyhow::Result<usize> {
        let paths = self.paths.clone();
        tokio::task::spawn_blocking(move || pending_session_reload_count(&paths))
            .await
            .context("evolution pending-reload inspection did not complete")?
    }

    pub(crate) async fn mark_session_assets_activated(&self) -> anyhow::Result<usize> {
        let paths = self.paths.clone();
        tokio::task::spawn_blocking(move || mark_session_assets_activated(&paths))
            .await
            .context("evolution activation update did not complete")?
    }

    pub(crate) fn session_preference_prompt(&self) -> anyhow::Result<Option<String>> {
        session_preference_prompt(&self.paths)
    }

    fn observe_sync(
        &self,
        observations: Vec<a3s_code_core::memory::MemoryObservation>,
    ) -> anyhow::Result<()> {
        let changed = observe_batch(&self.paths, observations)?;
        if changed.is_empty() {
            return Ok(());
        }
        let catalog = read_catalog(&self.paths)?;
        for candidate in catalog
            .candidates
            .iter()
            .filter(|candidate| changed.contains(&candidate.id))
            .filter(|candidate| auto_materialize_eligible(candidate))
        {
            if let Err(error) = materialize_candidate(&self.paths, &candidate.id, false, true) {
                tracing::warn!(
                    candidate_id = %candidate.id,
                    %error,
                    "automatic local evolution materialization was deferred"
                );
            }
        }
        Ok(())
    }
}

fn memory_item_observations(
    item: a3s_memory::MemoryItem,
) -> Vec<a3s_code_core::memory::MemoryObservation> {
    let mut ids = vec![item.id.clone()];
    if let Some(duplicates) = item.metadata.get("duplicate_ids") {
        for id in duplicates.split(',').map(str::trim) {
            if !id.is_empty() && !ids.iter().any(|seen| seen == id) {
                ids.push(id.to_string());
            }
        }
    }
    ids.into_iter()
        .map(|id| {
            let mut incoming = item.clone();
            incoming
                .metadata
                .insert("last_observation_id".to_string(), id);
            a3s_code_core::memory::MemoryObservation {
                incoming,
                stored: item.clone(),
                merged: false,
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use a3s_memory::{MemoryItem, MemoryType};

    #[tokio::test]
    async fn file_memory_scan_is_idempotent() {
        let temp = tempfile::tempdir().unwrap();
        let memory_dir = temp.path().join("memory");
        let workspace = temp.path().join("workspace");
        tokio::fs::create_dir_all(&workspace).await.unwrap();
        let store = a3s_memory::FileMemoryStore::new(&memory_dir).await.unwrap();
        let item = MemoryItem::new("Prefer concise evidence-backed responses in this workspace.")
            .with_type(MemoryType::Semantic)
            .with_importance(0.92)
            .with_metadata("source", "preference")
            .with_metadata("scope", "user")
            .with_metadata("confidence", "0.95")
            .with_metadata("evolution_schema", "a3s.evolution.signal.v1")
            .with_metadata("evolution_kind", "preference")
            .with_metadata("evolution_pattern", "preference.response.concise")
            .with_metadata("evolution_title", "Concise evidence-backed responses")
            .with_metadata(
                "evolution_summary",
                "Keep responses concise while retaining concrete supporting evidence.",
            )
            .with_metadata(
                "evolution_instructions",
                r#"["Lead with the outcome.","Keep supporting evidence concrete and concise."]"#,
            );
        store.store(item).await.unwrap();
        let evolution = WorkspaceEvolution::new(&workspace);
        evolution
            .synchronize_memory_store(&memory_dir)
            .await
            .unwrap();
        evolution
            .synchronize_memory_store(&memory_dir)
            .await
            .unwrap();
        let overview = evolution.overview().await.unwrap();
        assert_eq!(overview.candidates.len(), 1);
        assert_eq!(overview.candidates[0].occurrences, 1);
    }

    #[tokio::test]
    async fn three_qualifying_observations_materialize_a_skill_and_require_refresh() {
        let temp = tempfile::tempdir().unwrap();
        let workspace = temp.path().join("workspace");
        tokio::fs::create_dir_all(&workspace).await.unwrap();
        let evolution = WorkspaceEvolution::new(&workspace);

        for (id, session) in [
            ("observation-one", "session-one"),
            ("observation-two", "session-two"),
            ("observation-three", "session-two"),
        ] {
            let item = MemoryItem::new(
                "Run the focused persistence checks after changing memory storage.",
            )
            .with_type(MemoryType::Procedural)
            .with_importance(0.91)
            .with_metadata("source", "workflow")
            .with_metadata("scope", "workspace")
            .with_metadata("workspace", workspace.display().to_string())
            .with_metadata("session_id", session)
            .with_metadata("confidence", "0.95")
            .with_metadata("evolution_schema", "a3s.evolution.signal.v1")
            .with_metadata("evolution_kind", "skill")
            .with_metadata("evolution_pattern", "workflow.memory.persistence-checks")
            .with_metadata("evolution_title", "Memory persistence checks")
            .with_metadata(
                "evolution_summary",
                "Run focused persistence verification after memory storage changes.",
            )
            .with_metadata(
                "evolution_instructions",
                r#"["Run the focused memory persistence tests.","Inspect the durable files after the test."]"#,
            );
            let mut incoming = item.clone();
            incoming.id = id.to_string();
            evolution
                .observe(a3s_code_core::memory::MemoryObservation {
                    incoming: incoming.clone(),
                    stored: incoming,
                    merged: false,
                })
                .await
                .unwrap();
        }

        let overview = evolution.overview().await.unwrap();
        assert_eq!(overview.candidates.len(), 1);
        let candidate = &overview.candidates[0];
        assert_eq!(candidate.kind, EvolutionKind::Skill);
        assert_eq!(candidate.state, EvolutionState::Materialized);
        assert_eq!(candidate.current_version, Some(1));
        assert!(candidate.activation_pending);
        assert_eq!(evolution.pending_session_reload_count().await.unwrap(), 1);
        let asset = workspace.join(candidate.asset_path.as_deref().unwrap());
        assert!(asset.join("SKILL.md").is_file());
        assert!(candidate.versions[0].automatic);
    }
}