diff --git a/apps/contracts/openapi.json b/apps/contracts/openapi.json index 428a1dfcd1..f4661100e6 100644 --- a/apps/contracts/openapi.json +++ b/apps/contracts/openapi.json @@ -9141,9 +9141,29 @@ "/api/v1/user-memory/consolidation/plans": { "post": { "operationId": "generateMemoryConsolidationPlan", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "target": { + "type": "string", + "enum": [ + "user_model", + "project_memory" + ], + "default": "user_model" + } + } + } + } + } + }, "responses": { "201": { - "description": "A bounded daemon-curated user-memory consolidation plan.", + "description": "A bounded daemon-curated consolidation plan for the requested memory store.", "content": { "application/json": { "schema": { @@ -9248,7 +9268,8 @@ "target": { "type": "string", "enum": [ - "user_model" + "user_model", + "project_memory" ] } }, @@ -9454,7 +9475,8 @@ "target": { "type": "string", "enum": [ - "user_model" + "user_model", + "project_memory" ] } }, diff --git a/apps/contracts/src/generated/sdk.gen.ts b/apps/contracts/src/generated/sdk.gen.ts index 7a27877535..8c6642d54a 100644 --- a/apps/contracts/src/generated/sdk.gen.ts +++ b/apps/contracts/src/generated/sdk.gen.ts @@ -210,7 +210,14 @@ export const undoLearningPromotion = (opti export const reflectSession = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/sessions/{sessionId}/reflection', ...options }); -export const generateMemoryConsolidationPlan = (options?: Options): RequestResult => (options?.client ?? client).post({ url: '/api/v1/user-memory/consolidation/plans', ...options }); +export const generateMemoryConsolidationPlan = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/v1/user-memory/consolidation/plans', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); export const decideMemoryConsolidationPlan = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/user-memory/consolidation/plans/{planId}/decisions', diff --git a/apps/contracts/src/generated/types.gen.ts b/apps/contracts/src/generated/types.gen.ts index 53a2b5d7c5..4c718b17b6 100644 --- a/apps/contracts/src/generated/types.gen.ts +++ b/apps/contracts/src/generated/types.gen.ts @@ -2959,7 +2959,9 @@ export type ReflectSessionResponses = { export type ReflectSessionResponse = ReflectSessionResponses[keyof ReflectSessionResponses]; export type GenerateMemoryConsolidationPlanData = { - body?: never; + body?: { + target?: 'user_model' | 'project_memory'; + }; path?: never; query?: never; url: '/api/v1/user-memory/consolidation/plans'; @@ -3005,7 +3007,7 @@ export type GenerateMemoryConsolidationPlanError = GenerateMemoryConsolidationPl export type GenerateMemoryConsolidationPlanResponses = { /** - * A bounded daemon-curated user-memory consolidation plan. + * A bounded daemon-curated consolidation plan for the requested memory store. */ 201: { expiresAt: string; @@ -3031,7 +3033,7 @@ export type GenerateMemoryConsolidationPlanResponses = { }>; plannedOperationCount: number; plannedSourceCount: number; - target: 'user_model'; + target: 'user_model' | 'project_memory'; }; }; @@ -3131,7 +3133,7 @@ export type DecideMemoryConsolidationPlanResponses = { id: string; planned: number; skipped: number; - target: 'user_model'; + target: 'user_model' | 'project_memory'; }; }; diff --git a/apps/contracts/src/schemas/knowledge.ts b/apps/contracts/src/schemas/knowledge.ts index 6b3ecedb56..dc7a4d64ed 100644 --- a/apps/contracts/src/schemas/knowledge.ts +++ b/apps/contracts/src/schemas/knowledge.ts @@ -151,6 +151,12 @@ export const reflectionReceiptSchema = z.object({ staged: z.number().int().nonnegative(), }); +/** + * The two memory stores a consolidation plan can target: facts about the + * operator (`user_model`) and the per-project store (`project_memory`). + */ +export const memoryConsolidationTargetSchema = z.enum(["user_model", "project_memory"]); + export const memoryConsolidationParticipantSchema = z.object({ description: z.string(), key: z.string(), @@ -172,7 +178,12 @@ export const memoryConsolidationPlanSchema = z.object({ operations: z.array(memoryConsolidationOperationSchema), plannedOperationCount: z.number().int().nonnegative(), plannedSourceCount: z.number().int().nonnegative(), - target: z.literal("user_model"), + target: memoryConsolidationTargetSchema, +}); + +/** Omitting the body, or its target, consolidates `user_model` as before. */ +export const generateMemoryConsolidationPlanRequestSchema = z.object({ + target: memoryConsolidationTargetSchema.default("user_model"), }); export const decideMemoryConsolidationPlanRequestSchema = z.object({ @@ -187,7 +198,7 @@ export const memoryConsolidationReceiptSchema = z.object({ id: z.string(), planned: z.number().int().nonnegative(), skipped: z.number().int().nonnegative(), - target: z.literal("user_model"), + target: memoryConsolidationTargetSchema, }); export const memoryEntrySchema = z.object({ @@ -226,6 +237,9 @@ export type DecideLearningProposalRequest = z.infer; +export type GenerateMemoryConsolidationPlanRequest = z.infer< + typeof generateMemoryConsolidationPlanRequestSchema +>; export type LearnedSkillActionRequest = z.infer; export type LearnedSkillChangesResponse = z.infer; export type LearnedSkillDiffResponse = z.infer; @@ -236,6 +250,7 @@ export type LearningProposalResponse = z.infer; export type LearningProposalsResponse = z.infer; export type MemoryConsolidationPlanResponse = z.infer; export type MemoryConsolidationReceiptResponse = z.infer; +export type MemoryConsolidationTarget = z.infer; export type MemoryDetailResponse = z.infer; export type ReflectionReceiptResponse = z.infer; export type UndoLearningPromotionRequest = z.infer; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index fc88cb7a57..9e7009f4d7 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -364,15 +364,20 @@ export function knowledgeCapabilities(runtime: MecatlRuntime): KnowledgeCapabili } catch { capabilities = undefined; } - const dream = capabilities?.manualDream?.userModel; + const consolidation = ( + dream: { decide: boolean; generate: boolean; unavailableReason?: string } | undefined, + ) => ({ + decide: dream?.decide ?? false, + generate: dream?.generate ?? false, + unavailableReason: + dream?.unavailableReason ?? "Memory consolidation is not enabled on this deployment.", + }); return { learnedSkills: capabilities?.learnedSkills ?? false, learningProposals: capabilities?.learningProposals ?? false, memoryConsolidation: { - decide: dream?.decide ?? false, - generate: dream?.generate ?? false, - unavailableReason: - dream?.unavailableReason ?? "Memory consolidation is not enabled on this deployment.", + project_memory: consolidation(capabilities?.manualDream?.projectMemory), + user_model: consolidation(capabilities?.manualDream?.userModel), }, reflection: capabilities?.reflection ?? false, skills: capabilities?.skills ?? false, diff --git a/apps/server/src/mecatl/knowledge.test.ts b/apps/server/src/mecatl/knowledge.test.ts index 748596efe3..358a3d3029 100644 --- a/apps/server/src/mecatl/knowledge.test.ts +++ b/apps/server/src/mecatl/knowledge.test.ts @@ -28,6 +28,53 @@ function sdkSkill(over: Record = {}) { }; } +const allConsolidation = { + project_memory: { decide: true, generate: true, unavailableReason: "" }, + user_model: { decide: true, generate: true, unavailableReason: "" }, +}; + +const allCapabilities = { + learnedSkills: true, + learningProposals: true, + memoryConsolidation: allConsolidation, + reflection: true, + skills: true, + userModel: true, +}; + +function sdkDreamPlan(target: string) { + return { + expiresAt: { nanos: 0, seconds: 1_700_000_000n }, + id: "plan-1", + operations: [ + { + exactDuplicateEligible: true, + kind: "merge", + reason: "duplicates", + replacement: { description: "Merged", value: "Uses vim" }, + sources: [{ description: "Older", key: "editor_2", value: "vim" }], + survivor: { description: "Editor", key: "editor", value: "Uses vim" }, + }, + ], + plannedOperationCount: 1, + plannedSourceCount: 2, + target, + }; +} + +function sdkDreamReceipt(target: string) { + return { + appliedSourceCount: 1, + conflictedSourceCount: 1, + disposition: "applied", + failedSourceCount: 0, + id: "plan-1", + plannedSourceCount: 2, + skippedSourceCount: 0, + target, + }; +} + describe("Mecatl knowledge adapter", () => { it("derives the knowledge capability set from the live runtime snapshot", async () => { const snapshot = sampleSnapshot(); @@ -36,6 +83,11 @@ describe("Mecatl knowledge adapter", () => { learnedSkills: true, learningProposals: false, manualDream: { + projectMemory: { + decide: false, + generate: false, + unavailableReason: "target store is unavailable", + }, userModel: { decide: true, generate: false, unavailableReason: "needs a model" }, }, reflection: true, @@ -46,7 +98,14 @@ describe("Mecatl knowledge adapter", () => { expect(live).toEqual({ learnedSkills: true, learningProposals: false, - memoryConsolidation: { decide: true, generate: false, unavailableReason: "needs a model" }, + memoryConsolidation: { + project_memory: { + decide: false, + generate: false, + unavailableReason: "target store is unavailable", + }, + user_model: { decide: true, generate: false, unavailableReason: "needs a model" }, + }, reflection: true, skills: false, userModel: true, @@ -62,7 +121,18 @@ describe("Mecatl knowledge adapter", () => { expect(pending).toMatchObject({ learnedSkills: false, learningProposals: false, - memoryConsolidation: { decide: false, generate: false }, + memoryConsolidation: { + project_memory: { + decide: false, + generate: false, + unavailableReason: "Memory consolidation is not enabled on this deployment.", + }, + user_model: { + decide: false, + generate: false, + unavailableReason: "Memory consolidation is not enabled on this deployment.", + }, + }, reflection: false, skills: false, userModel: false, @@ -107,7 +177,7 @@ describe("Mecatl knowledge adapter", () => { const service = createMecatlKnowledgeService({ learnedSkills } as unknown as Client, { learnedSkills: true, learningProposals: true, - memoryConsolidation: { decide: true, generate: true, unavailableReason: "" }, + memoryConsolidation: allConsolidation, reflection: true, skills: true, userModel: true, @@ -204,7 +274,7 @@ describe("Mecatl knowledge adapter", () => { { learnedSkills: true, learningProposals: true, - memoryConsolidation: { decide: true, generate: true, unavailableReason: "" }, + memoryConsolidation: allConsolidation, reflection: true, skills: true, userModel: true, @@ -230,7 +300,7 @@ describe("Mecatl knowledge adapter", () => { { learnedSkills: true, learningProposals: true, - memoryConsolidation: { decide: true, generate: true, unavailableReason: "" }, + memoryConsolidation: allConsolidation, reflection: true, skills: true, userModel: true, @@ -254,4 +324,64 @@ describe("Mecatl knowledge adapter", () => { }); expect(activate).toHaveBeenCalledTimes(1); }); + + it("generates a consolidation plan for either target and keeps the daemon's target", async () => { + for (const target of ["user_model", "project_memory"] as const) { + const generate = vi.fn().mockResolvedValue({ plan: sdkDreamPlan(target) }); + const service = createMecatlKnowledgeService( + { dreamPlans: { generate } } as unknown as Client, + allCapabilities, + ); + const plan = await service.generateMemoryConsolidationPlan(target); + expect(generate).toHaveBeenCalledWith({ + $typeName: "mecatl.v1.GenerateDreamPlanRequest", + target, + }); + expect(plan).toMatchObject({ + expiresAt: "2023-11-14T22:13:20.000Z", + id: "plan-1", + operations: [{ sources: [{ key: "editor_2" }], survivor: { key: "editor" } }], + plannedOperationCount: 1, + plannedSourceCount: 2, + target, + }); + } + }); + + it("maps decision receipts for either target and rejects an unknown target", async () => { + for (const target of ["user_model", "project_memory"] as const) { + const decide = vi.fn().mockResolvedValue({ receipt: sdkDreamReceipt(target) }); + const service = createMecatlKnowledgeService( + { dreamPlans: { decide } } as unknown as Client, + allCapabilities, + ); + await expect( + service.decideMemoryConsolidationPlan("plan-1", { decision: "apply" }), + ).resolves.toEqual({ + applied: 1, + conflicted: 1, + disposition: "applied", + failed: 0, + id: "plan-1", + planned: 2, + skipped: 0, + target, + }); + } + const service = createMecatlKnowledgeService( + { + dreamPlans: { + decide: vi.fn().mockResolvedValue({ receipt: sdkDreamReceipt("team_memory") }), + generate: vi.fn().mockResolvedValue({ plan: sdkDreamPlan("team_memory") }), + }, + } as unknown as Client, + allCapabilities, + ); + await expect( + service.decideMemoryConsolidationPlan("plan-1", { decision: "dismiss" }), + ).rejects.toThrow("memory consolidation receipt for another target"); + await expect(service.generateMemoryConsolidationPlan("user_model")).rejects.toThrow( + "memory consolidation plan for another target", + ); + }); }); diff --git a/apps/server/src/mecatl/knowledge.ts b/apps/server/src/mecatl/knowledge.ts index 989270bc84..d64ee443ac 100644 --- a/apps/server/src/mecatl/knowledge.ts +++ b/apps/server/src/mecatl/knowledge.ts @@ -14,6 +14,7 @@ import type { LearningProposalsResponse, MemoryConsolidationPlanResponse, MemoryConsolidationReceiptResponse, + MemoryConsolidationTarget, MemoryDetailResponse, ReflectionReceiptResponse, UndoLearningPromotionRequest, @@ -28,14 +29,17 @@ import type { UserModelRevision, } from "@stacklok-oss/mecatl-sdk/gen"; +/** What the agent allows for one consolidation target. */ +export interface MemoryConsolidationCapability { + decide: boolean; + generate: boolean; + unavailableReason: string; +} + export interface KnowledgeCapabilities { learnedSkills: boolean; learningProposals: boolean; - memoryConsolidation: { - decide: boolean; - generate: boolean; - unavailableReason: string; - }; + memoryConsolidation: Record; reflection: boolean; skills: boolean; userModel: boolean; @@ -71,7 +75,9 @@ export interface KnowledgeService { ): Promise; getLearnedSkill(id: string, ownerAgent: string, version: string): Promise; getMemory(key: string): Promise; - generateMemoryConsolidationPlan(): Promise; + generateMemoryConsolidationPlan( + target: MemoryConsolidationTarget, + ): Promise; listConfiguredSkills(): Promise; listLearnedSkills(): Promise; listLearnedSkillChanges(): Promise; @@ -141,8 +147,6 @@ export function createMecatlKnowledgeService( }); const receipt = response.receipt; if (!receipt) throw new Error("Mecatl returned no memory consolidation receipt"); - if (receipt.target !== "user_model") - throw new Error("Mecatl returned a memory consolidation receipt for another target"); return { applied: receipt.appliedSourceCount, conflicted: receipt.conflictedSourceCount, @@ -151,7 +155,7 @@ export function createMecatlKnowledgeService( id: receipt.id, planned: receipt.plannedSourceCount, skipped: receipt.skippedSourceCount, - target: "user_model", + target: memoryConsolidationTargetFromSdk(receipt.target, "receipt"), }; }, @@ -192,10 +196,10 @@ export function createMecatlKnowledgeService( }; }, - async generateMemoryConsolidationPlan() { + async generateMemoryConsolidationPlan(target) { const response = await client.dreamPlans.generate({ $typeName: "mecatl.v1.GenerateDreamPlanRequest", - target: "user_model", + target, }); return memoryConsolidationPlanFromSdk(response.plan); }, @@ -386,8 +390,7 @@ function memoryConsolidationPlanFromSdk( plan: DreamReviewPlan | undefined, ): MemoryConsolidationPlanResponse { if (!plan?.id) throw new Error("Mecatl returned no memory consolidation plan"); - if (plan.target !== "user_model") - throw new Error("Mecatl returned a memory consolidation plan for another target"); + const target = memoryConsolidationTargetFromSdk(plan.target, "plan"); return { expiresAt: timestampToIso(plan.expiresAt), id: plan.id, @@ -404,10 +407,22 @@ function memoryConsolidationPlanFromSdk( })), plannedOperationCount: plan.plannedOperationCount, plannedSourceCount: plan.plannedSourceCount, - target: "user_model", + target, }; } +/** + * The SDK's target is a free-form string; narrow it to the contract's enum so + * an unknown target fails loudly instead of mislabeling the plan or receipt. + */ +function memoryConsolidationTargetFromSdk( + target: string, + kind: "plan" | "receipt", +): MemoryConsolidationTarget { + if (target === "user_model" || target === "project_memory") return target; + throw new Error(`Mecatl returned a memory consolidation ${kind} for another target`); +} + function memoryParticipantFromSdk(participant: DreamParticipant | undefined) { return { description: participant?.description ?? "", diff --git a/apps/server/src/routes/knowledge.test.ts b/apps/server/src/routes/knowledge.test.ts index 2eb3d02c03..e71ac99a67 100644 --- a/apps/server/src/routes/knowledge.test.ts +++ b/apps/server/src/routes/knowledge.test.ts @@ -9,7 +9,10 @@ const knowledge: KnowledgeService = { capabilities: { learnedSkills: true, learningProposals: true, - memoryConsolidation: { decide: true, generate: true, unavailableReason: "" }, + memoryConsolidation: { + project_memory: { decide: true, generate: true, unavailableReason: "" }, + user_model: { decide: true, generate: true, unavailableReason: "" }, + }, reflection: true, skills: true, userModel: true, @@ -59,7 +62,7 @@ const knowledge: KnowledgeService = { target: "user_model", }; }, - async generateMemoryConsolidationPlan() { + async generateMemoryConsolidationPlan(target) { return { expiresAt: null, id: "plan-1", @@ -79,7 +82,7 @@ const knowledge: KnowledgeService = { ], plannedOperationCount: 1, plannedSourceCount: 2, - target: "user_model", + target, }; }, async getLearnedSkill(id, ownerAgent, version) { @@ -360,6 +363,126 @@ describe("knowledge routes", () => { expect(decided.status).toBe(200); expect(await decided.json()).toMatchObject({ applied: 2, disposition: "apply" }); }); + + it("passes the requested consolidation target to the service, defaulting to user_model", async () => { + const targets: string[] = []; + const recording = csrfApp({ + knowledge: { + ...knowledge, + async generateMemoryConsolidationPlan(target) { + targets.push(target); + return knowledge.generateMemoryConsolidationPlan(target); + }, + }, + }); + const json = { "Content-Type": "application/json" }; + const project = await recording.request("/api/v1/user-memory/consolidation/plans", { + body: JSON.stringify({ target: "project_memory" }), + headers: json, + method: "POST", + }); + expect(project.status).toBe(201); + expect(await project.json()).toMatchObject({ id: "plan-1", target: "project_memory" }); + + const bodiless = await recording.request("/api/v1/user-memory/consolidation/plans", { + method: "POST", + }); + expect(bodiless.status).toBe(201); + expect(await bodiless.json()).toMatchObject({ target: "user_model" }); + + const emptyBody = await recording.request("/api/v1/user-memory/consolidation/plans", { + body: "{}", + headers: json, + method: "POST", + }); + expect(emptyBody.status).toBe(201); + expect(targets).toEqual(["project_memory", "user_model", "user_model"]); + + const unknown = await recording.request("/api/v1/user-memory/consolidation/plans", { + body: JSON.stringify({ target: "team_memory" }), + headers: json, + method: "POST", + }); + expect(unknown.status).toBe(400); + expect(targets).toHaveLength(3); + }); + + it("gates plan generation on the requested target's own capability", async () => { + let generated = 0; + const userOnly = csrfApp({ + knowledge: { + ...knowledge, + capabilities: { + ...knowledge.capabilities, + memoryConsolidation: { + project_memory: { + decide: false, + generate: false, + unavailableReason: "target store is unavailable", + }, + user_model: { decide: true, generate: true, unavailableReason: "" }, + }, + }, + async generateMemoryConsolidationPlan(target) { + generated += 1; + return knowledge.generateMemoryConsolidationPlan(target); + }, + }, + }); + const json = { "Content-Type": "application/json" }; + const project = await userOnly.request("/api/v1/user-memory/consolidation/plans", { + body: JSON.stringify({ target: "project_memory" }), + headers: json, + method: "POST", + }); + expect(project.status).toBe(501); + await expect(project.json()).resolves.toMatchObject({ + code: "memory_consolidation_unsupported", + detail: "target store is unavailable", + }); + expect(generated).toBe(0); + const user = await userOnly.request("/api/v1/user-memory/consolidation/plans", { + body: JSON.stringify({ target: "user_model" }), + headers: json, + method: "POST", + }); + expect(user.status).toBe(201); + expect(generated).toBe(1); + }); + + it("admits a decision when any target can decide, since the plan id carries no target", async () => { + const projectOnly = csrfApp({ + knowledge: { + ...knowledge, + capabilities: { + ...knowledge.capabilities, + memoryConsolidation: { + project_memory: { decide: true, generate: true, unavailableReason: "" }, + user_model: { decide: false, generate: false, unavailableReason: "Dreaming is off" }, + }, + }, + async decideMemoryConsolidationPlan(planId, request) { + return { + ...(await knowledge.decideMemoryConsolidationPlan(planId, request)), + target: "project_memory", + }; + }, + }, + }); + const decided = await projectOnly.request( + "/api/v1/user-memory/consolidation/plans/plan-1/decisions", + { + body: JSON.stringify({ decision: "dismiss" }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }, + ); + expect(decided.status).toBe(200); + expect(await decided.json()).toMatchObject({ + disposition: "dismiss", + target: "project_memory", + }); + }); it("capability-disables each knowledge surface with its own 501 code", async () => { const off = csrfApp({ knowledge: { @@ -368,9 +491,8 @@ describe("knowledge routes", () => { learnedSkills: false, learningProposals: false, memoryConsolidation: { - decide: false, - generate: false, - unavailableReason: "Dreaming is off", + project_memory: { decide: false, generate: false, unavailableReason: "No project" }, + user_model: { decide: false, generate: false, unavailableReason: "Dreaming is off" }, }, reflection: false, skills: false, diff --git a/apps/server/src/routes/knowledge.ts b/apps/server/src/routes/knowledge.ts index 409337b9c1..dbe9cb2edd 100644 --- a/apps/server/src/routes/knowledge.ts +++ b/apps/server/src/routes/knowledge.ts @@ -5,6 +5,7 @@ import { configuredSkillsResponseSchema, decideLearningProposalRequestSchema, decideMemoryConsolidationPlanRequestSchema, + generateMemoryConsolidationPlanRequestSchema, learnedSkillActionRequestSchema, learnedSkillActionResponseSchema, learnedSkillChangesResponseSchema, @@ -263,10 +264,16 @@ const generateMemoryConsolidationPlanRoute = createRoute({ method: "post", operationId: "generateMemoryConsolidationPlan", path: "/api/v1/user-memory/consolidation/plans", + request: { + body: { + content: { "application/json": { schema: generateMemoryConsolidationPlanRequestSchema } }, + required: false, + }, + }, responses: { 201: { content: { "application/json": { schema: memoryConsolidationPlanSchema } }, - description: "A bounded daemon-curated user-memory consolidation plan.", + description: "A bounded daemon-curated consolidation plan for the requested memory store.", }, 500: errorResponse, 501: errorResponse, @@ -432,21 +439,23 @@ export function registerKnowledgeRoutes( }); app.openapi(generateMemoryConsolidationPlanRoute, async (context) => { if (!knowledge) return unavailable(context); - if (!knowledge.capabilities.memoryConsolidation.generate) - return unsupported( - context, - "memory_consolidation_unsupported", - knowledge.capabilities.memoryConsolidation.unavailableReason, - ); - return context.json(await knowledge.generateMemoryConsolidationPlan(), 201); + // An absent body (older clients) consolidates the user model, as before. + const target = context.req.valid("json")?.target ?? "user_model"; + const capability = knowledge.capabilities.memoryConsolidation[target]; + if (!capability.generate) + return unsupported(context, "memory_consolidation_unsupported", capability.unavailableReason); + return context.json(await knowledge.generateMemoryConsolidationPlan(target), 201); }); app.openapi(decideMemoryConsolidationPlanRoute, async (context) => { if (!knowledge) return unavailable(context); - if (!knowledge.capabilities.memoryConsolidation.decide) + // The plan id carries no target, so any target that can decide admits the + // request; the daemon still enforces each plan's own decision authority. + const consolidation = knowledge.capabilities.memoryConsolidation; + if (!consolidation.user_model.decide && !consolidation.project_memory.decide) return unsupported( context, "memory_consolidation_decision_unsupported", - knowledge.capabilities.memoryConsolidation.unavailableReason, + consolidation.user_model.unavailableReason, ); return context.json( await knowledge.decideMemoryConsolidationPlan( diff --git a/apps/server/test/integration/mecated-mock.test.ts b/apps/server/test/integration/mecated-mock.test.ts index 20905e6e4f..9b5b231215 100644 --- a/apps/server/test/integration/mecated-mock.test.ts +++ b/apps/server/test/integration/mecated-mock.test.ts @@ -219,22 +219,41 @@ describe.skipIf(!available)("Studio BFF against a spawned mecated --mock", () => expect(missing).toMatchObject({ body: { code: "not_found" }, status: 404 }); const runtime = await json("/api/v1/runtime"); - const dream = ( - runtime.body.capabilities as { manualDream?: { userModel?: { generate: boolean } } } - ).manualDream?.userModel; - const plan = await json("/api/v1/user-memory/consolidation/plans", { - headers: csrf, - method: "POST", - }); - if (dream?.generate) { - expect(plan.status).toBe(201); - expect(plan.body).toMatchObject({ target: "user_model" }); - expect(typeof plan.body.id).toBe("string"); - } else { - expect(plan).toMatchObject({ - body: { code: "memory_consolidation_unsupported" }, - status: 501, + type Dream = { generate: boolean }; + const manualDream = ( + runtime.body.capabilities as { manualDream?: { projectMemory?: Dream; userModel?: Dream } } + ).manualDream; + const cases = [ + // No body at all: the BFF keeps consolidating the user model by default. + { dream: manualDream?.userModel, init: {}, target: "user_model" }, + { + dream: manualDream?.userModel, + init: { body: JSON.stringify({ target: "user_model" }) }, + target: "user_model", + }, + { + dream: manualDream?.projectMemory, + init: { body: JSON.stringify({ target: "project_memory" }) }, + target: "project_memory", + }, + ]; + for (const { dream, init, target } of cases) { + const { "Content-Type": _contentType, ...bodiless } = csrf; + const plan = await json("/api/v1/user-memory/consolidation/plans", { + headers: "body" in init ? csrf : bodiless, + method: "POST", + ...init, }); + if (dream?.generate) { + expect(plan.status, target).toBe(201); + expect(plan.body, target).toMatchObject({ target }); + expect(typeof plan.body.id, target).toBe("string"); + } else { + expect(plan, target).toMatchObject({ + body: { code: "memory_consolidation_unsupported" }, + status: 501, + }); + } } }); diff --git a/apps/web/src/features/knowledge/memory-consolidation.test.ts b/apps/web/src/features/knowledge/memory-consolidation.test.ts deleted file mode 100644 index 3f4bd72398..0000000000 --- a/apps/web/src/features/knowledge/memory-consolidation.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; -import { isStaleMemoryPlan, memoryConsolidationSummary } from "./memory-consolidation"; - -describe("memory consolidation", () => { - it("summarizes apply receipts", () => { - expect( - memoryConsolidationSummary({ - applied: 2, - conflicted: 1, - disposition: "apply", - failed: 0, - id: "plan-1", - planned: 4, - skipped: 1, - target: "user_model", - }), - ).toBe("Applied: 2 merged, 1 conflicted, 1 skipped, 0 failed."); - }); - - it("recognizes stale daemon plan errors", () => { - expect(isStaleMemoryPlan({ code: "dream_not_found", status: 404 })).toBe(true); - expect(isStaleMemoryPlan({ code: "internal_error", status: 500 })).toBe(false); - }); -}); diff --git a/apps/web/src/features/knowledge/memory-consolidation.tsx b/apps/web/src/features/knowledge/memory-consolidation.tsx deleted file mode 100644 index f4b23cf384..0000000000 --- a/apps/web/src/features/knowledge/memory-consolidation.tsx +++ /dev/null @@ -1,256 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import type { - DecideMemoryConsolidationPlanResponse, - GenerateMemoryConsolidationPlanResponse, -} from "@mecatl-studio/contracts/generated"; -import { - decideMemoryConsolidationPlanMutation, - generateMemoryConsolidationPlanMutation, - getRuntimeOptions, - listUserMemoryQueryKey, -} from "@mecatl-studio/contracts/query"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Sparkles } from "lucide-react"; -import { useState } from "react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "../../components/ui/alert-dialog"; -import { Badge } from "../../components/ui/badge"; -import { Button } from "../../components/ui/button"; - -export function MemoryConsolidation() { - const queryClient = useQueryClient(); - const runtime = useQuery(getRuntimeOptions()); - const generate = useMutation(generateMemoryConsolidationPlanMutation()); - const decide = useMutation(decideMemoryConsolidationPlanMutation()); - const [plan, setPlan] = useState(); - const [receipt, setReceipt] = useState(); - const [notice, setNotice] = useState(); - const [confirmApply, setConfirmApply] = useState(false); - const capability = runtime.data?.capabilities.manualDream?.userModel; - - if (!capability?.generate) return null; - - async function generatePlan() { - setPlan(undefined); - setReceipt(undefined); - setNotice(undefined); - try { - setPlan(await generate.mutateAsync({})); - } catch { - // The generated mutation error is rendered below. - } - } - - async function decidePlan(decision: "apply" | "dismiss") { - if (!plan) return; - setNotice(undefined); - try { - const result = await decide.mutateAsync({ - body: { decision }, - path: { planId: plan.id }, - }); - setReceipt(result); - setPlan(undefined); - if (decision === "apply") - await queryClient.invalidateQueries({ queryKey: listUserMemoryQueryKey() }); - } catch (error) { - if (isStaleMemoryPlan(error)) { - setPlan(undefined); - setNotice( - "That plan expired or belonged to a previous daemon process. Generate a new plan instead of retrying it.", - ); - } - } finally { - setConfirmApply(false); - } - } - - const error = generate.error ?? decide.error; - return ( -
-
-
- - - -
-

Consolidate memory

-

- Mecatl proposes merges for duplicate or overlapping facts. Nothing changes until you - apply the whole plan. -

-
-
- -
- - {generate.isPending && ( -

- Mecatl is reviewing memory; this may take a minute. -

- )} - {notice && ( -

{notice}

- )} - {error && !notice &&

{errorMessage(error)}

} - - {plan && plan.operations.length === 0 && ( -
-

- Nothing needs consolidation; this memory is already tidy. -

- -
- )} - - {plan && plan.operations.length > 0 && ( -
-

- {plan.plannedOperationCount} operation{plan.plannedOperationCount === 1 ? "" : "s"} over{" "} - {plan.plannedSourceCount} memor{plan.plannedSourceCount === 1 ? "y" : "ies"}. Review - every operation before deciding the whole plan. -

- {plan.expiresAt && ( -

- Expires {formatDate(plan.expiresAt)} -

- )} -
    - {plan.operations.map((operation) => ( -
  • source.key).join(",")}`} - > -
    - {operation.kind.replaceAll("_", " ") || "merge"} - {operation.exactDuplicateEligible && ( - exact duplicate - )} - {operation.reason && ( - {operation.reason} - )} -
    -

    - Keep {operation.survivor.key} - {operation.sources.length > 0 && ( - <> · absorb {operation.sources.map((source) => source.key).join(", ")} - )} -

    - {operation.replacement.description && ( -

    - {operation.replacement.description} -

    - )} - {operation.replacement.value && ( -
    -                    {operation.replacement.value}
    -                  
    - )} -
  • - ))} -
-
- - -
-
- )} - - {receipt && ( -

{memoryConsolidationSummary(receipt)}

- )} - - - - - Apply this consolidation plan? - - Mecatl will merge the listed memories exactly as shown; memories changed since - planning will be skipped, not overwritten. - - - - Cancel - void decidePlan("apply")} - > - Apply - - - - -
- ); -} - -export function memoryConsolidationSummary(receipt: DecideMemoryConsolidationPlanResponse) { - return receipt.disposition === "apply" - ? `Applied: ${receipt.applied} merged, ${receipt.conflicted} conflicted, ${receipt.skipped} skipped, ${receipt.failed} failed.` - : "Plan dismissed; memory was not changed."; -} - -export function isStaleMemoryPlan(error: unknown) { - if (typeof error !== "object" || error === null) return false; - if ( - "code" in error && - (error.code === "dream_not_found" || error.code === "dream_terminal_conflict") - ) - return true; - return "status" in error && (error.status === 404 || error.status === 410); -} - -function formatDate(value: string) { - return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format( - new Date(value), - ); -} - -function errorMessage(error: unknown) { - if (typeof error === "object" && error !== null && "detail" in error) return String(error.detail); - return error instanceof Error ? error.message : "The request could not be completed."; -} diff --git a/apps/web/src/features/memory/consolidate-memory-card.test.tsx b/apps/web/src/features/memory/consolidate-memory-card.test.tsx new file mode 100644 index 0000000000..7c728d2207 --- /dev/null +++ b/apps/web/src/features/memory/consolidate-memory-card.test.tsx @@ -0,0 +1,544 @@ +// SPDX-License-Identifier: Apache-2.0 +// @vitest-environment happy-dom + +import { client as apiClient } from "@mecatl-studio/contracts/client"; +import type { + DecideMemoryConsolidationPlanResponse, + GenerateMemoryConsolidationPlanResponse, +} from "@mecatl-studio/contracts/generated"; +import { getRuntimeQueryKey } from "@mecatl-studio/contracts/query"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + ConsolidateMemoryCard, + describeMemoryConsolidationReceipt, + formatUntilTime, + isDreamInProgress, + isStaleMemoryPlan, +} from "./consolidate-memory-card"; + +/** + * The Consolidate memory card: a target the agent cannot consolidate + * offered disabled; one confirmed button that generates the suggestion; the + * change list with current values; whole-plan Apply/Dismiss; an open + * decision (`dream_in_progress` or a dropped connection) locked to retrying + * the SAME decision; a no-longer-valid plan (`dream_conflict`, vanished) + * cleared with the consolidate-again notice; the plain result line. + */ + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +type ManualDream = Record< + "project_memory" | "user_model", + { decide: boolean; generate: boolean; unavailableReason?: string } +>; + +const BOTH_AVAILABLE: ManualDream = { + project_memory: { decide: true, generate: true }, + user_model: { decide: true, generate: true }, +}; + +const mutationState = { + decideAnswers: [] as Array<() => unknown>, + decideCalls: [] as Array<{ decision: string; planId: string }>, + decideIndex: 0, + generateCalls: [] as Array<{ target: string }>, +}; + +function plan(): GenerateMemoryConsolidationPlanResponse { + return { + expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(), + id: "plan1", + operations: [ + { + exactDuplicateEligible: true, + kind: "merge", + reason: "near-duplicates", + replacement: { + description: "Merged editor preference", + value: "Uses vim with vim keybindings", + }, + sources: [ + { + description: "Older note", + key: "editor_2", + value: "Prefers vim keybindings", + }, + ], + survivor: { + description: "Editor preference", + key: "editor", + value: "Uses vim", + }, + }, + ], + plannedOperationCount: 1, + plannedSourceCount: 2, + target: "user_model", + }; +} + +function receipt( + overrides: Partial = {}, +): DecideMemoryConsolidationPlanResponse { + return { + applied: 1, + conflicted: 1, + disposition: "apply", + failed: 0, + id: "plan1", + planned: 2, + skipped: 0, + target: "user_model", + ...overrides, + }; +} + +/** Routes each decision call, in order, to the given answers (the last one + * repeats). */ +function stubDecide(...answers: Array<() => unknown>) { + mutationState.decideAnswers = answers; + mutationState.decideIndex = 0; +} + +const initialApiConfig = apiClient.getConfig(); + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + headers: { "content-type": status < 400 ? "application/json" : "application/problem+json" }, + status, + }); +} + +/** + * Fakes the two consolidation calls the card makes. A decision answer that is + * a TypeError drops the connection; any other Error becomes the BFF's problem + * body with its `code` and `status`. + */ +const fakeFetch: typeof globalThis.fetch = async (input) => { + const request = input instanceof Request ? input : new Request(String(input)); + const { pathname } = new URL(request.url); + if (pathname === "/api/v1/runtime") return json(runtime); + if (request.method !== "POST") + return json({ detail: `unexpected ${pathname}`, status: 404 }, 404); + const body = (await request.clone().json()) as { decision?: string; target?: string }; + if (pathname === "/api/v1/user-memory/consolidation/plans") { + mutationState.generateCalls.push({ target: body.target ?? "" }); + return json(plan(), 201); + } + const decision = /^\/api\/v1\/user-memory\/consolidation\/plans\/([^/]+)\/decisions$/.exec( + pathname, + ); + if (decision) { + mutationState.decideCalls.push({ + decision: body.decision ?? "", + planId: decodeURIComponent(decision[1] ?? ""), + }); + const answers = mutationState.decideAnswers; + const answer = answers[Math.min(mutationState.decideIndex, answers.length - 1)]; + mutationState.decideIndex += 1; + const result = answer?.(); + if (result instanceof TypeError) throw result; + if (result instanceof Error) { + const { code, status } = result as Error & { code?: string; status?: number }; + return json( + { + code: code ?? "", + detail: result.message, + instance: "", + status: status ?? 409, + title: "", + type: "about:blank", + }, + status ?? 409, + ); + } + return json(result); + } + return json({ detail: `unexpected ${pathname}`, status: 404 }, 404); +}; + +let runtime: { capabilities: { manualDream?: unknown }; connection: string }; + +function setManualDream(manualDream: Partial) { + runtime = { + capabilities: { + manualDream: { + projectMemory: manualDream.project_memory, + userModel: manualDream.user_model, + }, + }, + connection: "online", + }; +} + +function renderCard() { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, retryOnMount: false, staleTime: Number.POSITIVE_INFINITY }, + }, + }); + client.setQueryData(getRuntimeQueryKey(), runtime); + apiClient.setConfig({ baseUrl: window.location.origin, fetch: fakeFetch }); + return render( + + + , + ); +} + +/** The tests use plain DOM checks; Studio has no jest-dom matchers. */ +const isDisabled = (element: Element) => (element as HTMLButtonElement).disabled; +const text = (element: Element | null) => element?.textContent ?? ""; + +const consolidateButton = () => screen.findByRole("button", { name: "Consolidate memory" }); + +async function generatePlan(user: ReturnType) { + await user.click(await consolidateButton()); + const dialog = await screen.findByRole("alertdialog"); + await user.click(within(dialog).getByRole("button", { name: "Continue" })); + await screen.findByTestId("dream-plan-summary"); +} + +async function applyPlan(user: ReturnType) { + await user.click(screen.getByRole("button", { name: "Apply" })); + const dialog = await screen.findByRole("alertdialog"); + await user.click(within(dialog).getByRole("button", { name: "Apply" })); +} + +beforeEach(() => { + setManualDream(BOTH_AVAILABLE); + mutationState.decideCalls = []; + mutationState.generateCalls = []; + stubDecide(() => receipt()); +}); + +afterEach(() => { + apiClient.setConfig({ baseUrl: initialApiConfig.baseUrl, fetch: initialApiConfig.fetch }); + cleanup(); +}); + +describe("describeMemoryConsolidationReceipt", () => { + const base = receipt({ applied: 2, conflicted: 1, planned: 4, skipped: 1 }); + + it("says what merged, then only the buckets that are not empty", () => { + expect(describeMemoryConsolidationReceipt(base)).toBe( + "2 of 4 memories merged · 1 left alone · 1 skipped", + ); + expect( + describeMemoryConsolidationReceipt({ + ...base, + conflicted: 0, + failed: 2, + skipped: 0, + }), + ).toBe("2 of 4 memories merged · 2 failed"); + }); + + it("singularises one memory", () => { + expect( + describeMemoryConsolidationReceipt({ + ...base, + applied: 1, + conflicted: 0, + planned: 1, + skipped: 0, + }), + ).toBe("1 of 1 memory merged"); + }); +}); + +describe("formatUntilTime", () => { + const now = Date.parse("2026-10-08T12:00:00Z"); + + it("names the remaining time in its largest whole unit", () => { + expect(formatUntilTime(now + 30_000, now)).toBe("<1m"); + expect(formatUntilTime(now + 14 * 60_000 + 59_000, now)).toBe("14m"); + expect(formatUntilTime(now + 3 * 3_600_000 + 60_000, now)).toBe("3h"); + expect(formatUntilTime(now + 50 * 3_600_000, now)).toBe("2d"); + }); + + it("is empty for a missing timestamp", () => { + expect(formatUntilTime(0, now)).toBe(""); + }); +}); + +describe("plan error classification", () => { + it("treats a vanished or already-decided plan as stale, covering dream_conflict", () => { + expect(isStaleMemoryPlan({ code: "dream_not_found", status: 404 })).toBe(true); + expect(isStaleMemoryPlan({ code: "dream_conflict", status: 409 })).toBe(true); + expect(isStaleMemoryPlan({ code: "dream_terminal_conflict", status: 409 })).toBe(true); + expect(isStaleMemoryPlan({ status: 410 })).toBe(true); + expect(isStaleMemoryPlan({ code: "internal_error", status: 500 })).toBe(false); + expect(isStaleMemoryPlan(new TypeError("fetch failed"))).toBe(false); + }); + + it("treats dream_in_progress and a missing answer as still open", () => { + expect(isDreamInProgress({ code: "dream_in_progress", status: 409 })).toBe(true); + expect(isDreamInProgress(new TypeError("fetch failed"))).toBe(true); + expect(isDreamInProgress(undefined)).toBe(true); + expect(isDreamInProgress({ code: "internal_error", status: 500 })).toBe(false); + }); +}); + +describe("ConsolidateMemoryCard", () => { + it("renders nothing without the manual_dream capability", () => { + runtime = { capabilities: {}, connection: "online" }; + const { container } = renderCard(); + expect(container.innerHTML).toBe(""); + }); + + it("offers a memory the agent cannot consolidate as disabled, without its reason, and picks a usable one by default", async () => { + const user = userEvent.setup(); + setManualDream({ + project_memory: { decide: true, generate: true }, + user_model: { + decide: false, + generate: false, + unavailableReason: "target store is unavailable", + }, + }); + renderCard(); + + const picker = await screen.findByRole("combobox", { name: "Memory to consolidate" }); + expect(text(picker)).toBe("Project memory"); + expect(screen.queryByTestId("dream-unavailable")).toBeNull(); + expect(isDisabled(await consolidateButton())).toBe(false); + expect(document.body.textContent).not.toMatch(/daemon|store/i); + + await user.click(picker); + const listbox = await screen.findByRole("listbox"); + const disabled = within(listbox).getByRole("option", { + name: "Facts about you (unavailable)", + }); + expect(disabled.getAttribute("aria-disabled")).toBe("true"); + expect(disabled.hasAttribute("data-disabled")).toBe(true); + const available = within(listbox).getByRole("option", { name: "Project memory" }); + expect(available.hasAttribute("aria-disabled")).toBe(false); + expect(available.getAttribute("aria-selected")).toBe("true"); + }); + + it("submits the chosen target from the picker", async () => { + const user = userEvent.setup(); + renderCard(); + const picker = await screen.findByRole("combobox", { name: "Memory to consolidate" }); + expect(text(picker)).toBe("Facts about you"); + await user.click(picker); + await user.click(await screen.findByRole("option", { name: "Project memory" })); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(text(picker)).toBe("Project memory"); + await generatePlan(user); + expect(mutationState.generateCalls).toEqual([{ target: "project_memory" }]); + }); + + it("disables the button with a plain sentence when the only memory cannot be consolidated", async () => { + setManualDream({ + user_model: { + decide: false, + generate: false, + unavailableReason: "dream planner is unavailable", + }, + }); + renderCard(); + expect(text(await screen.findByTestId("dream-unavailable"))).toContain( + "Consolidation isn't available for this memory right now.", + ); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(screen.getByText("Facts about you")).toBeTruthy(); + expect(isDisabled(await consolidateButton())).toBe(true); + expect(document.body.textContent).not.toMatch(/daemon|planner/i); + }); + + it("disables the button while the agent is offline", async () => { + runtime = { ...runtime, connection: "offline" }; + renderCard(); + expect(isDisabled(await consolidateButton())).toBe(true); + }); + + it("confirms before generating, then submits the target; the shown suggestion replaces the button", async () => { + const user = userEvent.setup(); + renderCard(); + + await user.click(await consolidateButton()); + const dialog = await screen.findByRole("alertdialog"); + expect(text(dialog)).toContain("Consolidate memory?"); + expect(text(dialog)).toContain( + "The agent reads everything it remembers and suggests which entries to merge. Nothing changes until you approve.", + ); + expect(mutationState.generateCalls).toHaveLength(0); + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(mutationState.generateCalls).toHaveLength(0); + + await generatePlan(user); + expect(mutationState.generateCalls[0]).toEqual({ target: "user_model" }); + expect(text(screen.getByTestId("dream-plan-summary"))).toMatch( + /1 suggested change across 2 memories\. Apply or dismiss them all together\. Expires in 1[45]m\./, + ); + expect(screen.queryByRole("button", { name: "Consolidate memory" })).toBeNull(); + expect(isDisabled(screen.getByRole("button", { name: "Apply" }))).toBe(false); + expect(isDisabled(screen.getByRole("button", { name: "Dismiss" }))).toBe(false); + }); + + it("shows each change: the exact-duplicate badge, the merged text, and the current values it keeps and merges in", async () => { + const user = userEvent.setup(); + renderCard(); + await generatePlan(user); + + const change = screen.getByRole("listitem"); + expect(within(change).getByText("exact duplicate")).toBeTruthy(); + expect(text(change)).toContain("near-duplicates"); + expect(text(change)).toContain("Keeps editor and merges in editor_2"); + expect(text(change)).toContain("Uses vim with vim keybindings"); + expect(text(change)).toContain("Merged editor preference"); + + const summary = within(change).getByText("Show current values"); + const details = summary.closest("details"); + expect(details).not.toBeNull(); + await user.click(summary); + expect(text(details)).toContain("Keeps editor"); + expect(text(details)).toContain("Uses vim"); + expect(text(details)).toContain("Merges in editor_2"); + expect(text(details)).toContain("Prefers vim keybindings"); + }); + + it("confirms before applying and reports the receipt", async () => { + const user = userEvent.setup(); + stubDecide(() => receipt({ applied: 2, conflicted: 0, failed: 1, planned: 3 })); + renderCard(); + await generatePlan(user); + + await user.click(screen.getByRole("button", { name: "Apply" })); + const dialog = await screen.findByRole("alertdialog"); + expect(text(dialog)).toContain("Apply these changes?"); + expect(text(dialog)).toContain( + "The memories are merged exactly as shown. Anything that changed in the meantime is left alone.", + ); + expect(mutationState.decideCalls).toHaveLength(0); + await user.click(within(dialog).getByRole("button", { name: "Apply" })); + + const receiptEl = await screen.findByTestId("dream-receipt"); + expect(mutationState.decideCalls).toEqual([{ decision: "apply", planId: "plan1" }]); + expect(text(receiptEl)).toContain("Done: 2 of 3 memories merged · 1 failed."); + expect(text(receiptEl)).toContain( + "Some memories couldn’t be merged. The counts above are the final result.", + ); + }); + + it("dream_in_progress keeps the suggestion, disables the other decision, and Try again re-submits the identical decision", async () => { + const user = userEvent.setup(); + stubDecide( + () => Object.assign(new Error("still running"), { code: "dream_in_progress" }), + () => receipt(), + ); + renderCard(); + await generatePlan(user); + + await applyPlan(user); + const notice = await screen.findByRole("status"); + expect(text(notice)).toContain( + "The agent is still working on that. Press Try again to see the result.", + ); + expect(screen.getByTestId("dream-plan-summary")).toBeTruthy(); + expect(isDisabled(screen.getByRole("button", { name: "Dismiss" }))).toBe(true); + expect(mutationState.decideCalls).toHaveLength(1); + + // The retry: no confirmation dialog, the same {decision} to the same id. + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(screen.queryByRole("alertdialog")).toBeNull(); + const receiptEl = await screen.findByTestId("dream-receipt"); + expect(mutationState.decideCalls).toHaveLength(2); + expect(mutationState.decideCalls[1]).toEqual({ decision: "apply", planId: "plan1" }); + + expect(text(receiptEl)).toContain("Done: 1 of 2 memories merged · 1 left alone."); + expect(text(receiptEl)).toContain( + "Some memories changed while you were reviewing, so they were left alone.", + ); + expect(screen.queryByTestId("dream-plan-summary")).toBeNull(); + expect(isDisabled(await consolidateButton())).toBe(false); + }); + + it("a connection dropped mid-decision locks to the same decision and Try again fetches the result", async () => { + const user = userEvent.setup(); + stubDecide( + () => new TypeError("fetch failed"), + () => receipt({ disposition: "dismiss" }), + ); + renderCard(); + await generatePlan(user); + + await user.click(screen.getByRole("button", { name: "Dismiss" })); + const notice = await screen.findByRole("status"); + expect(text(notice)).toContain("The agent is still working on that."); + expect(isDisabled(screen.getByRole("button", { name: "Apply" }))).toBe(true); + await user.click(screen.getByRole("button", { name: "Try again" })); + const receiptEl = await screen.findByTestId("dream-receipt"); + expect(text(receiptEl)).toContain("Dismissed. Nothing changed."); + expect(text(receiptEl)).not.toContain("Some memories changed"); + expect(screen.queryByTestId("dream-plan-summary")).toBeNull(); + expect(isDisabled(await consolidateButton())).toBe(false); + }); + + it("dream_conflict clears the suggestion with the consolidate-again notice", async () => { + const user = userEvent.setup(); + stubDecide(() => Object.assign(new Error("conflict"), { code: "dream_conflict" })); + renderCard(); + await generatePlan(user); + + await user.click(screen.getByRole("button", { name: "Dismiss" })); + expect(text(await screen.findByRole("status"))).toContain( + "That suggestion is no longer valid, so nothing changed. Consolidate again for a new one.", + ); + expect(screen.queryByTestId("dream-plan-summary")).toBeNull(); + expect(isDisabled(await consolidateButton())).toBe(false); + }); + + it("a vanished plan (dream_not_found) clears with the same notice", async () => { + const user = userEvent.setup(); + stubDecide(() => Object.assign(new Error("unknown plan"), { code: "dream_not_found" })); + renderCard(); + await generatePlan(user); + await applyPlan(user); + expect(text(await screen.findByRole("status"))).toContain( + "That suggestion is no longer valid, so nothing changed.", + ); + expect(screen.queryByTestId("dream-plan-summary")).toBeNull(); + }); + + it("an unclassified decide error is shown verbatim with the suggestion kept and both decisions available", async () => { + const user = userEvent.setup(); + stubDecide(() => Object.assign(new Error("boom"), { code: "internal", status: 500 })); + renderCard(); + await generatePlan(user); + await applyPlan(user); + expect(text(await screen.findByRole("alert"))).toContain("boom"); + expect(screen.getByTestId("dream-plan-summary")).toBeTruthy(); + expect(isDisabled(screen.getByRole("button", { name: "Apply" }))).toBe(false); + expect(isDisabled(screen.getByRole("button", { name: "Dismiss" }))).toBe(false); + }); + + it("decide:false disables Apply with a plain sentence and notes it on the card", async () => { + const user = userEvent.setup(); + setManualDream({ + user_model: { + decide: false, + generate: true, + unavailableReason: "target store lacks reviewed atomic consolidation", + }, + }); + renderCard(); + expect(text(await screen.findByTestId("dream-unavailable"))).toContain( + "Suggestions for this memory can be viewed but not applied.", + ); + await generatePlan(user); + const apply = screen.getByRole("button", { name: "Apply" }); + expect(isDisabled(apply)).toBe(true); + expect(document.body.textContent).not.toMatch(/daemon|atomic/i); + }); + + it("never names the daemon", () => { + renderCard(); + expect(document.body.textContent).not.toMatch(/daemon|mecated/i); + }); +}); diff --git a/apps/web/src/features/memory/consolidate-memory-card.tsx b/apps/web/src/features/memory/consolidate-memory-card.tsx new file mode 100644 index 0000000000..8b2a66030d --- /dev/null +++ b/apps/web/src/features/memory/consolidate-memory-card.tsx @@ -0,0 +1,507 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { MemoryConsolidationTarget } from "@mecatl-studio/contracts"; +import type { + DecideMemoryConsolidationPlanResponse, + GenerateMemoryConsolidationPlanResponse, + GetRuntimeResponse, +} from "@mecatl-studio/contracts/generated"; +import { + decideMemoryConsolidationPlanMutation, + generateMemoryConsolidationPlanMutation, + getRuntimeOptions, + listUserMemoryQueryKey, +} from "@mecatl-studio/contracts/query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../../components/ui/alert-dialog"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../components/ui/select"; +import { errorMessage } from "../knowledge/format"; +import { SettingsCard } from "../settings/settings-card"; + +/** + * Consolidate memory ("dream" review): the agent suggests which of its + * memories to merge and the person applies or dismisses the WHOLE + * suggestion — this deployment never composes memory content, only approves + * what the daemon curated. The card offers a `target` picker over the + * memories the agent can consolidate. + * + * A decision whose outcome is still open locks the card to retrying that + * SAME decision (`dream_in_progress`, or a dropped connection — the answer + * may already have applied); a suggestion that is no longer valid clears + * with a "consolidate again" notice (`dream_not_found`/`dream_conflict`); a + * target the agent cannot consolidate is offered disabled, not hidden. + */ + +type ManualDream = NonNullable; +type TargetCapability = NonNullable; + +const TARGET_LABELS: Record = { + project_memory: "Project memory", + user_model: "Facts about you", +}; + +/** The two targets, in the order the picker lists them. */ +const TARGETS: readonly MemoryConsolidationTarget[] = ["user_model", "project_memory"]; + +const STILL_WORKING = "The agent is still working on that. Press Try again to see the result."; +const NO_LONGER_VALID = + "That suggestion is no longer valid, so nothing changed. Consolidate again for a new one."; +const CANNOT_SUGGEST = "Consolidation isn't available for this memory right now."; +const CANNOT_APPLY = "Suggestions for this memory can be viewed but not applied."; +const DECISION_RUNNING = "Waiting for the other decision to finish."; + +type Confirmation = "apply" | "generate"; + +const CONFIRMATIONS: Record< + Confirmation, + { confirmText: string; description: string; title: string } +> = { + apply: { + confirmText: "Apply", + description: + "The memories are merged exactly as shown. Anything that changed in the meantime is left alone.", + title: "Apply these changes?", + }, + generate: { + confirmText: "Continue", + description: + "The agent reads everything it remembers and suggests which entries to merge. Nothing changes until you approve.", + title: "Consolidate memory?", + }, +}; + +/** The targets the agent reports at all, whether or not it can generate — + * so a target that exists but is unavailable shows disabled with its + * reason rather than silently disappearing. Empty against an older daemon + * without the capability, which hides the whole card. */ +function listConsolidationTargets( + manualDream: ManualDream | undefined, +): MemoryConsolidationTarget[] { + return TARGETS.filter((target) => targetEntry(manualDream, target)); +} + +function targetEntry( + manualDream: ManualDream | undefined, + target: MemoryConsolidationTarget, +): TargetCapability | undefined { + return target === "project_memory" ? manualDream?.projectMemory : manualDream?.userModel; +} + +function targetCapability( + manualDream: ManualDream | undefined, + target: MemoryConsolidationTarget, +): TargetCapability { + return targetEntry(manualDream, target) ?? { decide: false, generate: false }; +} + +/** The result as one line: what merged, then anything left out. */ +export function describeMemoryConsolidationReceipt( + receipt: DecideMemoryConsolidationPlanResponse, +): string { + const noun = receipt.planned === 1 ? "memory" : "memories"; + const parts = [`${receipt.applied} of ${receipt.planned} ${noun} merged`]; + if (receipt.conflicted > 0) parts.push(`${receipt.conflicted} left alone`); + if (receipt.skipped > 0) parts.push(`${receipt.skipped} skipped`); + if (receipt.failed > 0) parts.push(`${receipt.failed} failed`); + return parts.join(" · "); +} + +/** A dismissal changes nothing, so it carries no per-memory outcome. */ +function isDismissed(receipt: DecideMemoryConsolidationPlanResponse) { + return receipt.disposition === "dismiss" || receipt.disposition === "dismissed"; +} + +const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`; + +/** + * How long until `ts` (epoch milliseconds), in the largest whole unit: + * "<1m", "14m", "3h", "2d". Empty for a missing or nonsensical timestamp. + */ +export function formatUntilTime(ts: number, now = Date.now()): string { + if (!ts || ts < 1000) return ""; + const diffMin = Math.floor((ts - now) / 60000); + if (diffMin < 1) return "<1m"; + if (diffMin < 60) return `${diffMin}m`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h`; + return `${Math.floor(diffHr / 24)}d`; +} + +/** + * True when the decision's outcome is still open: the daemon reports the + * SAME decision is running (`dream_in_progress`), or the answer never + * arrived at all (a thrown non-problem error — a dropped connection, so the + * first request may already have applied). The only safe next step is to + * retry the IDENTICAL decision; decide is idempotent and answers the + * authoritative receipt. + */ +export function isDreamInProgress(error: unknown): boolean { + if (typeof error !== "object" || error === null) return true; + if ("code" in error) return error.code === "dream_in_progress"; + return true; +} + +/** + * True when the plan is no longer actionable for THIS decision — the id no + * longer resolves (unknown, expired, or minted by a daemon process that has + * since restarted, `dream_not_found`), or a conflicting/terminal decision + * already won (`dream_conflict`/`dream_terminal_conflict`). The fix is + * regenerate, not retry. + */ +export function isStaleMemoryPlan(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + if ( + "code" in error && + (error.code === "dream_not_found" || + error.code === "dream_conflict" || + error.code === "dream_terminal_conflict") + ) + return true; + return "status" in error && (error.status === 404 || error.status === 410); +} + +export function ConsolidateMemoryCard() { + const queryClient = useQueryClient(); + const runtime = useQuery(getRuntimeOptions()); + const connected = runtime.data?.connection === "online"; + const manualDream = runtime.data?.capabilities.manualDream; + const targets = useMemo(() => listConsolidationTargets(manualDream), [manualDream]); + + const generate = useMutation(generateMemoryConsolidationPlanMutation()); + const decide = useMutation(decideMemoryConsolidationPlanMutation()); + const [chosenTarget, setChosenTarget] = useState(null); + const [plan, setPlan] = useState(); + const [receipt, setReceipt] = useState(); + // The decision whose outcome is still open; only that decision may be + // retried while it is set (never automatically). + const [pendingDecision, setPendingDecision] = useState<"apply" | "dismiss" | null>(null); + const [notice, setNotice] = useState(); + const [confirming, setConfirming] = useState(null); + + // Absent capability → the card hides entirely (an older agent). + if (targets.length === 0) return null; + + const effectiveTarget: MemoryConsolidationTarget = + chosenTarget && targets.includes(chosenTarget) + ? chosenTarget + : // `targets[0]` is safe: the early return above guarantees length > 0. + (targets.find((candidate) => targetCapability(manualDream, candidate).generate) ?? + (targets[0] as MemoryConsolidationTarget)); + const capability = targetCapability(manualDream, effectiveTarget); + + async function generatePlan() { + setPlan(undefined); + setReceipt(undefined); + setNotice(undefined); + setPendingDecision(null); + try { + setPlan(await generate.mutateAsync({ body: { target: effectiveTarget } })); + } catch { + // The generated mutation error is rendered below. + } + } + + async function decidePlan(decision: "apply" | "dismiss") { + if (!plan) return; + setNotice(undefined); + try { + const result = await decide.mutateAsync({ + body: { decision }, + path: { planId: plan.id }, + }); + setReceipt(result); + setPlan(undefined); + setPendingDecision(null); + if (decision === "apply") + await queryClient.invalidateQueries({ queryKey: listUserMemoryQueryKey() }); + } catch (error) { + if (isStaleMemoryPlan(error)) { + setPlan(undefined); + setPendingDecision(null); + setNotice(NO_LONGER_VALID); + } else if (isDreamInProgress(error)) { + setPendingDecision(decision); + setNotice(STILL_WORKING); + } + // An unclassified failure: the suggestion stays reviewable; an + // already-open decision stays locked until a terminal answer arrives. + } + } + + function confirmed() { + const action = confirming; + setConfirming(null); + if (action === "generate") void generatePlan(); + else if (action === "apply") void decidePlan("apply"); + } + + const generateBlocked = capability.generate ? undefined : CANNOT_SUGGEST; + const applyBlocked = + pendingDecision === "dismiss" ? DECISION_RUNNING : capability.decide ? undefined : CANNOT_APPLY; + const dismissBlocked = pendingDecision === "apply" ? DECISION_RUNNING : undefined; + + const expiresIn = + plan?.expiresAt && Date.parse(plan.expiresAt) > Date.now() + ? formatUntilTime(Date.parse(plan.expiresAt)) + : ""; + + const error = generate.error ?? decide.error; + const confirmation = confirming ? CONFIRMATIONS[confirming] : undefined; + + return ( + +
+ {!plan && ( +
+ {targets.length > 1 ? ( + + ) : ( + + {TARGET_LABELS[effectiveTarget]} + + )} + +
+ )} + {(!capability.generate || !capability.decide) && ( +

+ {capability.generate ? CANNOT_APPLY : CANNOT_SUGGEST} +

+ )} + {generate.isPending && ( +

+ The agent is reviewing its memory. This can take a minute. +

+ )} + {notice && ( +

+ {notice} +

+ )} + {error && !notice && ( +

+ {errorMessage(error)} +

+ )} + + {plan && plan.operations.length === 0 && ( +
+

+ Nothing to merge. Memory is already tidy. +

+ +
+ )} + + {plan && plan.operations.length > 0 && ( +
+

+ {plural(plan.plannedOperationCount, "suggested change", "suggested changes")} across{" "} + {plural(plan.plannedSourceCount, "memory", "memories")}. Apply or dismiss them all + together. + {expiresIn && Expires in {expiresIn}.} +

+
    + {plan.operations.map((operation) => ( +
  • source.key) + .join(",")}`} + > +
    + {operation.kind.replaceAll("_", " ") || "merge"} + {operation.exactDuplicateEligible && ( + exact duplicate + )} + {operation.reason && ( + {operation.reason} + )} +
    +
    +

    + Keeps {operation.survivor.key} + {operation.sources.length > 0 && ( + <> + {" "} + and merges in{" "} + {operation.sources.map((source, i) => ( + + {i > 0 && ", "} + {source.key} + + ))} + + )} +

    + {operation.replacement.value && ( +
    +                        {operation.replacement.value}
    +                      
    + )} + {operation.replacement.description && ( +

    {operation.replacement.description}

    + )} +
    + + Show current values + +
    + + {operation.sources.map((source) => ( + + ))} +
    +
    +
    +
  • + ))} +
+
+ + +
+
+ )} + + {receipt && ( +
+

+ {isDismissed(receipt) + ? "Dismissed. Nothing changed." + : `Done: ${describeMemoryConsolidationReceipt(receipt)}.`} +

+ {!isDismissed(receipt) && receipt.conflicted > 0 && ( +

+ Some memories changed while you were reviewing, so they were left alone. Consolidate + again to review them. +

+ )} + {!isDismissed(receipt) && receipt.failed > 0 && ( +

Some memories couldn’t be merged. The counts above are the final result.

+ )} +
+ )} +
+ + !open && setConfirming(null)} open={Boolean(confirming)}> + + + {confirmation?.title} + {confirmation?.description} + + + Cancel + {confirmation?.confirmText} + + + +
+ ); +} + +/** One memory's current stored value and description — what the change + * keeps or merges in. */ +function ParticipantDetail({ + label, + participant, +}: { + label: string; + participant: { description: string; key: string; value: string }; +}) { + return ( +
+
+ {label} + {participant.key || "(unnamed)"} +
+
+
+          {participant.value || "(empty)"}
+        
+ {participant.description && ( +

{participant.description}

+ )} +
+
+ ); +} diff --git a/apps/web/src/features/memory/facts-about-you.test.tsx b/apps/web/src/features/memory/facts-about-you.test.tsx new file mode 100644 index 0000000000..86384ed916 --- /dev/null +++ b/apps/web/src/features/memory/facts-about-you.test.tsx @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// @vitest-environment happy-dom + +import { listUserMemoryQueryKey } from "@mecatl-studio/contracts/query"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { FactsAboutYou } from "./facts-about-you"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +afterEach(cleanup); + +type MemoryList = { + items: { description: string; key: string }[]; + reason: string; + sha256: string; + sizeBytes: string; + supported: boolean; +}; + +function seeded(list?: MemoryList, error?: unknown) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, retryOnMount: false, staleTime: Number.POSITIVE_INFINITY }, + }, + }); + if (list) client.setQueryData(listUserMemoryQueryKey(), list); + if (error) { + client + .getQueryCache() + .build(client, { queryKey: listUserMemoryQueryKey() }) + .setState({ + error: error as Error, + status: "error", + }); + } + return client; +} + +async function renderFacts(client: QueryClient) { + const root = createRootRoute({ component: Outlet }); + const settings = createRoute({ + component: FactsAboutYou, + getParentRoute: () => root, + path: "/workspace/settings/memory", + }); + const detail = createRoute({ + component: () => null, + getParentRoute: () => root, + path: "/workspace/memory", + }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/workspace/settings/memory"] }), + routeTree: root.addChildren([settings, detail]), + }); + await act(async () => { + render( + + + , + ); + await router.load(); + }); +} + +const supported = (items: MemoryList["items"]): MemoryList => ({ + items, + reason: "", + sha256: "abc", + sizeBytes: "42", + supported: true, +}); + +describe("FactsAboutYou", () => { + it("says the store is off instead of showing an empty list", async () => { + await renderFacts(seeded({ ...supported([]), reason: "not enabled", supported: false })); + expect(screen.getByText("Facts about you is off, so there is nothing to show.")).toBeTruthy(); + }); + + it("distinguishes an enabled but empty store", async () => { + await renderFacts(seeded(supported([]))); + expect(screen.getByText("The agent hasn’t remembered anything about you yet.")).toBeTruthy(); + }); + + it("counts facts without byte sizes and lists them in a table", async () => { + await renderFacts( + seeded( + supported([ + { description: "Prefers Italian replies", key: "language" }, + { description: "", key: "team/voice" }, + ]), + ), + ); + expect(screen.getByTestId("memory-footprint").textContent).toBe("2 facts remembered"); + expect(document.body.textContent).not.toMatch(/bytes/); + const table = screen.getByRole("table"); + expect(within(table).getByRole("button", { name: /Name/ })).toBeTruthy(); + expect(within(table).getByRole("button", { name: /Remembers/ })).toBeTruthy(); + const link = within(table).getByRole("link", { name: "team/voice" }); + expect(link.getAttribute("href")).toBe("/workspace/memory?item=team%2Fvoice"); + expect(within(table).getAllByText("No description recorded.").length).toBeGreaterThan(0); + }); + + it("sorts by name and by what each fact remembers", async () => { + await renderFacts( + seeded( + supported([ + { description: "b remembers", key: "alpha" }, + { description: "a remembers", key: "beta" }, + ]), + ), + ); + const names = () => + within(screen.getByRole("table")) + .getAllByRole("link") + .map((link) => link.textContent); + expect(names()).toEqual(["alpha", "beta"]); + fireEvent.click(screen.getByRole("button", { name: /Name/ })); + expect(names()).toEqual(["beta", "alpha"]); + fireEvent.click(screen.getByRole("button", { name: /Remembers/ })); + expect(names()).toEqual(["beta", "alpha"]); + }); + + it("uses singular wording for one fact", async () => { + await renderFacts(seeded(supported([{ description: "x", key: "only" }]))); + expect(screen.getByTestId("memory-footprint").textContent).toBe("1 fact remembered"); + }); + + it("reports a load failure as an alert", async () => { + await renderFacts(seeded(undefined, { detail: "Memory could not be read." })); + expect(screen.getByRole("alert").textContent).toBe("Memory could not be read."); + }); +}); diff --git a/apps/web/src/features/memory/facts-about-you.tsx b/apps/web/src/features/memory/facts-about-you.tsx new file mode 100644 index 0000000000..62acea5a0c --- /dev/null +++ b/apps/web/src/features/memory/facts-about-you.tsx @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { UserMemoryResponse } from "@mecatl-studio/contracts"; +import { listUserMemoryOptions } from "@mecatl-studio/contracts/query"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { SortableHead, type SortDirection } from "../../components/ui/sortable-head"; +import { Table, TableBody, TableCell, TableHeader, TableRow } from "../../components/ui/table"; +import { Note, SettingsCard } from "../settings/settings-card"; + +type FactSortKey = "name" | "remembers"; +type MemoryEntry = UserMemoryResponse["items"][number]; + +const NO_DESCRIPTION = "No description recorded."; + +/** + * Settings → Memory → Facts about you: what the agent has remembered about + * this user, as a sortable table whose rows open the fact's detail page. The + * agent writes these facts; Studio only reads them. + */ +export function FactsAboutYou() { + const query = useQuery(listUserMemoryOptions()); + const [sort, setSort] = useState<{ direction: SortDirection; key: FactSortKey }>({ + direction: "asc", + key: "name", + }); + + let body: React.ReactNode; + if (query.isPending) { + body = Loading memory…; + } else if (query.isError) { + body = {errorMessage(query.error)}; + } else if (!query.data.supported) { + body = Facts about you is off, so there is nothing to show.; + } else if (query.data.items.length === 0) { + body = The agent hasn’t remembered anything about you yet.; + } else { + const rows = sortFacts(query.data.items, sort.key, sort.direction); + const toggle = (key: FactSortKey) => + setSort((current) => ({ + direction: current.key === key && current.direction === "asc" ? "desc" : "asc", + key, + })); + const direction = (key: FactSortKey) => (sort.key === key ? sort.direction : undefined); + body = ( +
+

+ {footprint(rows.length)} +

+
+ + + + toggle("name")} + /> + toggle("remembers")} + /> + + + + {rows.map((entry) => ( + + + + {entry.key} + + {/* Mobile collapses to one stacked cell. */} +

+ {entry.description || NO_DESCRIPTION} +

+
+ +

+ {entry.description || NO_DESCRIPTION} +

+
+
+ ))} +
+
+
+
+ ); + } + + return {body}; +} + +function footprint(count: number) { + return `${count} ${count === 1 ? "fact" : "facts"} remembered`; +} + +function sortFacts(items: MemoryEntry[], key: FactSortKey, direction: SortDirection) { + const byName = (a: MemoryEntry, b: MemoryEntry) => a.key.localeCompare(b.key); + const primary = (a: MemoryEntry, b: MemoryEntry) => + key === "remembers" ? (a.description || "").localeCompare(b.description || "") : byName(a, b); + const sign = direction === "asc" ? 1 : -1; + return [...items].sort((a, b) => sign * primary(a, b) || byName(a, b)); +} + +function errorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "detail" in error) return String(error.detail); + return error instanceof Error ? error.message : "Memory could not be loaded."; +} diff --git a/apps/web/src/features/memory/memory-fact-detail.test.tsx b/apps/web/src/features/memory/memory-fact-detail.test.tsx new file mode 100644 index 0000000000..5b6920e904 --- /dev/null +++ b/apps/web/src/features/memory/memory-fact-detail.test.tsx @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: Apache-2.0 +// @vitest-environment happy-dom + +import type { MemoryDetailResponse, UserMemoryResponse } from "@mecatl-studio/contracts"; +import { client as apiClient } from "@mecatl-studio/contracts/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createMemoryHistory, createRouter, RouterContextProvider } from "@tanstack/react-router"; +import { act, cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { routeTree } from "../../routeTree.gen"; +import { MemoryFactDetail } from "./memory-fact-detail"; + +/** + * The memory fact detail page: the fact's value leads, daemon-derived + * provenance rows follow, the source session links to its chat, and the bounded + * history renders newest-first or is reported as unavailable from this + * store/driver. Read-only. Ported from the design prototype's test. + */ + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const state = { + connection: "online" as string, + detail: undefined as unknown, + detailError: undefined as { detail: string; status: number } | undefined, + detailPending: false, + list: undefined as unknown, + listPending: false, +}; + +const initialApiConfig = apiClient.getConfig(); + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + headers: { "content-type": status < 400 ? "application/json" : "application/problem+json" }, + status, + }); +} + +function problem(status: number, detail: string) { + return json({ code: "", detail, instance: "", status, title: "", type: "about:blank" }, status); +} + +/** Fakes the BFF reads the page makes; never resolves a read marked pending. */ +const fakeFetch: typeof globalThis.fetch = async (input) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname === "/api/v1/runtime") + return json({ capabilities: {}, connection: state.connection }); + if (url.pathname === "/api/v1/user-memory") { + if (state.listPending) return new Promise(() => {}); + return json(state.list); + } + if (url.pathname.startsWith("/api/v1/user-memory/")) { + if (state.detailPending) return new Promise(() => {}); + if (state.detailError) return problem(state.detailError.status, state.detailError.detail); + return json(state.detail); + } + return problem(404, `unexpected ${url.pathname}`); +}; + +const UPDATED_AT = "2026-01-10T00:00:00.000Z"; +const PRIOR_AT = "2025-12-01T00:00:00.000Z"; + +function index( + items: UserMemoryResponse["items"] = [ + { description: "The operator prefers tabs.", key: "prefers-tabs" }, + ], +): UserMemoryResponse { + return { items, reason: "", sha256: "", sizeBytes: "0", supported: true }; +} + +function detail(overrides: Partial = {}): MemoryDetailResponse { + return { + current: { + description: "The operator prefers tabs.", + key: "prefers-tabs", + origin: "reflection", + sourceSessionId: "", + status: "active", + updatedAt: UPDATED_AT, + value: "Tabs, width 4", + version: "3", + writer: "agent", + ...overrides, + }, + history: [ + { + description: "", + key: "prefers-tabs", + origin: "", + sourceSessionId: "", + status: "superseded", + updatedAt: "2025-12-20T00:00:00.000Z", + value: "", + version: "2", + writer: "", + }, + { + description: "", + key: "prefers-tabs", + origin: "", + sourceSessionId: "", + status: "superseded", + updatedAt: PRIOR_AT, + value: "", + version: "1", + writer: "", + }, + ], + historyAvailable: true, + }; +} + +async function renderDetail(memoryId = "prefers-tabs") { + apiClient.setConfig({ baseUrl: window.location.origin, fetch: fakeFetch }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const router = createRouter({ + history: createMemoryHistory({ + initialEntries: [`/workspace/memory?item=${encodeURIComponent(memoryId)}`], + }), + routeTree, + }); + await act(async () => { + render( + + + + + , + ); + }); +} + +/** The value cell of the Details row labelled `label`. */ +function factValue(label: string): HTMLElement { + const value = screen.getByText(label, { selector: "span" }).nextElementSibling; + if (!(value instanceof HTMLElement)) throw new Error(`fact "${label}" has no value cell`); + return value; +} + +beforeEach(() => { + state.connection = "online"; + state.listPending = false; + state.list = index(); + state.detailError = undefined; + state.detailPending = false; + state.detail = detail(); +}); + +afterEach(() => { + apiClient.setConfig({ baseUrl: initialApiConfig.baseUrl, fetch: initialApiConfig.fetch }); + cleanup(); +}); + +describe("MemoryFactDetail", () => { + it("leads with the fact value and shows daemon-derived provenance rows", async () => { + await renderDetail(); + + const value = await screen.findByText("Tabs, width 4"); + expect(value.tagName).toBe("PRE"); + expect( + within(screen.getByRole("region", { name: "Value" })).getByText("The operator prefers tabs."), + ).toBeTruthy(); + expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("prefers-tabs"); + + expect(factValue("Key").textContent).toBe("prefers-tabs"); + expect(factValue("Status").textContent).toBe("active"); + expect(factValue("Version").textContent).toBe("3"); + expect(factValue("Writer").textContent).toBe("agent"); + expect(factValue("Origin").textContent).toBe("reflection"); + + const updated = within(factValue("Updated")).getByText(/ago$/); + expect(updated.getAttribute("title")).toBe(UPDATED_AT); + }); + + it("shows the value as loading while the detail read is in flight", async () => { + state.detailPending = true; + await renderDetail(); + const value = await screen.findByRole("region", { name: "Value" }); + expect(value.textContent).toContain("Loading value…"); + expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("prefers-tabs"); + }); + + it("links the source session to its chat", async () => { + state.detail = detail({ sourceSessionId: "session-fixture-1" }); + await renderDetail(); + await screen.findByText("Tabs, width 4"); + + expect(screen.getByRole("link", { name: "session-fixture-1" }).getAttribute("href")).toBe( + "/workspace/chat?sessionId=session-fixture-1", + ); + }); + + it("lists the bounded revisions newest-first as version · status · date", async () => { + await renderDetail(); + await screen.findByText("Tabs, width 4"); + + const history = screen.getByRole("region", { name: "History" }); + expect(history.textContent).toContain("2 bounded revisions"); + const rows = within(history).getAllByRole("listitem"); + expect(rows.map((row) => row.textContent)).toEqual([ + "2 · superseded · 2025-12-20", + "1 · superseded · 2025-12-01", + ]); + }); + + it("reports history the store/driver could not supply, never as 'no revisions'", async () => { + state.detail = { ...detail(), history: [], historyAvailable: false }; + await renderDetail(); + await screen.findByText("Tabs, width 4"); + + const history = screen.getByRole("region", { name: "History" }); + expect(history.textContent).toContain("History unavailable from this store/driver."); + expect(history.textContent).not.toContain("bounded revision"); + }); + + it("says the value is unavailable when the read fails", async () => { + state.detailError = { detail: "store offline", status: 503 }; + await renderDetail(); + expect(await screen.findByText("Value unavailable: store offline")).toBeTruthy(); + expect(factValue("Key").textContent).toBe("prefers-tabs"); + }); + + it("stays read-only: the footer names the agent's memory tools, no editor", async () => { + await renderDetail(); + await screen.findByText("Tabs, width 4"); + expect( + screen.getByText("Read-only — ask the agent to use ForgetUserMemory or UndoUserMemory."), + ).toBeTruthy(); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + + it("treats a still-loading index as loading, never as a missing fact", async () => { + state.listPending = true; + await renderDetail(); + expect(await screen.findByText("Loading…")).toBeTruthy(); + expect(screen.queryByText("Memory not found")).toBeNull(); + }); + + it("reports a fact the settled index lacks as missing, in-page and recoverable", async () => { + state.list = index([]); + await renderDetail(); + expect(await screen.findByText("Memory not found")).toBeTruthy(); + expect(screen.getByText(/the agent may have forgotten or renamed it\./)).toBeTruthy(); + const back = screen.getByRole("link", { name: "Back to Memory" }); + expect(back.getAttribute("href")).toBe("/workspace/settings/memory"); + }); + + it("reports a fact the detail read answers 404 for as missing", async () => { + state.detailError = { detail: "No memory entry named prefers-tabs", status: 404 }; + await renderDetail(); + expect(await screen.findByText("Memory not found")).toBeTruthy(); + expect(screen.queryByText(/Value unavailable/)).toBeNull(); + }); + + it("names an unreachable agent instead of claiming the fact is gone", async () => { + state.connection = "offline"; + await renderDetail(); + expect(await screen.findByText("Memory not found")).toBeTruthy(); + expect( + screen.getByText("The agent can't be reached, so this fact can't be looked up right now."), + ).toBeTruthy(); + }); + + it("says memory is off, with the daemon's own reason, when the store is disabled", async () => { + state.list = { + items: [], + reason: "User memory is not enabled on this Mecatl deployment.", + sha256: "", + sizeBytes: "0", + supported: false, + }; + await renderDetail(); + expect(await screen.findByText("Memory is turned off for this agent.")).toBeTruthy(); + expect(screen.getByText("User memory is not enabled on this Mecatl deployment.")).toBeTruthy(); + }); + + it("the back pill returns to Settings → Memory", async () => { + await renderDetail(); + await screen.findByText("Tabs, width 4"); + // The chevron is aria-hidden, so the accessible name is just "Back". + const back = screen.getByRole("link", { name: "Back" }); + expect(back.getAttribute("href")).toBe("/workspace/settings/memory"); + }); +}); diff --git a/apps/web/src/features/memory/memory-fact-detail.tsx b/apps/web/src/features/memory/memory-fact-detail.tsx new file mode 100644 index 0000000000..86a8a822e1 --- /dev/null +++ b/apps/web/src/features/memory/memory-fact-detail.tsx @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { MemoryDetailResponse } from "@mecatl-studio/contracts"; +import { + getRuntimeOptions, + getUserMemoryOptions, + listUserMemoryOptions, +} from "@mecatl-studio/contracts/query"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import type { ReactNode } from "react"; +import { PageShell } from "../../components/shell/page-shell"; +import { Button } from "../../components/ui/button"; +import { pageTitleClass } from "../../lib/typography"; +import { formatRelativeTime } from "../chat/latest-chat"; + +type MemoryRevision = MemoryDetailResponse["current"]; + +/** + * The memory fact detail page (`/workspace/memory?item=`), outside the + * Settings layout like the skill and schedule details. The list it backs out to + * stays under Settings → Memory. + * + * SPEC: the index gates the page. A still-loading index reads "Loading…", an + * unsupported store says memory is off with the daemon's reason, and a key the + * settled index lacks (or the detail read answers 404 for) is "Memory not + * found", recoverable through "Back to Memory". When the agent can't be reached + * the not-found card says so instead of claiming the fact is gone. + * + * DECISION: read-only by construction. The agent curates memory itself, so the + * footer names the two tools that change it instead of offering an editor. + */ +export function MemoryFactDetail({ memoryId }: { memoryId: string }) { + const runtime = useQuery(getRuntimeOptions()); + const connected = runtime.data?.connection === "online"; + const list = useQuery({ ...listUserMemoryOptions(), enabled: connected }); + const detail = useQuery({ + ...getUserMemoryOptions({ path: { memoryKey: memoryId } }), + enabled: connected && memoryId !== "", + }); + + const supported = list.data?.supported ?? true; + const entry = list.data?.supported + ? list.data.items.find((item) => item.key === memoryId) + : undefined; + + if (!entry || isNotFound(detail.error)) { + if (runtime.isPending || (connected && list.isPending)) { + return ( +
+ Loading… +
+ ); + } + if (!supported) { + return ( +
+

Memory is turned off for this agent.

+ {list.data?.reason &&

{list.data.reason}

} +
+ ); + } + const reachable = connected && !list.isError; + return ( +
+
+

Memory not found

+

+ {reachable ? ( + <> + No remembered fact has the key {memoryId}{" "} + — the agent may have forgotten or renamed it. + + ) : ( + "The agent can't be reached, so this fact can't be looked up right now." + )} +

+ +
+
+ ); + } + + const current = detail.data?.current ?? null; + const description = current?.description || entry.description; + + return ( + + + +

+ {entry.key} +

+ +
+
+ {detail.isPending ? ( +

Loading value…

+ ) : detail.isError ? ( +

Value unavailable: {errorMessage(detail.error)}

+ ) : ( +
+              {current?.value || "(empty value)"}
+            
+ )} + {description && ( +

{description}

+ )} +
+ +
+

+ Details +

+
+ + {memoryId} + + {current && } +
+
+ + {detail.data && } + +

+ Read-only — ask the agent to use ForgetUserMemory or UndoUserMemory. +

+
+
+ ); +} + +/** The provenance rows of the current revision; an empty field has no row. */ +function RevisionRows({ revision }: { revision: MemoryRevision }) { + return ( + <> + {revision.status && {revision.status}} + {revision.version && ( + + {revision.version} + + )} + {revision.writer && {revision.writer}} + {revision.origin && {revision.origin}} + {revision.sourceSessionId && ( + + + {revision.sourceSessionId} + + + )} + {revision.updatedAt && ( + + + + )} + + ); +} + +/** + * The bounded prior revisions, in the order the daemon delivered them (newest + * first). A store/driver that supplied only the current value says so, which is + * a different fact from "no prior revisions". + */ +function HistorySection({ detail }: { detail: MemoryDetailResponse }) { + return ( +
+

+ History +

+ {detail.historyAvailable ? ( +
+

+ {detail.history.length}{" "} + {detail.history.length === 1 ? "bounded revision" : "bounded revisions"} +

+ {detail.history.length > 0 && ( +
    + {detail.history.map((prior) => ( +
  • + {prior.version || "?"} · {prior.status || "?"} ·{" "} + {prior.updatedAt ? prior.updatedAt.slice(0, 10) : "—"} +
  • + ))} +
+ )} +
+ ) : ( +

+ History unavailable from this store/driver. +

+ )} +
+ ); +} + +/** One label/value row in the Details group. */ +function FactRow({ + children, + label, + mono = false, +}: { + children: ReactNode; + label: string; + mono?: boolean; +}) { + return ( +
+ {label} + + {children} + +
+ ); +} + +function isNotFound(error: unknown) { + return typeof error === "object" && error !== null && "status" in error && error.status === 404; +} + +function errorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "detail" in error) return String(error.detail); + return error instanceof Error ? error.message : "The request could not be completed."; +} diff --git a/apps/web/src/features/memory/memory-settings-page.tsx b/apps/web/src/features/memory/memory-settings-page.tsx new file mode 100644 index 0000000000..f6b2df404f --- /dev/null +++ b/apps/web/src/features/memory/memory-settings-page.tsx @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { ServerCapabilitiesResponse } from "@mecatl-studio/contracts"; +import { ConsolidateMemoryCard } from "./consolidate-memory-card"; +import { FactsAboutYou } from "./facts-about-you"; +import { MemoryStores } from "./memory-stores"; + +/** + * Settings → Memory: the read-only stores card (when the runtime's capabilities + * are known), the remembered facts, then the consolidation review, which hides + * itself when the agent reports no consolidation target at all. + */ +export function MemorySettingsPage({ + capabilities, +}: { + capabilities?: Pick; +}) { + return ( + <> + {capabilities && } + + + + ); +} diff --git a/apps/web/src/features/memory/memory-stores.test.tsx b/apps/web/src/features/memory/memory-stores.test.tsx new file mode 100644 index 0000000000..f01dd59e64 --- /dev/null +++ b/apps/web/src/features/memory/memory-stores.test.tsx @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +// @vitest-environment happy-dom + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { MemoryStores } from "./memory-stores"; + +afterEach(cleanup); + +describe("MemoryStores", () => { + it("reports each store's on/off state from the runtime capabilities", () => { + render(); + + expect( + screen.getByText("Memory is set where the agent runs and can’t be changed here."), + ).toBeTruthy(); + expect(screen.getByText("Project memory")).toBeTruthy(); + expect(screen.getByText("Facts about you")).toBeTruthy(); + expect(screen.getByTestId("memory-status-project").textContent).toBe("On"); + expect(screen.getByTestId("memory-status-user-model").textContent).toBe("Off"); + }); + + it("is a read-only report with no editable control", () => { + render(); + + expect(screen.queryByRole("switch")).toBeNull(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + + it("never names the daemon, the BFF, or a flag", () => { + render(); + + expect(document.body.textContent).not.toMatch(/daemon|mecated|BFF|--/i); + }); +}); diff --git a/apps/web/src/features/memory/memory-stores.tsx b/apps/web/src/features/memory/memory-stores.tsx new file mode 100644 index 0000000000..2a87b74bf2 --- /dev/null +++ b/apps/web/src/features/memory/memory-stores.tsx @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { ServerCapabilitiesResponse } from "@mecatl-studio/contracts"; +import { Note, SettingsCard } from "../settings/settings-card"; + +/** + * Settings → Memory: which memory stores the running agent has, as a + * read-only report. Memory is configured where the agent runs; there is no + * write path for it in Studio, so this card offers no controls. + */ +const stores = [ + { capability: "memory", label: "Project memory", testId: "memory-status-project" }, + { capability: "userModel", label: "Facts about you", testId: "memory-status-user-model" }, +] as const; + +export function MemoryStores({ + capabilities, +}: { + capabilities: Pick; +}) { + return ( + +
+ Memory is set where the agent runs and can’t be changed here. +
+ {stores.map((store) => ( +
+

{store.label}

+ + {capabilities[store.capability] ? "On" : "Off"} + +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/features/settings/memory-settings.tsx b/apps/web/src/features/settings/memory-settings.tsx deleted file mode 100644 index 4815b16295..0000000000 --- a/apps/web/src/features/settings/memory-settings.tsx +++ /dev/null @@ -1,140 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import type { GetUserMemoryResponse } from "@mecatl-studio/contracts/generated"; -import { getUserMemoryOptions, listUserMemoryOptions } from "@mecatl-studio/contracts/query"; -import { useQuery } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; -import { PageShell } from "../../components/shell/page-shell"; -import { Badge } from "../../components/ui/badge"; -import { MemoryConsolidation } from "../knowledge/memory-consolidation"; -import { StateCard } from "../knowledge/state-card"; - -/** Settings → Memory: the facts the agent has remembered about this user. */ -export function MemorySettings() { - const query = useQuery(listUserMemoryOptions()); - if (query.isPending) return ; - if (query.isError) return ; - if (!query.data.supported) - return ; - if (query.data.items.length === 0) - return ( - - ); - return ( - <> - -
- - {query.data.items.length} remembered fact{query.data.items.length === 1 ? "" : "s"} - - {query.data.sizeBytes} bytes -
-
- {query.data.items.map((entry) => ( - - {entry.key} - - {entry.description || "No description recorded."} - - - ))} -
- - ); -} - -/** A stable, reloadable detail page for a single exact memory key. */ -export function MemoryFactDetail({ memoryKey }: { memoryKey: string }) { - const query = useQuery(getUserMemoryOptions({ path: { memoryKey } })); - return ( - - - ← Memory - -

Memory fact

-
- {query.isPending ? ( -

Loading memory…

- ) : query.isError ? ( -

{errorMessage(query.error)}

- ) : ( - - )} -
-
- ); -} - -function MemoryDetail({ detail }: { detail: GetUserMemoryResponse }) { - return ( - <> -
-

{detail.current.key}

- {detail.current.status && {detail.current.status}} -
-

{detail.current.description}

-

- {detail.current.value || "No value recorded."} -

-
-
-
Origin:
-
{detail.current.origin || "unknown"}
-
-
-
Writer:
-
{detail.current.writer || "unknown"}
-
- {detail.current.updatedAt && ( -
-
Updated:
-
{formatDate(detail.current.updatedAt)}
-
- )} -
- {detail.historyAvailable && detail.history.length > 0 && ( -
- - Revision history ({detail.history.length}) - -
    - {detail.history.map((revision) => ( -
  • - {revision.version} -

    {revision.value}

    -
  • - ))} -
-
- )} - - ); -} - -function formatDate(value: string) { - return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format( - new Date(value), - ); -} - -function errorMessage(error: unknown) { - if (typeof error === "object" && error !== null && "detail" in error) return String(error.detail); - return error instanceof Error ? error.message : "The request could not be completed."; -} diff --git a/apps/web/src/features/settings/settings-accessibility.test.tsx b/apps/web/src/features/settings/settings-accessibility.test.tsx index 8841f23bfd..ba50a25489 100644 --- a/apps/web/src/features/settings/settings-accessibility.test.tsx +++ b/apps/web/src/features/settings/settings-accessibility.test.tsx @@ -13,7 +13,6 @@ import { describe, expect, it } from "vitest"; import { routeTree } from "../../routeTree.gen"; import { ShortcutReference } from "../shortcuts/shortcut-reference"; import { AvatarPicker } from "./avatar-picker"; -import { MemoryFactDetail } from "./memory-settings"; import { ProviderDetail } from "./provider-detail"; import { SettingsWorkspace } from "./settings-workspace"; @@ -65,18 +64,13 @@ describe("settings responsive accessibility", () => { } }); - it("offers a named, focusable 44px return link on both detail pages and shortcuts", () => { + it("offers a named, focusable 44px return link on the provider detail and shortcuts", () => { const details = [ [ "/workspace/provider?providerId=team%2Fopenai", , /providers/i, ], - [ - "/workspace/memory?item=team%2Fvoice", - , - /memory/i, - ], ["/workspace/shortcuts", , /settings/i], ] as const; for (const [path, component, name] of details) { diff --git a/apps/web/src/features/settings/settings-navigation.test.tsx b/apps/web/src/features/settings/settings-navigation.test.tsx index a6770574ce..12500f16cb 100644 --- a/apps/web/src/features/settings/settings-navigation.test.tsx +++ b/apps/web/src/features/settings/settings-navigation.test.tsx @@ -12,7 +12,8 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { describe, expect, it } from "vitest"; import { routeTree } from "../../routeTree.gen"; -import { MemoryFactDetail, MemorySettings } from "./memory-settings"; +import { MemoryFactDetail } from "../memory/memory-fact-detail"; +import { MemorySettingsPage } from "../memory/memory-settings-page"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -39,7 +40,7 @@ describe("memory navigation", () => { root.render( - + , ); @@ -59,6 +60,14 @@ describe("memory navigation", () => { it("returns from a fact to the Memory list without a document navigation", async () => { const key = "team/voice"; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }); + queryClient.setQueryData(getRuntimeQueryKey(), { capabilities: {}, connection: "online" }); + queryClient.setQueryData(listUserMemoryQueryKey(), { + items: [{ description: "A voice preference", key }], + reason: "", + sha256: "abc", + sizeBytes: "42", + supported: true, + }); queryClient.setQueryData(getUserMemoryQueryKey({ path: { memoryKey: key } }), { current: { description: "A voice preference", @@ -85,13 +94,14 @@ describe("memory navigation", () => { root.render( - + , ); }); + expect(host.textContent).toContain("Use a calm voice."); const link = host.querySelector('a[href="/workspace/settings/memory"]'); - expect(link).not.toBeNull(); + expect(link?.textContent).toBe("‹Back"); const click = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }); await act(async () => link?.dispatchEvent(click)); expect(click.defaultPrevented).toBe(true); diff --git a/apps/web/src/features/settings/settings-routes.test.tsx b/apps/web/src/features/settings/settings-routes.test.tsx index bad1d50b70..bb3350118c 100644 --- a/apps/web/src/features/settings/settings-routes.test.tsx +++ b/apps/web/src/features/settings/settings-routes.test.tsx @@ -12,7 +12,8 @@ import { describe, expect, it } from "vitest"; import { stringSearchParams } from "../../lib/search-params"; import { routeTree } from "../../routeTree.gen"; import { authLoginUrl } from "../auth/auth-gate"; -import { MemoryFactDetail, MemorySettings } from "./memory-settings"; +import { MemoryFactDetail } from "../memory/memory-fact-detail"; +import { MemorySettingsPage } from "../memory/memory-settings-page"; import { isSettingsSection, settingsGroups } from "./settings-sections"; import { SettingsWorkspace } from "./settings-workspace"; @@ -118,7 +119,13 @@ describe("settings routes", () => { const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }); queryClient.setQueryData(getRuntimeQueryKey(), { - capabilities: { manualDream: { userModel: { decide: true, generate: true } } }, + capabilities: { + manualDream: { + projectMemory: { decide: false, generate: false }, + userModel: { decide: true, generate: true }, + }, + }, + connection: "online", }); queryClient.setQueryData(listUserMemoryQueryKey(), { items: [{ description: "A voice preference", key }], @@ -157,23 +164,23 @@ describe("settings routes", () => { const list = renderToStaticMarkup( - + , ); expect(list).toContain('href="/workspace/memory?item=team%2Fvoice"'); expect(list).toContain("Consolidate memory"); - expect(list).toContain("Generate plan"); + expect(list).toContain("Memory to consolidate"); const detail = renderToStaticMarkup( - + , ); expect(detail).toContain("Use a calm voice."); - expect(detail).toContain("Revision history (1)"); - expect(detail).toContain("Use a short voice."); + expect(detail).toContain("1 bounded revision"); + expect(detail).toContain("1 · active · —"); expect(detail).toContain('href="/workspace/settings/memory"'); }); diff --git a/apps/web/src/features/settings/settings-workspace.test.tsx b/apps/web/src/features/settings/settings-workspace.test.tsx index 92cec77427..c73c7bf2bf 100644 --- a/apps/web/src/features/settings/settings-workspace.test.tsx +++ b/apps/web/src/features/settings/settings-workspace.test.tsx @@ -77,7 +77,10 @@ function runtime(overrides: Partial = {}): GetRuntimeRespons learnedSkills: true, learningProposals: true, manualCompaction: true, - manualDream: { userModel: { decide: true, generate: true } }, + manualDream: { + projectMemory: { decide: false, generate: false }, + userModel: { decide: true, generate: true }, + }, mcp: true, mcpConnectorStatus: false, memory: true, @@ -415,9 +418,11 @@ describe("settings facts", () => { }); const page = await renderSection("memory", client); expect(page).toContain("Consolidate memory"); - expect(page).toContain("Generate plan"); + expect(page).toContain("Memory to consolidate"); expect(page).toContain('href="/workspace/memory?item=team%2Fvoice"'); - expect(page).toContain("deployment"); + expect(page).toContain("Memory is set where the agent runs"); + expect(page).toContain('data-testid="memory-stores"'); + expect(page).not.toContain("Source:"); }); it("explains managed sections without write controls", async () => { diff --git a/apps/web/src/features/settings/settings-workspace.tsx b/apps/web/src/features/settings/settings-workspace.tsx index ed0f888025..05e2f8d3b8 100644 --- a/apps/web/src/features/settings/settings-workspace.tsx +++ b/apps/web/src/features/settings/settings-workspace.tsx @@ -12,16 +12,7 @@ import { } from "@mecatl-studio/contracts/query"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { - BookOpen, - BrainCircuit, - Copy, - ExternalLink, - Keyboard, - LifeBuoy, - type LucideIcon, - Search, -} from "lucide-react"; +import { BookOpen, Copy, ExternalLink, Keyboard, LifeBuoy, Search } from "lucide-react"; import { type ReactNode, useMemo, useState } from "react"; import { AuthControl } from "../../components/shell/auth-control"; import { PageShell } from "../../components/shell/page-shell"; @@ -32,11 +23,11 @@ import { Switch } from "../../components/ui/switch"; import { modelPreferenceId, useDisabledModels } from "../../lib/model-preferences"; import { pageTitleClass } from "../../lib/typography"; import { LearningReview } from "../knowledge/learning-review"; +import { MemorySettingsPage } from "../memory/memory-settings-page"; import { AgentSettings } from "./agent-settings"; import { IdentitySettings } from "./identity-settings"; import { InterfaceSettings } from "./interface-settings"; import { managementNotes } from "./management-notes"; -import { MemorySettings } from "./memory-settings"; import { Note, SettingsCard } from "./settings-card"; import { connectionMessage, @@ -206,16 +197,7 @@ export function SettingsWorkspace({ (runtimeState ? ( ) : ( - <> -
- -

- Memory store configuration is managed by this deployment. Approved consolidation - plans can be generated and applied below. -

-
- - + ))} {section === "learning" && (runtimeState ? ( @@ -629,28 +611,6 @@ function DiagnosticsSettings({ ); } -function Section({ - children, - icon: Icon, - title, -}: { - children: ReactNode; - icon: LucideIcon; - title: string; -}) { - return ( -
-
- - - -

{title}

-
-
{children}
-
- ); -} - function Fact({ children, label }: { children: ReactNode; label: string }) { return (
@@ -660,14 +620,6 @@ function Fact({ children, label }: { children: ReactNode; label: string }) { ); } -function SourceNote({ source, owner }: { source: string; owner: string }) { - return ( -

- Source: {source}. Owner: {owner}. -

- ); -} - function ProviderState({ state }: { state: string }) { const normalized = state.toLowerCase(); if (normalized === "ok" || normalized === "available") { diff --git a/apps/web/src/features/shortcuts/help-features.test.ts b/apps/web/src/features/shortcuts/help-features.test.ts index feb8dd8ab1..6478633ed4 100644 --- a/apps/web/src/features/shortcuts/help-features.test.ts +++ b/apps/web/src/features/shortcuts/help-features.test.ts @@ -66,7 +66,7 @@ describe("deriveHelpFeatures", () => { expect(userModel.find((row) => row.id === "memory")?.enabled).toBe(true); }); - it("reads memory consolidation off the exact gate the Consolidate button uses", () => { + it("reads memory consolidation off whether any target can generate a plan", () => { const off = deriveHelpFeatures(allOff); expect(off.find((row) => row.id === "manual_dream")?.enabled).toBe(false); @@ -81,5 +81,14 @@ describe("deriveHelpFeatures", () => { manualDream: { userModel: { decide: true, generate: true } }, }); expect(generateReady.find((row) => row.id === "manual_dream")?.enabled).toBe(true); + + const projectOnly = deriveHelpFeatures({ + ...allOff, + manualDream: { + projectMemory: { decide: true, generate: true }, + userModel: { decide: false, generate: false }, + }, + }); + expect(projectOnly.find((row) => row.id === "manual_dream")?.enabled).toBe(true); }); }); diff --git a/apps/web/src/features/shortcuts/help-features.ts b/apps/web/src/features/shortcuts/help-features.ts index edd8bd42e8..3b49574395 100644 --- a/apps/web/src/features/shortcuts/help-features.ts +++ b/apps/web/src/features/shortcuts/help-features.ts @@ -45,9 +45,11 @@ const HELP_FEATURES: readonly HelpFeatureSpec[] = [ label: "Memory", }, { - // Mirrors the exact gate `memory-consolidation.tsx` uses to render its - // "Consolidate" button — only the user-model target is wired here. - enabled: (caps) => caps.manualDream?.userModel?.generate === true, + // Mirrors `consolidate-memory-card.tsx`: consolidation is on demand when + // the agent can generate a plan for at least one memory target. + enabled: (caps) => + caps.manualDream?.userModel?.generate === true || + caps.manualDream?.projectMemory?.generate === true, hint: "Consolidate memory on demand under Settings → Memory", id: "manual_dream", label: "Memory consolidation", diff --git a/apps/web/src/lib/fonts.test.ts b/apps/web/src/lib/fonts.test.ts index 59c979feb5..52ce53c757 100644 --- a/apps/web/src/lib/fonts.test.ts +++ b/apps/web/src/lib/fonts.test.ts @@ -19,8 +19,8 @@ describe("Studio typography", () => { expect(css).toMatch(/--font-sans:\s*"Inter Variable"/); expect(css).toMatch(/--font-serif:\s*"Merriweather"/); expect(css).toMatch(/body\s*\{[^}]*font-family:\s*var\(--font-sans\)/s); - expect(css).toMatch(/\.text-page-title\s*\{[^}]*font-family:\s*var\(--font-serif\)/s); - expect(css).toMatch(/\.text-page-title\s*\{[^}]*font-weight:\s*300/s); + expect(css).toMatch(/@utility text-page-title\s*\{[^}]*font-family:\s*var\(--font-serif\)/s); + expect(css).toMatch(/@utility text-page-title\s*\{[^}]*font-weight:\s*300/s); const { pageTitleClass } = await import("./typography"); expect(pageTitleClass()).toContain("text-page-title"); diff --git a/apps/web/src/routes/workspace.memory.tsx b/apps/web/src/routes/workspace.memory.tsx index 10715a2bfb..cf508c7b37 100644 --- a/apps/web/src/routes/workspace.memory.tsx +++ b/apps/web/src/routes/workspace.memory.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { createFileRoute, redirect } from "@tanstack/react-router"; -import { MemoryFactDetail } from "../features/settings/memory-settings"; +import { MemoryFactDetail } from "../features/memory/memory-fact-detail"; export const Route = createFileRoute("/workspace/memory")({ beforeLoad: ({ search }) => { @@ -23,5 +23,5 @@ export const Route = createFileRoute("/workspace/memory")({ function MemoryDetailPage() { const { item } = Route.useSearch(); if (!item) return null; - return ; + return ; } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 7567a3ba8d..8fc9db45c1 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -329,15 +329,14 @@ } } -@layer utilities { - .text-page-title { - font-family: var(--font-serif); - font-size: 34px; - line-height: 42px; - font-weight: 300; - letter-spacing: -0.85px; - color: var(--title); - } +/* A registered utility, so a page's own size/leading overrides (text-[44px]) sort after it. */ +@utility text-page-title { + font-family: var(--font-serif); + font-size: 34px; + line-height: 42px; + font-weight: 300; + letter-spacing: -0.85px; + color: var(--title); } @media (max-width: 499px) {