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:
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user