feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé

- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:24:51 +02:00
parent 32398827fb
commit 785e9935fd
118 changed files with 5793 additions and 866 deletions

View File

@ -59,4 +59,14 @@ describe("TauriAgentGateway invoke payloads", () => {
agentId: "a",
});
});
it("attach_live_agent wraps project, agent and target node in the request DTO", async () => {
invoke.mockResolvedValueOnce([
{ agentId: "agent-2", nodeId: "node-3", sessionId: "session-4" },
]);
await new TauriAgentGateway().attachLiveAgent("proj-1", "agent-2", "node-3");
expect(invoke).toHaveBeenCalledWith("attach_live_agent", {
request: { projectId: "proj-1", agentId: "agent-2", nodeId: "node-3" },
});
});
});

View File

@ -45,6 +45,22 @@ export class TauriAgentGateway implements AgentGateway {
return invoke<LiveAgent[]>("list_live_agents", { projectId });
}
attachLiveAgent(
projectId: string,
agentId: string,
nodeId: string,
): Promise<LiveAgent> {
return invoke<LiveAgent[]>("attach_live_agent", {
request: { projectId, agentId, nodeId },
}).then((list) => {
const attached = list[0];
if (!attached) {
throw new Error(`running session for agent ${agentId} was not returned`);
}
return attached;
});
}
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
// The `create_agent` command takes a single `request` DTO; `projectId` must
// live *inside* it (camelCase), not at the top level.

View File

@ -0,0 +1,35 @@
/**
* Tauri adapter for {@link EmbedderGateway} (L14 / lot C2). Like the sibling
* adapters this is one of the *only* places that calls `invoke()`; components
* reach it exclusively through the port.
*
* Commands use snake_case (Tauri convention); payload keys are camelCase
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`). The mutating
* command `save_embedder_profile` wraps its field under a `request` key (as
* `save_profile` does); `delete_embedder_profile` passes its arg flat.
*/
import { invoke } from "@tauri-apps/api/core";
import type { EmbedderEngines, EmbedderProfile } from "@/domain";
import type { EmbedderGateway } from "@/ports";
export class TauriEmbedderGateway implements EmbedderGateway {
listEmbedderProfiles(): Promise<EmbedderProfile[]> {
return invoke<EmbedderProfile[]>("list_embedder_profiles");
}
saveEmbedderProfile(profile: EmbedderProfile): Promise<EmbedderProfile> {
return invoke<EmbedderProfile>("save_embedder_profile", {
request: { profile },
});
}
async deleteEmbedderProfile(embedderId: string): Promise<void> {
await invoke("delete_embedder_profile", { embedderId });
}
describeEmbedderEngines(): Promise<EmbedderEngines> {
return invoke<EmbedderEngines>("describe_embedder_engines");
}
}

View File

@ -21,6 +21,7 @@ import { TauriProfileGateway } from "./profile";
import { TauriTemplateGateway } from "./template";
import { TauriSkillGateway } from "./skill";
import { TauriMemoryGateway } from "./memory";
import { TauriEmbedderGateway } from "./embedder";
import { TauriGitGateway } from "./git";
function notImplemented(what: string): never {
@ -51,6 +52,7 @@ export function createTauriGateways(): Gateways {
template: new TauriTemplateGateway(),
skill: new TauriSkillGateway(),
memory: new TauriMemoryGateway(),
embedder: new TauriEmbedderGateway(),
};
}
@ -64,5 +66,6 @@ export {
TauriTemplateGateway,
TauriSkillGateway,
TauriMemoryGateway,
TauriEmbedderGateway,
TauriGitGateway,
};

View File

@ -9,6 +9,8 @@ import type {
AgentDrift,
AgentProfile,
DomainEvent,
EmbedderEngines,
EmbedderProfile,
FirstRunState,
GatewayError,
GitBranches,
@ -38,6 +40,7 @@ import type {
CreateAgentInput,
CreateMemoryInput,
CreateSkillInput,
EmbedderGateway,
CreateTemplateInput,
Gateways,
LiveAgent,
@ -178,6 +181,8 @@ export class MockAgentGateway implements AgentGateway {
* Only populated when the caller supplies a `nodeId`.
*/
private liveByAgent = new Map<string, string>();
/** Live PTY session id per agent (`agentId → sessionId`). */
private liveSessionByAgent = new Map<string, string>();
private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
@ -198,7 +203,36 @@ export class MockAgentGateway implements AgentGateway {
const known = new Set(this.getAgents(projectId).map((a) => a.id));
return [...this.liveByAgent.entries()]
.filter(([agentId]) => known.has(agentId))
.map(([agentId, nodeId]) => ({ agentId, nodeId }));
.map(([agentId, nodeId]) => ({
agentId,
nodeId,
sessionId: this.liveSessionByAgent.get(agentId),
}));
}
async attachLiveAgent(
projectId: string,
agentId: string,
nodeId: string,
): Promise<LiveAgent> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
const sessionId = this.liveSessionByAgent.get(agentId);
if (!sessionId || !this.sessions.has(sessionId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} has no live session`,
};
throw err;
}
this.liveByAgent.set(agentId, nodeId);
return { agentId, nodeId, sessionId };
}
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
@ -356,7 +390,10 @@ export class MockAgentGateway implements AgentGateway {
this.sessions.set(sessionId, session);
// Record liveness for `listLiveAgents` + the guard above (only when the
// caller pins a node).
if (options.nodeId) this.liveByAgent.set(agentId, options.nodeId);
if (options.nodeId) {
this.liveByAgent.set(agentId, options.nodeId);
this.liveSessionByAgent.set(agentId, sessionId);
}
// Greet so something is visible immediately (mirrors MockTerminalGateway).
queueMicrotask(() =>
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
@ -365,6 +402,7 @@ export class MockAgentGateway implements AgentGateway {
this.sessions.delete(sessionId);
if (this.liveByAgent.get(agentId) === options.nodeId) {
this.liveByAgent.delete(agentId);
this.liveSessionByAgent.delete(agentId);
}
});
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a
@ -393,7 +431,15 @@ export class MockAgentGateway implements AgentGateway {
}
const scrollback = session.reattach(onData);
return {
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
handle: makeMockHandle(session, () => {
this.sessions.delete(sessionId);
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
if (liveSessionId === sessionId) {
this.liveSessionByAgent.delete(agentId);
this.liveByAgent.delete(agentId);
}
}
}),
scrollback,
};
}
@ -513,6 +559,7 @@ export class MockTerminalGateway implements TerminalGateway {
export class MockProjectGateway implements ProjectGateway {
private projects: Project[] = [];
private contexts = new Map<string, string>();
async listProjects(): Promise<Project[]> {
return [...this.projects];
@ -550,6 +597,28 @@ export class MockProjectGateway implements ProjectGateway {
}
async closeProject(): Promise<void> {}
async readProjectContext(projectId: string): Promise<string> {
if (!this.projects.some((p) => p.id === projectId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `project ${projectId} not found`,
};
throw err;
}
return this.contexts.get(projectId) ?? "";
}
async updateProjectContext(projectId: string, content: string): Promise<void> {
if (!this.projects.some((p) => p.id === projectId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `project ${projectId} not found`,
};
throw err;
}
this.contexts.set(projectId, content);
}
}
/** Internal per-project layout store entry. */
@ -1321,6 +1390,80 @@ export class MockMemoryGateway implements MemoryGateway {
}
}
/**
* In-memory {@link EmbedderGateway} (L14 / lot C2). Seeds a curated set of
* recommended ONNX engines (e5-small recommended) with both feature flags on by
* default; tests can pass overrides to simulate a build where a strategy is not
* compiled in.
*/
export class MockEmbedderGateway implements EmbedderGateway {
private profiles = new Map<string, EmbedderProfile>();
private engines: EmbedderEngines;
constructor(
engines?: Partial<EmbedderEngines>,
seedProfiles: EmbedderProfile[] = [],
) {
this.engines = {
recommendedOnnx: [
{
id: "e5-small",
displayName: "E5 Small (multilingual)",
dimension: 384,
approxSizeMb: 120,
recommended: true,
},
{
id: "bge-small",
displayName: "BGE Small",
dimension: 384,
approxSizeMb: 130,
recommended: false,
},
],
ollamaDetected: false,
onnxCachedModels: [],
vectorHttpEnabled: true,
vectorOnnxEnabled: true,
...engines,
};
for (const p of seedProfiles) this.profiles.set(p.id, p);
}
async listEmbedderProfiles(): Promise<EmbedderProfile[]> {
return structuredClone([...this.profiles.values()]);
}
async saveEmbedderProfile(
profile: EmbedderProfile,
): Promise<EmbedderProfile> {
if (!profile.id.trim() || !profile.name.trim()) {
const err: GatewayError = {
code: "INVALID",
message: "embedder profile id/name must not be empty",
};
throw err;
}
if (profile.dimension <= 0) {
const err: GatewayError = {
code: "INVALID",
message: "embedder profile dimension must be > 0",
};
throw err;
}
this.profiles.set(profile.id, structuredClone(profile));
return structuredClone(profile);
}
async deleteEmbedderProfile(embedderId: string): Promise<void> {
this.profiles.delete(embedderId);
}
async describeEmbedderEngines(): Promise<EmbedderEngines> {
return structuredClone(this.engines);
}
}
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
@ -1336,6 +1479,7 @@ export function createMockGateways(): Gateways {
template: new MockTemplateGateway(agentGateway),
skill: new MockSkillGateway(agentGateway),
memory: new MockMemoryGateway(),
embedder: new MockEmbedderGateway(),
};
}

View File

@ -12,9 +12,10 @@ import { createMockGateways, MockSystemGateway } from "./index";
const gateways: Gateways = createMockGateways();
describe("createMockGateways", () => {
it("exposes all eleven gateways", () => {
it("exposes all twelve gateways", () => {
expect(Object.keys(gateways).sort()).toEqual([
"agent",
"embedder",
"git",
"layout",
"memory",

View File

@ -0,0 +1,27 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const invoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invoke(...args),
}));
import { TauriProjectGateway } from "./project";
describe("TauriProjectGateway invoke payloads", () => {
beforeEach(() => invoke.mockReset().mockResolvedValue({}));
it("reads and updates the project context under .ideai", async () => {
invoke.mockResolvedValueOnce("# shared");
await expect(new TauriProjectGateway().readProjectContext("proj-1")).resolves.toBe(
"# shared",
);
expect(invoke).toHaveBeenCalledWith("read_project_context", {
projectId: "proj-1",
});
await new TauriProjectGateway().updateProjectContext("proj-1", "# next");
expect(invoke).toHaveBeenCalledWith("update_project_context", {
request: { projectId: "proj-1", content: "# next" },
});
});
});

View File

@ -27,4 +27,14 @@ export class TauriProjectGateway implements ProjectGateway {
async closeProject(projectId: string): Promise<void> {
await invoke("close_project", { projectId });
}
readProjectContext(projectId: string): Promise<string> {
return invoke<string>("read_project_context", { projectId });
}
async updateProjectContext(projectId: string, content: string): Promise<void> {
await invoke("update_project_context", {
request: { projectId, content },
});
}
}