feat(memory): clôture du sujet mémoire — bascule adaptative live + panneau UI (§14.5.5)

Pièce 1 (backend) : state.rs câble AdaptiveMemoryRecall via build_memory_recall,
piloté par le profil embedder chargé depuis embedder.json (fallback none). Défaut
none ⇒ NaiveMemoryRecall nu (comportement inchangé, StubEmbedder jamais touché,
zéro dépendance lourde). Instance de recall partagée (RecallMemory + LaunchAgent).
Chargement du profil isolé sur un runtime dédié (évite le block_on imbriqué).

Pièce 2 (frontend) : feature mémoire complète en miroir de skills —
MemoryGateway (port) + TauriMemoryGateway + MockMemoryGateway, types domaine
Memory/MemoryIndexEntry/MemoryType, MemoryPanel/MemoryEditor/useMemory, onglet
sidebar « Memory » dans ProjectsView. CRUD par slug, liens [[slug]] résolus.

Le sujet mémoire est clos : CRUD .md + index + rappel adaptatif + injection à
l'activation des agents + UI de gestion. Embedder concret ONNX/HTTP reste un
follow-up (défaut none = pleinement fonctionnel sans dépendance).

Tests: backend 57 binaires verts, frontend 285 tests verts, typecheck OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:28:27 +02:00
parent 2435857cbf
commit f3bc3f20d8
16 changed files with 1414 additions and 10 deletions

View File

@ -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,
};

View 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 },
});
}
}

View File

@ -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<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. */
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(),
};
}

View File

@ -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",

View File

@ -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`.
// ---------------------------------------------------------------------------

View 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&#10;&#10;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>
);
}

View 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>
</>
);
}

View 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";

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

View 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,
};
}

View File

@ -35,18 +35,26 @@ import { LayoutGrid, LayoutTabs } from "@/features/layout";
import { AgentsPanel } from "@/features/agents";
import { TemplatesPanel } from "@/features/templates";
import { SkillsPanel } from "@/features/skills";
import { MemoryPanel } from "@/features/memory";
import { GitPanel, GitGraphView } from "@/features/git";
import { Button, Input, Panel, Tabs, cn } from "@/shared";
import { useGateways } from "@/app/di";
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 }[] = [
{ id: "projects", label: "Projects" },
{ id: "agents", label: "Agents" },
{ id: "templates", label: "Templates" },
{ id: "skills", label: "Skills" },
{ id: "memory", label: "Memory" },
{ id: "git", label: "Git" },
];
@ -269,6 +277,14 @@ export function ProjectsView() {
<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 */}
{sidebarTab === "git" && active && (
<GitPanel projectId={active.id} />

View File

@ -23,6 +23,10 @@ import type {
LayoutList,
LayoutOperation,
LayoutTree,
Memory,
MemoryIndexEntry,
MemoryLink,
MemoryType,
Project,
ProfileAvailability,
Skill,
@ -365,6 +369,52 @@ export interface SkillGateway {
): 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
* management: the pre-filled reference catalogue, detection of installed CLIs,
@ -402,4 +452,5 @@ export interface Gateways {
profile: ProfileGateway;
template: TemplateGateway;
skill: SkillGateway;
memory: MemoryGateway;
}