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

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