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