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:
@ -896,6 +896,27 @@ Une ligne par `MemoryIndexEntry` (`title`, `slug`, `hook`, `r#type`). C'est le
|
|||||||
|
|
||||||
**Note de réutilisation** : la résolution préfère le port `MemoryRecall` (rappel borné) plutôt qu'un `read_index` brut, pour hériter directement de la bascule étage 1/étage 2 (LOT C) sans retoucher `LaunchAgent`.
|
**Note de réutilisation** : la résolution préfère le port `MemoryRecall` (rappel borné) plutôt qu'un `read_index` brut, pour hériter directement de la bascule étage 1/étage 2 (LOT C) sans retoucher `LaunchAgent`.
|
||||||
|
|
||||||
|
##### 14.5.5 Câblage final & UI mémoire — clôture du sujet mémoire
|
||||||
|
|
||||||
|
Deux dernières pièces ferment L14 : le **câblage adaptatif backend** (rendre la bascule étage 1↔2 « live » au composition root, défaut `none` conservé) et le **contrat `MemoryGateway` + panneau mémoire** côté front (miroir du modèle skills L12).
|
||||||
|
|
||||||
|
**Pièce 1 — wiring `AdaptiveMemoryRecall` dans `app-tauri` (backend).**
|
||||||
|
|
||||||
|
- Décision : `state.rs` câble désormais `Arc<dyn MemoryRecall>` via un **helper de composition** `build_memory_recall(fs, memory_store_port, embedder_profile) -> Arc<dyn MemoryRecall>` plutôt que `NaiveMemoryRecall` en dur. Le profil embedder est **chargé depuis `embedder.json` global** via `FsEmbedderProfileStore` (déjà existant), avec **fallback `EmbedderProfile::none()`** si le fichier est absent ou vide — esprit « rien d'imposé, zéro dépendance ».
|
||||||
|
- **Défaut strictement identique au naïf** : pour `EmbedderProfile::none()`, `embedder_from_profile` retourne `None`. Dans ce cas le helper renvoie **directement `NaiveMemoryRecall`** (pas d'`AdaptiveMemoryRecall`, pas de `VectorMemoryRecall`, donc `StubEmbedder` jamais instancié). L'`AdaptiveMemoryRecall` n'est construit **que** lorsqu'un embedder concret existe (stratégie ≠ `none`) ; sa logique `should_use_vector` garantit de toute façon le repli naïf tant que la mémoire ne dépasse pas le budget, et le repli best-effort si l'embedder échoue (Liskov). Comportement par défaut = byte-for-byte le naïf actuel (couvert par `adaptive_none_strategy_matches_naive_exactly`).
|
||||||
|
- **Instance unique partagée** : le `Arc<dyn MemoryRecall>` produit est injecté **à l'identique** dans `LaunchAgent` (injection §14.5.4) **et** dans `RecallMemory` (commande `recall_memory`) — une seule instance, donc l'UI et l'activation d'agent voient le même rappel. Aucune régression sur les 7 commandes mémoire ni sur les tests existants : seul le type concret derrière le port change, et il reste `NaiveMemoryRecall` par défaut.
|
||||||
|
- **Chargement async dans un `build` sync** : `embedder.json` est lu via `tauri::async_runtime::block_on` dans `AppState::build` (déjà appelé dans un contexte `setup` Tauri), cohérent avec le `block_on` du hook de shutdown. Le composition root reste le seul endroit qui touche au runtime.
|
||||||
|
- Conformité : `LaunchAgent`/`RecallMemory` ne dépendent que du port `MemoryRecall` ; `build_memory_recall` est la seule fonction qui connaît les adapters concrets (DIP). Zéro dépendance lourde tirée au défaut.
|
||||||
|
|
||||||
|
**Pièce 2 — contrat `MemoryGateway` + panneau mémoire (frontend).**
|
||||||
|
|
||||||
|
- Décision : un nouveau port UI `MemoryGateway` (dans `ports/index.ts`), **miroir des 7 commandes backend** (`create_memory`/`update_memory`/`list_memories`/`get_memory`/`delete_memory`/`read_memory_index`/`resolve_memory_links`) + `recall_memory` (optionnel UI). Identité = **slug** (kebab-case), pas d'UUID. Payloads camelCase, `type ∈ user|feedback|project|reference`. Adapter `TauriMemoryGateway` (`adapters/memory.ts`) + `MockMemoryGateway` (`adapters/mock/index.ts`), enregistrés dans `Gateways`.
|
||||||
|
- Types domaine TS ajoutés (`domain/index.ts`) : `MemoryType` (`"user"|"feedback"|"project"|"reference"`), `Memory` (`{ name; description; type; content }`, miroir `MemoryDto`), `MemoryIndexEntry` (`{ slug; title; hook; type }`), `MemoryLink` (alias `string` cible, miroir `MemoryLinksDto`).
|
||||||
|
- UI : feature `features/memory/` calquée sur `features/skills/` — `useMemory.ts` (view-model : liste depuis l'index, create/update/delete, resolve links), `MemoryPanel.tsx` (liste l'index, boutons New/Edit/Delete), `MemoryEditor.tsx` (slug+description+type+contenu en create, contenu+description+type en edit), `index.ts`, `memory.test.tsx`. Montée dans `ProjectsView` via un nouvel onglet sidebar `"memory"`. Périmètre **sobre**, aligné `SkillsPanel`, sans design system avancé. Affichage des liens `[[ ]]` via `resolveLinks` dans l'éditeur (lecture seule).
|
||||||
|
- Conformité : aucun couplage UI↔Tauri hors `TauriMemoryGateway` ; les composants ne consomment que `MemoryGateway` (DIP), testables avec `MockMemoryGateway`. Impacts tests : étendre le mock gateway, ajouter `memory.test.tsx`, et compléter le test `ProjectsView` (nouvel onglet + montage du panneau).
|
||||||
|
|
||||||
|
> **Sujet mémoire (L14) clos** une fois ces deux pièces livrées et vertes : domaine + adapters (A/B/C), use cases + commandes (LOT A/B), injection à l'activation (§14.5.4), bascule adaptative live (§14.5.5 pièce 1) et UI complète (§14.5.5 pièce 2). Évolutions ultérieures (vrais embedders ONNX/HTTP derrière feature, réglage du budget/seuil en config projet) restent des follow-ups indépendants, hors périmètre de clôture.
|
||||||
|
|
||||||
#### Anomalies de conformité relevées sur l'existant (à traiter par les agents dev)
|
#### Anomalies de conformité relevées sur l'existant (à traiter par les agents dev)
|
||||||
|
|
||||||
1. **Doublon `DomainError::MalformedFrontmatter` vs `MemoryError::Frontmatter`** : `error.rs` définit `DomainError::MalformedFrontmatter { reason }`, mais le domaine `memory.rs` ne l'utilise jamais (il lève `EmptyField`/`InvalidSlug`) et l'adapter parse via `MemoryError::Frontmatter`. La variante `DomainError::MalformedFrontmatter` est **morte**. **Action** : la supprimer (le parsing de frontmatter est une responsabilité d'adapter → `MemoryError::Frontmatter`), sauf si un futur parseur de frontmatter *dans le domaine* est prévu (il ne l'est pas — le domaine reste format-neutral, cf. doc-module `memory.rs`). Anomalie mineure, sans impact fonctionnel.
|
1. **Doublon `DomainError::MalformedFrontmatter` vs `MemoryError::Frontmatter`** : `error.rs` définit `DomainError::MalformedFrontmatter { reason }`, mais le domaine `memory.rs` ne l'utilise jamais (il lève `EmptyField`/`InvalidSlug`) et l'adapter parse via `MemoryError::Frontmatter`. La variante `DomainError::MalformedFrontmatter` est **morte**. **Action** : la supprimer (le parsing de frontmatter est une responsabilité d'adapter → `MemoryError::Frontmatter`), sauf si un futur parseur de frontmatter *dans le domaine* est prévu (il ne l'est pas — le domaine reste format-neutral, cf. doc-module `memory.rs`). Anomalie mineure, sans impact fonctionnel.
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -52,6 +52,7 @@ name = "app-tauri"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"application",
|
"application",
|
||||||
|
"async-trait",
|
||||||
"domain",
|
"domain",
|
||||||
"infrastructure",
|
"infrastructure",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@ -33,3 +33,4 @@ uuid = { workspace = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
|||||||
@ -24,17 +24,18 @@ use application::{
|
|||||||
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
|
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
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,
|
MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore,
|
||||||
TemplateStore,
|
TemplateStore,
|
||||||
};
|
};
|
||||||
use domain::{DomainEvent, Project, ProjectId};
|
use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
|
||||||
|
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
ClaudeTranscriptInspector, CliAgentRuntime, FsOrchestratorWatcher, FsProfileStore,
|
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
|
||||||
FsMemoryStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore,
|
FsEmbedderProfileStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore,
|
||||||
LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle,
|
FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, LocalFileSystem,
|
||||||
PortablePtyAdapter, SystemClock, TokioBroadcastEventBus, UuidGenerator,
|
LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
||||||
|
SystemClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::pty::PtyBridge;
|
use crate::pty::PtyBridge;
|
||||||
@ -368,8 +369,35 @@ impl AppState {
|
|||||||
// are wired further down from these same instances.
|
// are wired further down from these same instances.
|
||||||
let memory_store = Arc::new(FsMemoryStore::new(Arc::clone(&fs_port)));
|
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_store_port = Arc::clone(&memory_store) as Arc<dyn MemoryStore>;
|
||||||
let memory_recall_port =
|
// Load the configured embedder profile (mono-profile for now: take the last
|
||||||
Arc::new(NaiveMemoryRecall::new(Arc::clone(&memory_store_port))) as Arc<dyn MemoryRecall>;
|
// 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(
|
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
||||||
Arc::clone(&contexts_port),
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import { TauriLayoutGateway } from "./layout";
|
|||||||
import { TauriProfileGateway } from "./profile";
|
import { TauriProfileGateway } from "./profile";
|
||||||
import { TauriTemplateGateway } from "./template";
|
import { TauriTemplateGateway } from "./template";
|
||||||
import { TauriSkillGateway } from "./skill";
|
import { TauriSkillGateway } from "./skill";
|
||||||
|
import { TauriMemoryGateway } from "./memory";
|
||||||
import { TauriGitGateway } from "./git";
|
import { TauriGitGateway } from "./git";
|
||||||
|
|
||||||
function notImplemented(what: string): never {
|
function notImplemented(what: string): never {
|
||||||
@ -49,6 +50,7 @@ export function createTauriGateways(): Gateways {
|
|||||||
profile: new TauriProfileGateway(),
|
profile: new TauriProfileGateway(),
|
||||||
template: new TauriTemplateGateway(),
|
template: new TauriTemplateGateway(),
|
||||||
skill: new TauriSkillGateway(),
|
skill: new TauriSkillGateway(),
|
||||||
|
memory: new TauriMemoryGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,5 +63,6 @@ export {
|
|||||||
TauriProfileGateway,
|
TauriProfileGateway,
|
||||||
TauriTemplateGateway,
|
TauriTemplateGateway,
|
||||||
TauriSkillGateway,
|
TauriSkillGateway,
|
||||||
|
TauriMemoryGateway,
|
||||||
TauriGitGateway,
|
TauriGitGateway,
|
||||||
};
|
};
|
||||||
|
|||||||
71
frontend/src/adapters/memory.ts
Normal file
71
frontend/src/adapters/memory.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Tauri adapter for {@link MemoryGateway} (L14).
|
||||||
|
*
|
||||||
|
* Commands use snake_case (Tauri convention); payload keys are camelCase
|
||||||
|
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent
|
||||||
|
* with the other adapters in this directory. Mutating commands that take a
|
||||||
|
* structured request (`create_memory`, `update_memory`, `recall_memory`) wrap
|
||||||
|
* their fields under a `request` key; the read commands pass their args flat.
|
||||||
|
* Identity is the immutable **slug**, so `updateMemory` never sends a name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import type { Memory, MemoryIndexEntry, MemoryLink, MemoryType } from "@/domain";
|
||||||
|
import type { CreateMemoryInput, MemoryGateway } from "@/ports";
|
||||||
|
|
||||||
|
export class TauriMemoryGateway implements MemoryGateway {
|
||||||
|
listMemories(projectId: string): Promise<Memory[]> {
|
||||||
|
return invoke<Memory[]>("list_memories", { projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
getMemory(projectId: string, slug: string): Promise<Memory> {
|
||||||
|
return invoke<Memory>("get_memory", { projectId, slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
createMemory(input: CreateMemoryInput): Promise<Memory> {
|
||||||
|
return invoke<Memory>("create_memory", {
|
||||||
|
request: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
type: input.type,
|
||||||
|
content: input.content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMemory(
|
||||||
|
projectId: string,
|
||||||
|
slug: string,
|
||||||
|
description: string,
|
||||||
|
type: MemoryType,
|
||||||
|
content: string,
|
||||||
|
): Promise<Memory> {
|
||||||
|
return invoke<Memory>("update_memory", {
|
||||||
|
request: { projectId, slug, description, type, content },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMemory(projectId: string, slug: string): Promise<void> {
|
||||||
|
await invoke("delete_memory", { projectId, slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
readIndex(projectId: string): Promise<MemoryIndexEntry[]> {
|
||||||
|
return invoke<MemoryIndexEntry[]>("read_memory_index", { projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveLinks(projectId: string, slug: string): Promise<MemoryLink[]> {
|
||||||
|
return invoke<MemoryLink[]>("resolve_memory_links", { projectId, slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
recall(
|
||||||
|
projectId: string,
|
||||||
|
text: string,
|
||||||
|
tokenBudget: number,
|
||||||
|
): Promise<MemoryIndexEntry[]> {
|
||||||
|
return invoke<MemoryIndexEntry[]>("recall_memory", {
|
||||||
|
request: { projectId, text, tokenBudget },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,6 +21,10 @@ import type {
|
|||||||
LayoutList,
|
LayoutList,
|
||||||
LayoutOperation,
|
LayoutOperation,
|
||||||
LayoutTree,
|
LayoutTree,
|
||||||
|
Memory,
|
||||||
|
MemoryIndexEntry,
|
||||||
|
MemoryLink,
|
||||||
|
MemoryType,
|
||||||
Project,
|
Project,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
Skill,
|
Skill,
|
||||||
@ -32,9 +36,11 @@ import type {
|
|||||||
AgentGateway,
|
AgentGateway,
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
|
CreateMemoryInput,
|
||||||
CreateSkillInput,
|
CreateSkillInput,
|
||||||
CreateTemplateInput,
|
CreateTemplateInput,
|
||||||
Gateways,
|
Gateways,
|
||||||
|
MemoryGateway,
|
||||||
GitGateway,
|
GitGateway,
|
||||||
LayoutGateway,
|
LayoutGateway,
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
@ -1178,6 +1184,107 @@ export class MockSkillGateway implements SkillGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful in-memory memory gateway (L14).
|
||||||
|
*
|
||||||
|
* Notes are partitioned by `projectId` and keyed by their immutable **slug**
|
||||||
|
* (derived from the name on create). `readIndex` projects the notes into the
|
||||||
|
* recall index; `resolveLinks` scans `[[slug]]` wikilinks in a note's content
|
||||||
|
* and keeps only those targeting an existing note; `recall` returns a simple
|
||||||
|
* truncation of the index (no embeddings in the mock).
|
||||||
|
*/
|
||||||
|
export class MockMemoryGateway implements MemoryGateway {
|
||||||
|
private byProject = new Map<string, Map<string, Memory>>();
|
||||||
|
|
||||||
|
private bucket(projectId: string): Map<string, Memory> {
|
||||||
|
let store = this.byProject.get(projectId);
|
||||||
|
if (!store) {
|
||||||
|
store = new Map();
|
||||||
|
this.byProject.set(projectId, store);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private notFound(slug: string, projectId: string): GatewayError {
|
||||||
|
return {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `memory ${slug} not found in project ${projectId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMemories(projectId: string): Promise<Memory[]> {
|
||||||
|
return structuredClone([...this.bucket(projectId).values()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMemory(projectId: string, slug: string): Promise<Memory> {
|
||||||
|
const note = this.bucket(projectId).get(slug);
|
||||||
|
if (!note) throw this.notFound(slug, projectId);
|
||||||
|
return structuredClone(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMemory(input: CreateMemoryInput): Promise<Memory> {
|
||||||
|
const note: Memory = {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
type: input.type,
|
||||||
|
content: input.content,
|
||||||
|
};
|
||||||
|
this.bucket(input.projectId).set(slugify(input.name), note);
|
||||||
|
return structuredClone(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMemory(
|
||||||
|
projectId: string,
|
||||||
|
slug: string,
|
||||||
|
description: string,
|
||||||
|
type: MemoryType,
|
||||||
|
content: string,
|
||||||
|
): Promise<Memory> {
|
||||||
|
const store = this.bucket(projectId);
|
||||||
|
const note = store.get(slug);
|
||||||
|
if (!note) throw this.notFound(slug, projectId);
|
||||||
|
const updated: Memory = { ...note, description, type, content };
|
||||||
|
store.set(slug, updated);
|
||||||
|
return structuredClone(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMemory(projectId: string, slug: string): Promise<void> {
|
||||||
|
const store = this.bucket(projectId);
|
||||||
|
if (!store.delete(slug)) throw this.notFound(slug, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readIndex(projectId: string): Promise<MemoryIndexEntry[]> {
|
||||||
|
return [...this.bucket(projectId).entries()].map(([slug, note]) => ({
|
||||||
|
slug,
|
||||||
|
title: note.name,
|
||||||
|
hook: note.description,
|
||||||
|
type: note.type,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveLinks(projectId: string, slug: string): Promise<MemoryLink[]> {
|
||||||
|
const store = this.bucket(projectId);
|
||||||
|
const note = store.get(slug);
|
||||||
|
if (!note) throw this.notFound(slug, projectId);
|
||||||
|
const targets = new Set<MemoryLink>();
|
||||||
|
for (const match of note.content.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
||||||
|
const target = match[1].trim();
|
||||||
|
if (target !== slug && store.has(target)) targets.add(target);
|
||||||
|
}
|
||||||
|
return [...targets];
|
||||||
|
}
|
||||||
|
|
||||||
|
async recall(
|
||||||
|
projectId: string,
|
||||||
|
_text: string,
|
||||||
|
tokenBudget: number,
|
||||||
|
): Promise<MemoryIndexEntry[]> {
|
||||||
|
const index = await this.readIndex(projectId);
|
||||||
|
const limit = Math.max(0, Math.floor(tokenBudget / 16));
|
||||||
|
return index.slice(0, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Builds the full set of mock gateways. */
|
/** Builds the full set of mock gateways. */
|
||||||
export function createMockGateways(): Gateways {
|
export function createMockGateways(): Gateways {
|
||||||
const agentGateway = new MockAgentGateway();
|
const agentGateway = new MockAgentGateway();
|
||||||
@ -1192,6 +1299,7 @@ export function createMockGateways(): Gateways {
|
|||||||
profile: new MockProfileGateway(),
|
profile: new MockProfileGateway(),
|
||||||
template: new MockTemplateGateway(agentGateway),
|
template: new MockTemplateGateway(agentGateway),
|
||||||
skill: new MockSkillGateway(agentGateway),
|
skill: new MockSkillGateway(agentGateway),
|
||||||
|
memory: new MockMemoryGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,11 +12,12 @@ import { createMockGateways, MockSystemGateway } from "./index";
|
|||||||
const gateways: Gateways = createMockGateways();
|
const gateways: Gateways = createMockGateways();
|
||||||
|
|
||||||
describe("createMockGateways", () => {
|
describe("createMockGateways", () => {
|
||||||
it("exposes all ten gateways", () => {
|
it("exposes all eleven gateways", () => {
|
||||||
expect(Object.keys(gateways).sort()).toEqual([
|
expect(Object.keys(gateways).sort()).toEqual([
|
||||||
"agent",
|
"agent",
|
||||||
"git",
|
"git",
|
||||||
"layout",
|
"layout",
|
||||||
|
"memory",
|
||||||
"profile",
|
"profile",
|
||||||
"project",
|
"project",
|
||||||
"remote",
|
"remote",
|
||||||
|
|||||||
@ -278,6 +278,41 @@ export interface SkillRef {
|
|||||||
scope: SkillScope;
|
scope: SkillScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Memory (L14) — mirror of the backend `MemoryDto` / `MemoryIndexEntryDto`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The category of a memory note (selects how it is recalled/injected). Mirrors
|
||||||
|
* the backend `type` field; identity of a note is its **slug** (kebab-case).
|
||||||
|
*/
|
||||||
|
export type MemoryType = "user" | "feedback" | "project" | "reference";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A memory note (mirror of the backend `Memory` DTO, camelCase wire format).
|
||||||
|
* `name` doubles as the human title and the source of the slug identity.
|
||||||
|
*/
|
||||||
|
export interface Memory {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: MemoryType;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry of the memory index (mirror of the backend `MemoryIndexEntry`):
|
||||||
|
* a lightweight, recall-oriented projection of a note.
|
||||||
|
*/
|
||||||
|
export interface MemoryIndexEntry {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
hook: string;
|
||||||
|
type: MemoryType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A resolved `[[wikilink]]` target — the slug of another note. */
|
||||||
|
export type MemoryLink = string;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Templates (L7) — mirror of the domain `Template` / `AgentDrift`.
|
// Templates (L7) — mirror of the domain `Template` / `AgentDrift`.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
265
frontend/src/features/memory/MemoryEditor.tsx
Normal file
265
frontend/src/features/memory/MemoryEditor.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* `MemoryEditor` — a fullscreen overlay for creating or editing memory notes
|
||||||
|
* (L14). Rendered on top of the rest of the UI (`fixed inset-0`).
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - Create-mode: name (slug source) + description + type selector + content
|
||||||
|
* - Edit-mode: slug shown read-only (identity is immutable), description + type
|
||||||
|
* + content editable, and a read-only list of resolved `[[wikilinks]]`.
|
||||||
|
*
|
||||||
|
* Pure presentation: all mutations are delegated to the callbacks supplied by
|
||||||
|
* the parent (`MemoryPanel`). Styled with `@/shared`; no inline styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { MemoryIndexEntry, MemoryLink, MemoryType } from "@/domain";
|
||||||
|
import { Button, Input, cn } from "@/shared";
|
||||||
|
|
||||||
|
const MEMORY_TYPES: MemoryType[] = ["user", "feedback", "project", "reference"];
|
||||||
|
|
||||||
|
export interface MemoryEditorProps {
|
||||||
|
/**
|
||||||
|
* When set, the editor is in edit-mode for the given index entry; otherwise it
|
||||||
|
* is in create-mode (all fields start empty). The slug is immutable in edit.
|
||||||
|
*/
|
||||||
|
entry?: MemoryIndexEntry | null;
|
||||||
|
/** Loads the full content of the entry being edited (edit-mode only). */
|
||||||
|
loadContent?: (slug: string) => Promise<string>;
|
||||||
|
/** Resolves the entry's `[[wikilinks]]` to existing target slugs (edit-mode only). */
|
||||||
|
resolveLinks?: (slug: string) => Promise<MemoryLink[]>;
|
||||||
|
/** Create-mode submit: name + description + type + content. */
|
||||||
|
onCreate: (
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
type: MemoryType,
|
||||||
|
content: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
/** Edit-mode submit: description + type + content (slug stays fixed). */
|
||||||
|
onUpdate: (
|
||||||
|
slug: string,
|
||||||
|
description: string,
|
||||||
|
type: MemoryType,
|
||||||
|
content: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
/** Called when the user cancels / closes the overlay. */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Whether a save operation is in flight. */
|
||||||
|
busy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemoryEditor({
|
||||||
|
entry,
|
||||||
|
loadContent,
|
||||||
|
resolveLinks,
|
||||||
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
onClose,
|
||||||
|
busy = false,
|
||||||
|
}: MemoryEditorProps) {
|
||||||
|
const editing = entry != null;
|
||||||
|
|
||||||
|
const [name, setName] = useState(entry?.title ?? "");
|
||||||
|
const [description, setDescription] = useState(entry?.hook ?? "");
|
||||||
|
const [type, setType] = useState<MemoryType>(entry?.type ?? "project");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [links, setLinks] = useState<MemoryLink[]>([]);
|
||||||
|
|
||||||
|
// In edit-mode, hydrate the editable content and the resolved links lazily.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entry) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (loadContent) {
|
||||||
|
const loaded = await loadContent(entry.slug);
|
||||||
|
if (!cancelled) setContent(loaded);
|
||||||
|
}
|
||||||
|
if (resolveLinks) {
|
||||||
|
const resolved = await resolveLinks(entry.slug);
|
||||||
|
if (!cancelled) setLinks(resolved);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort hydration: leave fields as-is on failure.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [entry, loadContent, resolveLinks]);
|
||||||
|
|
||||||
|
const canSave =
|
||||||
|
description.trim().length > 0 &&
|
||||||
|
content.trim().length > 0 &&
|
||||||
|
!busy &&
|
||||||
|
(editing || name.trim().length > 0);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSave) return;
|
||||||
|
if (editing) {
|
||||||
|
await onUpdate(entry.slug, description.trim(), type, content);
|
||||||
|
} else {
|
||||||
|
await onCreate(name.trim(), description.trim(), type, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectClass = cn(
|
||||||
|
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||||
|
"border border-border outline-none transition-colors",
|
||||||
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="memory editor"
|
||||||
|
>
|
||||||
|
{/* ── Header bar ── */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-content">
|
||||||
|
{editing ? `Edit note — ${entry.title}` : "New note"}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="close memory editor"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Form body ── */}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void handleSubmit(e)}
|
||||||
|
className="flex flex-1 flex-col gap-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* ── Meta fields ── */}
|
||||||
|
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-4 py-3">
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted">Slug</span>
|
||||||
|
<code className="truncate rounded-md border border-border bg-raised px-3 py-2 text-sm text-muted">
|
||||||
|
{entry.slug}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<label htmlFor="me-name" className="text-xs font-medium text-muted">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="me-name"
|
||||||
|
aria-label="memory name"
|
||||||
|
placeholder="My note"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className="min-w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<label htmlFor="me-type" className="text-xs font-medium text-muted">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="me-type"
|
||||||
|
aria-label="memory type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as MemoryType)}
|
||||||
|
disabled={busy}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{MEMORY_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Description ── */}
|
||||||
|
<div className="flex shrink-0 flex-col gap-1 border-b border-border px-4 py-3">
|
||||||
|
<label htmlFor="me-description" className="text-xs font-medium text-muted">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="me-description"
|
||||||
|
aria-label="memory description"
|
||||||
|
placeholder="One-line hook"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden px-4 py-3">
|
||||||
|
<label htmlFor="me-content" className="mb-1 text-xs font-medium text-muted">
|
||||||
|
Content
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="me-content"
|
||||||
|
aria-label="memory content"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 w-full rounded-md bg-raised px-3 py-2 text-sm text-content font-mono",
|
||||||
|
"border border-border outline-none transition-colors resize-none",
|
||||||
|
"focus:border-primary placeholder:text-faint",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
placeholder="# Note Use [[other-slug]] to link…"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Resolved links (edit-mode, read-only) ── */}
|
||||||
|
{editing && (
|
||||||
|
<div className="mt-3 shrink-0">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Links
|
||||||
|
</h4>
|
||||||
|
{links.length === 0 ? (
|
||||||
|
<p className="mt-1 text-sm text-muted">No resolved links.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{links.map((slug) => (
|
||||||
|
<li
|
||||||
|
key={slug}
|
||||||
|
className="rounded-md border border-border bg-raised px-2 py-0.5 text-xs text-muted"
|
||||||
|
>
|
||||||
|
{slug}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footer ── */}
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
aria-label="Save note"
|
||||||
|
disabled={!canSave}
|
||||||
|
loading={busy}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
frontend/src/features/memory/MemoryPanel.tsx
Normal file
169
frontend/src/features/memory/MemoryPanel.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* `MemoryPanel` — feature component for project memory management (L14).
|
||||||
|
*
|
||||||
|
* Pure presentation: all behaviour comes from {@link useMemory}. Styled with
|
||||||
|
* `@/shared` design system tokens; no inline styles.
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - The recall-oriented memory index (title + hook + type badge)
|
||||||
|
* - A "New note" button that opens {@link MemoryEditor} in create-mode
|
||||||
|
* - Per-entry "Edit" (opens the editor in edit-mode) and "Delete"
|
||||||
|
*
|
||||||
|
* The editor needs the full note content (the index only carries the hook), so
|
||||||
|
* it loads it lazily through the memory gateway's `getMemory`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import type { MemoryIndexEntry } from "@/domain";
|
||||||
|
import { Button, Panel, Spinner } from "@/shared";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
import { MemoryEditor } from "./MemoryEditor";
|
||||||
|
import { useMemory } from "./useMemory";
|
||||||
|
|
||||||
|
export interface MemoryPanelProps {
|
||||||
|
/** The project whose memory notes to manage. */
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editor open-state: "create" or edit-mode for a specific index entry. */
|
||||||
|
type EditorState = { mode: "create" } | { mode: "edit"; entry: MemoryIndexEntry };
|
||||||
|
|
||||||
|
export function MemoryPanel({ projectId }: MemoryPanelProps) {
|
||||||
|
const vm = useMemory(projectId);
|
||||||
|
const { memory } = useGateways();
|
||||||
|
|
||||||
|
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
||||||
|
const [editorBusy, setEditorBusy] = useState(false);
|
||||||
|
|
||||||
|
const loadContent = useCallback(
|
||||||
|
async (slug: string) => (await memory.getMemory(projectId, slug)).content,
|
||||||
|
[memory, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleCreate(
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
type: MemoryIndexEntry["type"],
|
||||||
|
content: string,
|
||||||
|
) {
|
||||||
|
setEditorBusy(true);
|
||||||
|
try {
|
||||||
|
await vm.createMemory({ name, description, type, content });
|
||||||
|
setEditorState(null);
|
||||||
|
} finally {
|
||||||
|
setEditorBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdate(
|
||||||
|
slug: string,
|
||||||
|
description: string,
|
||||||
|
type: MemoryIndexEntry["type"],
|
||||||
|
content: string,
|
||||||
|
) {
|
||||||
|
setEditorBusy(true);
|
||||||
|
try {
|
||||||
|
await vm.updateMemory(slug, description, type, content);
|
||||||
|
setEditorState(null);
|
||||||
|
} finally {
|
||||||
|
setEditorBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Fullscreen memory editor overlay ── */}
|
||||||
|
{editorState !== null && (
|
||||||
|
<MemoryEditor
|
||||||
|
entry={editorState.mode === "edit" ? editorState.entry : null}
|
||||||
|
loadContent={loadContent}
|
||||||
|
resolveLinks={vm.resolveLinks}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onClose={() => setEditorState(null)}
|
||||||
|
busy={editorBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Panel title="Memory" className="flex flex-col gap-0">
|
||||||
|
{vm.error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||||
|
>
|
||||||
|
{vm.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Create button ── */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Notes
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{vm.busy && <Spinner size={14} />}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
aria-label="create memory"
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => setEditorState({ mode: "create" })}
|
||||||
|
>
|
||||||
|
New note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Index list ── */}
|
||||||
|
<div className="p-4">
|
||||||
|
{vm.entries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No memory notes yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{vm.entries.map((e) => (
|
||||||
|
<li
|
||||||
|
key={e.slug}
|
||||||
|
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 truncate font-medium text-content">
|
||||||
|
{e.title}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 rounded border border-border bg-raised px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted">
|
||||||
|
{e.type}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted">{e.hook}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`edit memory ${e.title}`}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => setEditorState({ mode: "edit", entry: e })}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`delete memory ${e.title}`}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void vm.deleteMemory(e.slug)}
|
||||||
|
className="text-danger hover:text-danger"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/features/memory/index.ts
Normal file
7
frontend/src/features/memory/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/** Public surface of the memory feature (L14). */
|
||||||
|
export { MemoryPanel } from "./MemoryPanel";
|
||||||
|
export type { MemoryPanelProps } from "./MemoryPanel";
|
||||||
|
export { MemoryEditor } from "./MemoryEditor";
|
||||||
|
export type { MemoryEditorProps } from "./MemoryEditor";
|
||||||
|
export { useMemory } from "./useMemory";
|
||||||
|
export type { MemoryViewModel } from "./useMemory";
|
||||||
334
frontend/src/features/memory/memory.test.tsx
Normal file
334
frontend/src/features/memory/memory.test.tsx
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* L14 — memory feature wired to the stateful `MockMemoryGateway` via the real
|
||||||
|
* `DIProvider` (same harness as `skills.test.tsx`).
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - index rendering (title + hook + type badge) on mount
|
||||||
|
* - create a note (New → fill name/description/type/content → Save) → appears in
|
||||||
|
* the list, and `createMemory` receives the right payload
|
||||||
|
* - edit a note (slug read-only; description/type/content editable) → persisted
|
||||||
|
* - delete a note → removed from the list
|
||||||
|
* - `[[slug]]` link resolution (existing target listed; broken link ignored)
|
||||||
|
* - basic error state (gateway rejects → alert message shown)
|
||||||
|
*
|
||||||
|
* Also includes `MockMemoryGateway` unit tests (CRUD + index derivation +
|
||||||
|
* `resolveLinks` ignoring broken links).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { MockMemoryGateway } from "@/adapters/mock";
|
||||||
|
import type { GatewayError } from "@/domain";
|
||||||
|
import type { Gateways } from "@/ports";
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import { MemoryPanel } from "./MemoryPanel";
|
||||||
|
|
||||||
|
const PROJECT_ID = "proj-memory-test";
|
||||||
|
|
||||||
|
function renderMemoryPanel(memory?: MockMemoryGateway) {
|
||||||
|
const mem = memory ?? new MockMemoryGateway();
|
||||||
|
const gateways = { memory: mem } as unknown as Gateways;
|
||||||
|
return {
|
||||||
|
memory: mem,
|
||||||
|
...render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<MemoryPanel projectId={PROJECT_ID} />
|
||||||
|
</DIProvider>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForMemoryIdle() {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
screen.getByRole("button", { name: "create memory" }) as HTMLButtonElement
|
||||||
|
).disabled,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the editor in create-mode, fills it, and saves. */
|
||||||
|
async function createMemory(
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
content: string,
|
||||||
|
type: "user" | "feedback" | "project" | "reference" = "project",
|
||||||
|
) {
|
||||||
|
await waitForMemoryIdle();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "create memory" }));
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("memory name")).toBeTruthy());
|
||||||
|
fireEvent.change(screen.getByLabelText("memory name"), {
|
||||||
|
target: { value: name },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("memory description"), {
|
||||||
|
target: { value: description },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("memory type"), {
|
||||||
|
target: { value: type },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("memory content"), {
|
||||||
|
target: { value: content },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save note" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MemoryPanel feature tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("MemoryPanel (with MockMemoryGateway)", () => {
|
||||||
|
it("shows the empty state initially", async () => {
|
||||||
|
renderMemoryPanel();
|
||||||
|
await waitForMemoryIdle();
|
||||||
|
expect(screen.getByText("No memory notes yet.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the index (title + hook + type) on mount", async () => {
|
||||||
|
const memory = new MockMemoryGateway();
|
||||||
|
await memory.createMemory({
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
name: "Build AppImage",
|
||||||
|
description: "use APPIMAGE_EXTRACT_AND_RUN=1",
|
||||||
|
type: "reference",
|
||||||
|
content: "# notes",
|
||||||
|
});
|
||||||
|
renderMemoryPanel(memory);
|
||||||
|
|
||||||
|
await screen.findByText("Build AppImage");
|
||||||
|
expect(screen.getByText("use APPIMAGE_EXTRACT_AND_RUN=1")).toBeTruthy();
|
||||||
|
// Type badge.
|
||||||
|
expect(screen.getByText("reference")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creating a note adds it to the list with the right payload", async () => {
|
||||||
|
const { memory } = renderMemoryPanel();
|
||||||
|
const spy = vi.spyOn(memory, "createMemory");
|
||||||
|
|
||||||
|
await createMemory("My Note", "a one-line hook", "# body", "user");
|
||||||
|
await screen.findByText("My Note");
|
||||||
|
|
||||||
|
// Payload received by the gateway.
|
||||||
|
expect(spy).toHaveBeenCalledWith({
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
name: "My Note",
|
||||||
|
description: "a one-line hook",
|
||||||
|
type: "user",
|
||||||
|
content: "# body",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persisted in the gateway under the derived slug.
|
||||||
|
const note = await memory.getMemory(PROJECT_ID, "my-note");
|
||||||
|
expect(note.name).toBe("My Note");
|
||||||
|
expect(note.description).toBe("a one-line hook");
|
||||||
|
expect(note.type).toBe("user");
|
||||||
|
expect(note.content).toBe("# body");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Save is disabled until description and content are filled", async () => {
|
||||||
|
renderMemoryPanel();
|
||||||
|
await waitForMemoryIdle();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "create memory" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("button", { name: "Save note" })).toBeTruthy(),
|
||||||
|
);
|
||||||
|
const save = screen.getByRole("button", {
|
||||||
|
name: "Save note",
|
||||||
|
}) as HTMLButtonElement;
|
||||||
|
expect(save.disabled).toBe(true);
|
||||||
|
|
||||||
|
// Filling everything enables Save.
|
||||||
|
fireEvent.change(screen.getByLabelText("memory name"), {
|
||||||
|
target: { value: "X" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("memory description"), {
|
||||||
|
target: { value: "h" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("memory content"), {
|
||||||
|
target: { value: "c" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", { name: "Save note" }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("editing a note keeps the slug read-only and persists the changes", async () => {
|
||||||
|
const { memory } = renderMemoryPanel();
|
||||||
|
await createMemory("Editable Note", "old hook", "old body", "project");
|
||||||
|
await screen.findByText("Editable Note");
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "edit memory Editable Note" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Slug shown read-only — there's no editable "memory name" input in edit-mode.
|
||||||
|
await waitFor(() => expect(screen.getByText("editable-note")).toBeTruthy());
|
||||||
|
expect(screen.queryByLabelText("memory name")).toBeNull();
|
||||||
|
|
||||||
|
// Description / type / content are editable.
|
||||||
|
fireEvent.change(screen.getByLabelText("memory description"), {
|
||||||
|
target: { value: "new hook" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("memory type"), {
|
||||||
|
target: { value: "feedback" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("memory content"), {
|
||||||
|
target: { value: "new body" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save note" }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const note = await memory.getMemory(PROJECT_ID, "editable-note");
|
||||||
|
expect(note.description).toBe("new hook");
|
||||||
|
expect(note.type).toBe("feedback");
|
||||||
|
expect(note.content).toBe("new body");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleting a note removes it from the list", async () => {
|
||||||
|
renderMemoryPanel();
|
||||||
|
await createMemory("To Delete", "hook", "body", "project");
|
||||||
|
await screen.findByText("To Delete");
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "delete memory To Delete" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.queryByText("To Delete")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves [[slug]] links: existing target listed, broken link ignored", async () => {
|
||||||
|
const memory = new MockMemoryGateway();
|
||||||
|
// Target note that the linker will reference.
|
||||||
|
await memory.createMemory({
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
name: "Target Note",
|
||||||
|
description: "the target",
|
||||||
|
type: "project",
|
||||||
|
content: "# target",
|
||||||
|
});
|
||||||
|
// Source note links to the existing target and to a non-existent slug.
|
||||||
|
await memory.createMemory({
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
name: "Source Note",
|
||||||
|
description: "the source",
|
||||||
|
type: "project",
|
||||||
|
content: "See [[target-note]] and [[ghost-slug]].",
|
||||||
|
});
|
||||||
|
renderMemoryPanel(memory);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "edit memory Source Note" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The resolved link to the existing note appears; the broken one does not.
|
||||||
|
await waitFor(() => expect(screen.getByText("target-note")).toBeTruthy());
|
||||||
|
expect(screen.queryByText("ghost-slug")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an error message when the gateway rejects", async () => {
|
||||||
|
const memory = new MockMemoryGateway();
|
||||||
|
const err: GatewayError = { code: "INTERNAL", message: "boom from gateway" };
|
||||||
|
vi.spyOn(memory, "readIndex").mockRejectedValue(err);
|
||||||
|
|
||||||
|
renderMemoryPanel(memory);
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toMatch(/boom from gateway/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MockMemoryGateway unit tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("MockMemoryGateway (unit)", () => {
|
||||||
|
it("CRUD: create → get → update → delete by immutable slug", async () => {
|
||||||
|
const gw = new MockMemoryGateway();
|
||||||
|
await gw.createMemory({
|
||||||
|
projectId: "p",
|
||||||
|
name: "Hello World",
|
||||||
|
description: "d",
|
||||||
|
type: "project",
|
||||||
|
content: "c",
|
||||||
|
});
|
||||||
|
|
||||||
|
const got = await gw.getMemory("p", "hello-world");
|
||||||
|
expect(got.name).toBe("Hello World");
|
||||||
|
|
||||||
|
await gw.updateMemory("p", "hello-world", "d2", "user", "c2");
|
||||||
|
const updated = await gw.getMemory("p", "hello-world");
|
||||||
|
expect(updated.description).toBe("d2");
|
||||||
|
expect(updated.type).toBe("user");
|
||||||
|
expect(updated.content).toBe("c2");
|
||||||
|
// Name (and therefore slug) is immutable.
|
||||||
|
expect(updated.name).toBe("Hello World");
|
||||||
|
|
||||||
|
await gw.deleteMemory("p", "hello-world");
|
||||||
|
await expect(gw.getMemory("p", "hello-world")).rejects.toMatchObject({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getMemory / updateMemory / deleteMemory throw NOT_FOUND for unknown slug", async () => {
|
||||||
|
const gw = new MockMemoryGateway();
|
||||||
|
await expect(gw.getMemory("p", "ghost")).rejects.toMatchObject({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
gw.updateMemory("p", "ghost", "d", "project", "c"),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
await expect(gw.deleteMemory("p", "ghost")).rejects.toMatchObject({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readIndex derives the recall index from the notes", async () => {
|
||||||
|
const gw = new MockMemoryGateway();
|
||||||
|
await gw.createMemory({
|
||||||
|
projectId: "p",
|
||||||
|
name: "Indexed Note",
|
||||||
|
description: "hooky",
|
||||||
|
type: "reference",
|
||||||
|
content: "x",
|
||||||
|
});
|
||||||
|
const index = await gw.readIndex("p");
|
||||||
|
expect(index).toEqual([
|
||||||
|
{ slug: "indexed-note", title: "Indexed Note", hook: "hooky", type: "reference" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notes are partitioned by project", async () => {
|
||||||
|
const gw = new MockMemoryGateway();
|
||||||
|
await gw.createMemory({
|
||||||
|
projectId: "p1",
|
||||||
|
name: "A",
|
||||||
|
description: "d",
|
||||||
|
type: "project",
|
||||||
|
content: "c",
|
||||||
|
});
|
||||||
|
expect(await gw.readIndex("p1")).toHaveLength(1);
|
||||||
|
expect(await gw.readIndex("p2")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveLinks keeps existing targets and ignores broken / self links", async () => {
|
||||||
|
const gw = new MockMemoryGateway();
|
||||||
|
await gw.createMemory({
|
||||||
|
projectId: "p",
|
||||||
|
name: "Alpha",
|
||||||
|
description: "d",
|
||||||
|
type: "project",
|
||||||
|
content: "c",
|
||||||
|
});
|
||||||
|
await gw.createMemory({
|
||||||
|
projectId: "p",
|
||||||
|
name: "Beta",
|
||||||
|
description: "d",
|
||||||
|
type: "project",
|
||||||
|
content: "Links [[alpha]], [[ghost]] and self [[beta]].",
|
||||||
|
});
|
||||||
|
const links = await gw.resolveLinks("p", "beta");
|
||||||
|
expect(links).toEqual(["alpha"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
137
frontend/src/features/memory/useMemory.ts
Normal file
137
frontend/src/features/memory/useMemory.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* `useMemory` — view-model hook for the memory feature (L14).
|
||||||
|
*
|
||||||
|
* Owns the recall-oriented memory index of one project and the CRUD actions.
|
||||||
|
* Consumes {@link MemoryGateway} exclusively; never touches `invoke()` or
|
||||||
|
* `@tauri-apps/api`, keeping the component layer testable with mock gateways
|
||||||
|
* (ARCHITECTURE §1.3). Note identity is the immutable **slug**.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { GatewayError, MemoryIndexEntry, MemoryLink, MemoryType } from "@/domain";
|
||||||
|
import type { CreateMemoryInput } from "@/ports";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
|
/** What the memory UI needs from this hook. */
|
||||||
|
export interface MemoryViewModel {
|
||||||
|
/** The recall-oriented memory index entries. */
|
||||||
|
entries: MemoryIndexEntry[];
|
||||||
|
/** Last error message, or `null`. */
|
||||||
|
error: string | null;
|
||||||
|
/** Whether a request is in flight. */
|
||||||
|
busy: boolean;
|
||||||
|
/** Reloads the index. */
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
/** Creates a new note and refreshes the index. */
|
||||||
|
createMemory: (input: Omit<CreateMemoryInput, "projectId">) => Promise<void>;
|
||||||
|
/** Updates a note's mutable fields (slug is immutable) and refreshes. */
|
||||||
|
updateMemory: (
|
||||||
|
slug: string,
|
||||||
|
description: string,
|
||||||
|
type: MemoryType,
|
||||||
|
content: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
/** Deletes a note by slug and refreshes. */
|
||||||
|
deleteMemory: (slug: string) => Promise<void>;
|
||||||
|
/** Resolves a note's `[[wikilinks]]` to existing target slugs. */
|
||||||
|
resolveLinks: (slug: string) => Promise<MemoryLink[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMemory(projectId: string): MemoryViewModel {
|
||||||
|
const { memory } = useGateways();
|
||||||
|
|
||||||
|
const [entries, setEntries] = useState<MemoryIndexEntry[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setEntries(await memory.readIndex(projectId));
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [memory, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const createMemory = useCallback(
|
||||||
|
async (input: Omit<CreateMemoryInput, "projectId">) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await memory.createMemory({ ...input, projectId });
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[memory, projectId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateMemory = useCallback(
|
||||||
|
async (
|
||||||
|
slug: string,
|
||||||
|
description: string,
|
||||||
|
type: MemoryType,
|
||||||
|
content: string,
|
||||||
|
) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await memory.updateMemory(projectId, slug, description, type, content);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[memory, projectId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteMemory = useCallback(
|
||||||
|
async (slug: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await memory.deleteMemory(projectId, slug);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[memory, projectId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolveLinks = useCallback(
|
||||||
|
(slug: string) => memory.resolveLinks(projectId, slug),
|
||||||
|
[memory, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
error,
|
||||||
|
busy,
|
||||||
|
refresh,
|
||||||
|
createMemory,
|
||||||
|
updateMemory,
|
||||||
|
deleteMemory,
|
||||||
|
resolveLinks,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -35,18 +35,26 @@ import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
|||||||
import { AgentsPanel } from "@/features/agents";
|
import { AgentsPanel } from "@/features/agents";
|
||||||
import { TemplatesPanel } from "@/features/templates";
|
import { TemplatesPanel } from "@/features/templates";
|
||||||
import { SkillsPanel } from "@/features/skills";
|
import { SkillsPanel } from "@/features/skills";
|
||||||
|
import { MemoryPanel } from "@/features/memory";
|
||||||
import { GitPanel, GitGraphView } from "@/features/git";
|
import { GitPanel, GitGraphView } from "@/features/git";
|
||||||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useProjects } from "./useProjects";
|
import { useProjects } from "./useProjects";
|
||||||
|
|
||||||
type SidebarTab = "projects" | "agents" | "templates" | "skills" | "git";
|
type SidebarTab =
|
||||||
|
| "projects"
|
||||||
|
| "agents"
|
||||||
|
| "templates"
|
||||||
|
| "skills"
|
||||||
|
| "memory"
|
||||||
|
| "git";
|
||||||
|
|
||||||
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||||
{ id: "projects", label: "Projects" },
|
{ id: "projects", label: "Projects" },
|
||||||
{ id: "agents", label: "Agents" },
|
{ id: "agents", label: "Agents" },
|
||||||
{ id: "templates", label: "Templates" },
|
{ id: "templates", label: "Templates" },
|
||||||
{ id: "skills", label: "Skills" },
|
{ id: "skills", label: "Skills" },
|
||||||
|
{ id: "memory", label: "Memory" },
|
||||||
{ id: "git", label: "Git" },
|
{ id: "git", label: "Git" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -269,6 +277,14 @@ export function ProjectsView() {
|
|||||||
<p className="text-sm text-muted">Open a project to manage skills.</p>
|
<p className="text-sm text-muted">Open a project to manage skills.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Memory panel */}
|
||||||
|
{sidebarTab === "memory" && active && (
|
||||||
|
<MemoryPanel projectId={active.id} />
|
||||||
|
)}
|
||||||
|
{sidebarTab === "memory" && !active && (
|
||||||
|
<p className="text-sm text-muted">Open a project to manage memory.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Git panel */}
|
{/* Git panel */}
|
||||||
{sidebarTab === "git" && active && (
|
{sidebarTab === "git" && active && (
|
||||||
<GitPanel projectId={active.id} />
|
<GitPanel projectId={active.id} />
|
||||||
|
|||||||
@ -23,6 +23,10 @@ import type {
|
|||||||
LayoutList,
|
LayoutList,
|
||||||
LayoutOperation,
|
LayoutOperation,
|
||||||
LayoutTree,
|
LayoutTree,
|
||||||
|
Memory,
|
||||||
|
MemoryIndexEntry,
|
||||||
|
MemoryLink,
|
||||||
|
MemoryType,
|
||||||
Project,
|
Project,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
Skill,
|
Skill,
|
||||||
@ -365,6 +369,52 @@ export interface SkillGateway {
|
|||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Input for {@link MemoryGateway.createMemory}. */
|
||||||
|
export interface CreateMemoryInput {
|
||||||
|
/** Owning project (resolved to its `.ideai/` memory store). */
|
||||||
|
projectId: string;
|
||||||
|
/** Human title; also the source of the note's slug identity. */
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: MemoryType;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memory (L14): CRUD over project memory notes + the recall-oriented index and
|
||||||
|
* `[[wikilink]]` resolution. Identity is the **slug** (kebab-case), not a UUID;
|
||||||
|
* a note's slug is immutable, so `updateMemory` never changes it. Mirrors the
|
||||||
|
* seven backend memory commands (+ optional `recall`).
|
||||||
|
*/
|
||||||
|
export interface MemoryGateway {
|
||||||
|
/** Lists every memory note of a project (full payloads). */
|
||||||
|
listMemories(projectId: string): Promise<Memory[]>;
|
||||||
|
/** Reads a single note by slug. */
|
||||||
|
getMemory(projectId: string, slug: string): Promise<Memory>;
|
||||||
|
/** Creates a new note; returns the created note. */
|
||||||
|
createMemory(input: CreateMemoryInput): Promise<Memory>;
|
||||||
|
/** Updates a note's mutable fields (slug is immutable); returns the updated note. */
|
||||||
|
updateMemory(
|
||||||
|
projectId: string,
|
||||||
|
slug: string,
|
||||||
|
description: string,
|
||||||
|
type: MemoryType,
|
||||||
|
content: string,
|
||||||
|
): Promise<Memory>;
|
||||||
|
/** Deletes a note by slug. */
|
||||||
|
deleteMemory(projectId: string, slug: string): Promise<void>;
|
||||||
|
/** Reads the recall-oriented memory index. */
|
||||||
|
readIndex(projectId: string): Promise<MemoryIndexEntry[]>;
|
||||||
|
/** Resolves a note's `[[wikilinks]]` to the slugs of existing target notes. */
|
||||||
|
resolveLinks(projectId: string, slug: string): Promise<MemoryLink[]>;
|
||||||
|
/** Best-effort recall: the index entries most relevant to `text`, within a token budget. */
|
||||||
|
recall(
|
||||||
|
projectId: string,
|
||||||
|
text: string,
|
||||||
|
tokenBudget: number,
|
||||||
|
): Promise<MemoryIndexEntry[]>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI profiles & first-run (L5). Drives the first-run wizard and profile
|
* AI profiles & first-run (L5). Drives the first-run wizard and profile
|
||||||
* management: the pre-filled reference catalogue, detection of installed CLIs,
|
* management: the pre-filled reference catalogue, detection of installed CLIs,
|
||||||
@ -402,4 +452,5 @@ export interface Gateways {
|
|||||||
profile: ProfileGateway;
|
profile: ProfileGateway;
|
||||||
template: TemplateGateway;
|
template: TemplateGateway;
|
||||||
skill: SkillGateway;
|
skill: SkillGateway;
|
||||||
|
memory: MemoryGateway;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user