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:
2026-06-08 13:28:27 +02:00
parent 2435857cbf
commit f3bc3f20d8
16 changed files with 1414 additions and 10 deletions

View File

@ -33,3 +33,4 @@ uuid = { workspace = true }
[dev-dependencies]
uuid = { workspace = true }
async-trait = { workspace = true }

View File

@ -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}"
);
}
}
}