feat(memory): clôture du sujet mémoire — bascule adaptative live + panneau UI (§14.5.5)
Pièce 1 (backend) : state.rs câble AdaptiveMemoryRecall via build_memory_recall, piloté par le profil embedder chargé depuis embedder.json (fallback none). Défaut none ⇒ NaiveMemoryRecall nu (comportement inchangé, StubEmbedder jamais touché, zéro dépendance lourde). Instance de recall partagée (RecallMemory + LaunchAgent). Chargement du profil isolé sur un runtime dédié (évite le block_on imbriqué). Pièce 2 (frontend) : feature mémoire complète en miroir de skills — MemoryGateway (port) + TauriMemoryGateway + MockMemoryGateway, types domaine Memory/MemoryIndexEntry/MemoryType, MemoryPanel/MemoryEditor/useMemory, onglet sidebar « Memory » dans ProjectsView. CRUD par slug, liens [[slug]] résolus. Le sujet mémoire est clos : CRUD .md + index + rappel adaptatif + injection à l'activation des agents + UI de gestion. Embedder concret ONNX/HTTP reste un follow-up (défaut none = pleinement fonctionnel sans dépendance). Tests: backend 57 binaires verts, frontend 285 tests verts, typecheck OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -24,17 +24,18 @@ use application::{
|
||||
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator,
|
||||
AgentContextStore, AgentRuntime, Clock, Embedder, EventBus, FileSystem, GitPort, IdGenerator,
|
||||
MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore,
|
||||
TemplateStore,
|
||||
};
|
||||
use domain::{DomainEvent, Project, ProjectId};
|
||||
use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
|
||||
|
||||
use infrastructure::{
|
||||
ClaudeTranscriptInspector, CliAgentRuntime, FsOrchestratorWatcher, FsProfileStore,
|
||||
FsMemoryStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore,
|
||||
LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle,
|
||||
PortablePtyAdapter, SystemClock, TokioBroadcastEventBus, UuidGenerator,
|
||||
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
|
||||
FsEmbedderProfileStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore,
|
||||
FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, LocalFileSystem,
|
||||
LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
||||
SystemClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall,
|
||||
};
|
||||
|
||||
use crate::pty::PtyBridge;
|
||||
@ -368,8 +369,35 @@ impl AppState {
|
||||
// are wired further down from these same instances.
|
||||
let memory_store = Arc::new(FsMemoryStore::new(Arc::clone(&fs_port)));
|
||||
let memory_store_port = Arc::clone(&memory_store) as Arc<dyn MemoryStore>;
|
||||
let memory_recall_port =
|
||||
Arc::new(NaiveMemoryRecall::new(Arc::clone(&memory_store_port))) as Arc<dyn MemoryRecall>;
|
||||
// Load the configured embedder profile (mono-profile for now: take the last
|
||||
// listed, fall back to `none`). A multi-profile selector is a follow-up; the
|
||||
// `none` default keeps recall strictly naïve and dependency-free.
|
||||
let embedder_store = FsEmbedderProfileStore::new(
|
||||
Arc::clone(&fs_port),
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
);
|
||||
// `build` may run inside an ambient async runtime (Tauri's `setup`, or
|
||||
// `#[tokio::test]`), so blocking the current thread on a future panics.
|
||||
// Drive the one-shot load on a dedicated thread with its own runtime.
|
||||
let embedder_profile = std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.ok()
|
||||
.and_then(|rt| rt.block_on(embedder_store.list()).ok())
|
||||
.and_then(|mut v| v.pop())
|
||||
})
|
||||
.join()
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or_else(EmbedderProfile::none);
|
||||
let memory_recall_port = build_memory_recall(
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&memory_store_port),
|
||||
&embedder_profile,
|
||||
);
|
||||
|
||||
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
||||
Arc::clone(&contexts_port),
|
||||
@ -670,3 +698,159 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the project memory recall port from an embedder profile.
|
||||
///
|
||||
/// This is the only place that knows the concrete recall adapters (DIP): callers
|
||||
/// only ever see `Arc<dyn MemoryRecall>`. With the default `none` profile,
|
||||
/// `embedder_from_profile` returns `None` and we hand back the plain
|
||||
/// `NaiveMemoryRecall` — strictly identical, dependency-free behaviour. When an
|
||||
/// embedder is configured, we wrap naïve + vector recall in an
|
||||
/// `AdaptiveMemoryRecall` that switches stages live per the profile strategy.
|
||||
pub(crate) fn build_memory_recall(
|
||||
fs: Arc<dyn FileSystem>,
|
||||
store: Arc<dyn MemoryStore>,
|
||||
profile: &EmbedderProfile,
|
||||
) -> Arc<dyn MemoryRecall> {
|
||||
let naive = Arc::new(NaiveMemoryRecall::new(Arc::clone(&store))) as Arc<dyn MemoryRecall>;
|
||||
match embedder_from_profile(profile) {
|
||||
None => naive,
|
||||
Some(embedder) => {
|
||||
let embedder: Arc<dyn Embedder> = Arc::from(embedder);
|
||||
let vector: Arc<dyn MemoryRecall> = Arc::new(VectorMemoryRecall::new(
|
||||
embedder,
|
||||
Arc::clone(&store),
|
||||
Arc::clone(&fs),
|
||||
));
|
||||
Arc::new(AdaptiveMemoryRecall::new(
|
||||
naive,
|
||||
vector,
|
||||
Arc::clone(&store),
|
||||
profile.strategy,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Wiring contract for [`build_memory_recall`] (Pièce 1, §14.5.5).
|
||||
//!
|
||||
//! The *behaviour* of `NaiveMemoryRecall` / `VectorMemoryRecall` /
|
||||
//! `AdaptiveMemoryRecall` is covered exhaustively in
|
||||
//! `infrastructure/tests/vector_recall.rs`. Here we only pin the **composition
|
||||
//! root contract**: with the default `EmbedderProfile::none()` profile (the
|
||||
//! dependency-free default), `build_memory_recall` must hand back a recall whose
|
||||
//! observable behaviour is *identical* to a bare `NaiveMemoryRecall` over the
|
||||
//! same store — same index ordering, same budget truncation.
|
||||
//!
|
||||
//! `build_memory_recall` is `pub(crate)`, so this lives in `state.rs` (it is not
|
||||
//! reachable from the `tests/` integration crate). Everything is in-memory and
|
||||
//! deterministic.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{DirEntry, FsError, MemoryQuery, RemotePath};
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
// In-memory FileSystem (same minimal shape as the infrastructure test fixtures).
|
||||
#[derive(Default)]
|
||||
struct MemFs {
|
||||
files: Mutex<HashMap<String, Vec<u8>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for MemFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(path.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| FsError::NotFound(path.as_str().to_string()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.as_str().to_string(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(self.files.lock().unwrap().contains_key(path.as_str()))
|
||||
}
|
||||
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn root() -> ProjectPath {
|
||||
ProjectPath::new("/proj").unwrap()
|
||||
}
|
||||
|
||||
fn note(slug: &str, hook: &str) -> Memory {
|
||||
Memory::new(
|
||||
MemoryFrontmatter {
|
||||
name: MemorySlug::new(slug).unwrap(),
|
||||
description: hook.to_string(),
|
||||
r#type: MemoryType::Project,
|
||||
},
|
||||
MarkdownDoc::new("# body"),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn query(text: &str, budget: usize) -> MemoryQuery {
|
||||
MemoryQuery {
|
||||
text: text.to_string(),
|
||||
token_budget: budget,
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_store() -> (Arc<dyn FileSystem>, Arc<dyn MemoryStore>) {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(MemFs::default());
|
||||
let store_concrete = Arc::new(FsMemoryStore::new(Arc::clone(&fs)));
|
||||
for n in [
|
||||
note("alpha", "apple orange grape"),
|
||||
note("beta", "kiwi mango papaya"),
|
||||
note("gamma", "carrot potato onion"),
|
||||
] {
|
||||
store_concrete.save(&root(), &n).await.unwrap();
|
||||
}
|
||||
let store: Arc<dyn MemoryStore> = store_concrete;
|
||||
(fs, store)
|
||||
}
|
||||
|
||||
/// With the default `none` profile, `build_memory_recall` is observationally a
|
||||
/// plain `NaiveMemoryRecall`: same index ordering and same budget truncation,
|
||||
/// entry-for-entry, across a representative spread of budgets.
|
||||
#[tokio::test]
|
||||
async fn build_memory_recall_none_profile_matches_naive_recall() {
|
||||
let (fs, store) = seed_store().await;
|
||||
|
||||
let wired = build_memory_recall(Arc::clone(&fs), Arc::clone(&store), &EmbedderProfile::none());
|
||||
let naive = NaiveMemoryRecall::new(Arc::clone(&store));
|
||||
|
||||
// 0 ⇒ empty; mid budgets ⇒ partial truncation; huge ⇒ full index order.
|
||||
for budget in [0usize, 3, 6, 100, 100_000] {
|
||||
let q = query("kiwi mango papaya", budget);
|
||||
let expected = naive.recall(&root(), &q).await.unwrap();
|
||||
let got = wired.recall(&root(), &q).await.unwrap();
|
||||
assert_eq!(
|
||||
got, expected,
|
||||
"none profile must equal bare NaiveMemoryRecall at budget {budget}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user