From f3bc3f20d8af437530587ab1fcf9a081782513ab Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 8 Jun 2026 13:28:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(memory):=20cl=C3=B4ture=20du=20sujet=20m?= =?UTF-8?q?=C3=A9moire=20=E2=80=94=20bascule=20adaptative=20live=20+=20pan?= =?UTF-8?q?neau=20UI=20(=C2=A714.5.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE.md | 21 ++ Cargo.lock | 1 + crates/app-tauri/Cargo.toml | 1 + crates/app-tauri/src/state.rs | 200 ++++++++++- frontend/src/adapters/index.ts | 3 + frontend/src/adapters/memory.ts | 71 ++++ frontend/src/adapters/mock/index.ts | 108 ++++++ frontend/src/adapters/mock/mock.test.ts | 3 +- frontend/src/domain/index.ts | 35 ++ frontend/src/features/memory/MemoryEditor.tsx | 265 ++++++++++++++ frontend/src/features/memory/MemoryPanel.tsx | 169 +++++++++ frontend/src/features/memory/index.ts | 7 + frontend/src/features/memory/memory.test.tsx | 334 ++++++++++++++++++ frontend/src/features/memory/useMemory.ts | 137 +++++++ .../src/features/projects/ProjectsView.tsx | 18 +- frontend/src/ports/index.ts | 51 +++ 16 files changed, 1414 insertions(+), 10 deletions(-) create mode 100644 frontend/src/adapters/memory.ts create mode 100644 frontend/src/features/memory/MemoryEditor.tsx create mode 100644 frontend/src/features/memory/MemoryPanel.tsx create mode 100644 frontend/src/features/memory/index.ts create mode 100644 frontend/src/features/memory/memory.test.tsx create mode 100644 frontend/src/features/memory/useMemory.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3851970..a3ca9ee 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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`. +##### 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` via un **helper de composition** `build_memory_recall(fs, memory_store_port, embedder_profile) -> Arc` 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` 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) 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. diff --git a/Cargo.lock b/Cargo.lock index ebd62ef..3b2e21b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,7 @@ name = "app-tauri" version = "0.1.0" dependencies = [ "application", + "async-trait", "domain", "infrastructure", "serde", diff --git a/crates/app-tauri/Cargo.toml b/crates/app-tauri/Cargo.toml index 3ad0de3..0615c00 100644 --- a/crates/app-tauri/Cargo.toml +++ b/crates/app-tauri/Cargo.toml @@ -33,3 +33,4 @@ uuid = { workspace = true } [dev-dependencies] uuid = { workspace = true } +async-trait = { workspace = true } diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index e3fd0b9..4d6203e 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -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; - let memory_recall_port = - Arc::new(NaiveMemoryRecall::new(Arc::clone(&memory_store_port))) as Arc; + // 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`. 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, + store: Arc, + profile: &EmbedderProfile, +) -> Arc { + let naive = Arc::new(NaiveMemoryRecall::new(Arc::clone(&store))) as Arc; + match embedder_from_profile(profile) { + None => naive, + Some(embedder) => { + let embedder: Arc = Arc::from(embedder); + let vector: Arc = 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>>, + } + + #[async_trait] + impl FileSystem for MemFs { + async fn read(&self, path: &RemotePath) -> Result, 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 { + 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, 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, Arc) { + let fs: Arc = 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 = 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}" + ); + } + } +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index bcd8dc1..25d792f 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -20,6 +20,7 @@ import { TauriLayoutGateway } from "./layout"; import { TauriProfileGateway } from "./profile"; import { TauriTemplateGateway } from "./template"; import { TauriSkillGateway } from "./skill"; +import { TauriMemoryGateway } from "./memory"; import { TauriGitGateway } from "./git"; function notImplemented(what: string): never { @@ -49,6 +50,7 @@ export function createTauriGateways(): Gateways { profile: new TauriProfileGateway(), template: new TauriTemplateGateway(), skill: new TauriSkillGateway(), + memory: new TauriMemoryGateway(), }; } @@ -61,5 +63,6 @@ export { TauriProfileGateway, TauriTemplateGateway, TauriSkillGateway, + TauriMemoryGateway, TauriGitGateway, }; diff --git a/frontend/src/adapters/memory.ts b/frontend/src/adapters/memory.ts new file mode 100644 index 0000000..42f0ec0 --- /dev/null +++ b/frontend/src/adapters/memory.ts @@ -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 { + return invoke("list_memories", { projectId }); + } + + getMemory(projectId: string, slug: string): Promise { + return invoke("get_memory", { projectId, slug }); + } + + createMemory(input: CreateMemoryInput): Promise { + return invoke("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 { + return invoke("update_memory", { + request: { projectId, slug, description, type, content }, + }); + } + + async deleteMemory(projectId: string, slug: string): Promise { + await invoke("delete_memory", { projectId, slug }); + } + + readIndex(projectId: string): Promise { + return invoke("read_memory_index", { projectId }); + } + + resolveLinks(projectId: string, slug: string): Promise { + return invoke("resolve_memory_links", { projectId, slug }); + } + + recall( + projectId: string, + text: string, + tokenBudget: number, + ): Promise { + return invoke("recall_memory", { + request: { projectId, text, tokenBudget }, + }); + } +} diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index f7bb97b..09a0e8e 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -21,6 +21,10 @@ import type { LayoutList, LayoutOperation, LayoutTree, + Memory, + MemoryIndexEntry, + MemoryLink, + MemoryType, Project, ProfileAvailability, Skill, @@ -32,9 +36,11 @@ import type { AgentGateway, ConversationDetails, CreateAgentInput, + CreateMemoryInput, CreateSkillInput, CreateTemplateInput, Gateways, + MemoryGateway, GitGateway, LayoutGateway, 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>(); + + private bucket(projectId: string): Map { + 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 { + return structuredClone([...this.bucket(projectId).values()]); + } + + async getMemory(projectId: string, slug: string): Promise { + const note = this.bucket(projectId).get(slug); + if (!note) throw this.notFound(slug, projectId); + return structuredClone(note); + } + + async createMemory(input: CreateMemoryInput): Promise { + 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 { + 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 { + const store = this.bucket(projectId); + if (!store.delete(slug)) throw this.notFound(slug, projectId); + } + + async readIndex(projectId: string): Promise { + 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 { + const store = this.bucket(projectId); + const note = store.get(slug); + if (!note) throw this.notFound(slug, projectId); + const targets = new Set(); + 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 { + 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. */ export function createMockGateways(): Gateways { const agentGateway = new MockAgentGateway(); @@ -1192,6 +1299,7 @@ export function createMockGateways(): Gateways { profile: new MockProfileGateway(), template: new MockTemplateGateway(agentGateway), skill: new MockSkillGateway(agentGateway), + memory: new MockMemoryGateway(), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 6b75dd5..9186162 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -12,11 +12,12 @@ import { createMockGateways, MockSystemGateway } from "./index"; const gateways: Gateways = createMockGateways(); describe("createMockGateways", () => { - it("exposes all ten gateways", () => { + it("exposes all eleven gateways", () => { expect(Object.keys(gateways).sort()).toEqual([ "agent", "git", "layout", + "memory", "profile", "project", "remote", diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index e147cb0..31fe1e8 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -278,6 +278,41 @@ export interface SkillRef { 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`. // --------------------------------------------------------------------------- diff --git a/frontend/src/features/memory/MemoryEditor.tsx b/frontend/src/features/memory/MemoryEditor.tsx new file mode 100644 index 0000000..77d13a8 --- /dev/null +++ b/frontend/src/features/memory/MemoryEditor.tsx @@ -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; + /** Resolves the entry's `[[wikilinks]]` to existing target slugs (edit-mode only). */ + resolveLinks?: (slug: string) => Promise; + /** Create-mode submit: name + description + type + content. */ + onCreate: ( + name: string, + description: string, + type: MemoryType, + content: string, + ) => Promise; + /** Edit-mode submit: description + type + content (slug stays fixed). */ + onUpdate: ( + slug: string, + description: string, + type: MemoryType, + content: string, + ) => Promise; + /** 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(entry?.type ?? "project"); + const [content, setContent] = useState(""); + const [links, setLinks] = useState([]); + + // 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 ( +
+ {/* ── Header bar ── */} +
+

+ {editing ? `Edit note — ${entry.title}` : "New note"} +

+ +
+ + {/* ── Form body ── */} +
void handleSubmit(e)} + className="flex flex-1 flex-col gap-0 overflow-hidden" + > + {/* ── Meta fields ── */} +
+ {editing ? ( +
+ Slug + + {entry.slug} + +
+ ) : ( +
+ + setName(e.target.value)} + disabled={busy} + className="min-w-48" + /> +
+ )} + +
+ + +
+
+ + {/* ── Description ── */} +
+ + setDescription(e.target.value)} + disabled={busy} + /> +
+ + {/* ── Content ── */} +
+ +