From e197b6c9a205a1c064d72d35090091d235883e70 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 14 Jun 2026 10:14:47 +0000 Subject: [PATCH 01/10] feat: consolidate project memory in dream runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include project-scope memory in dream consolidation for single-project workspaces, track explicit project coverage records, and surface scope-aware status in the Memory tab. Validation: bun test src/node/services/memoryConsolidation.test.ts src/node/services/memoryConsolidationService.test.ts && make static-check --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$35.69`_ --- docs/agents/index.mdx | 4 +- src/browser/features/Memory/MemoryBrowser.tsx | 49 ++++-- .../RightSidebar/Memory/MemoryTab.stories.tsx | 28 +++- src/browser/stories/mocks/orpc.ts | 42 ++++- src/cli/debug/consolidate-memory.ts | 15 +- src/common/orpc/schemas/api.ts | 5 +- src/common/orpc/schemas/memory.ts | 8 + src/node/builtinAgents/dream.md | 4 +- src/node/orpc/router.ts | 4 +- .../builtInAgentContent.generated.ts | 2 +- .../builtInSkillContent.generated.ts | 4 +- src/node/services/memoryConsolidation.test.ts | 75 ++++++++- src/node/services/memoryConsolidation.ts | 21 +-- .../memoryConsolidationService.test.ts | 149 +++++++++++++++++- .../services/memoryConsolidationService.ts | 99 +++++++++--- 15 files changed, 439 insertions(+), 70 deletions(-) diff --git a/docs/agents/index.mdx b/docs/agents/index.mdx index 256ce9c7f0..8b7d77fb47 100644 --- a/docs/agents/index.mdx +++ b/docs/agents/index.mdx @@ -573,13 +573,13 @@ Your job, in order: 2. Merge: when two files cover the same topic, fold the unique facts into the better-named file and `delete` the other. 3. Prune: `delete` files (or `str_replace` away sections) that are stale, contradicted, one-off task detail, or derivable from the codebase. 4. Polish: rewrite frontmatter `description:` lines that no longer match their file's contents; keep each to one line. -5. Promote: when explicitly instructed that this is a final pass for an archived workspace, copy durable lessons from /memories/workspace/... into /memories/global/... (create or extend), then delete the workspace copy. +5. Promote: move durable lessons to the narrowest durable scope that should keep them: repo-specific lessons from /memories/workspace/... to /memories/project/... when project memory is available, and cross-project user preferences or environment facts to /memories/global/.... On a final pass for an archived workspace, make sure durable workspace lessons are promoted before deleting the workspace copy. Rules: - Consolidation must shrink or hold total memory size; never pad, never create files unless merging or promoting requires it. - Prefer `str_replace`/`insert` edits over delete-and-recreate. -- Pinned files and the project scope are protected; the tool rejects out-of-policy operations — do not retry rejected commands. +- Pinned files may be edited but must not be deleted or renamed. Project memory is available only for single-project runs. The tool rejects out-of-policy operations — do not retry rejected commands. - You have a budget of 8 mutating commands per run. Spend it on the highest-value cleanups first; finishing under budget is good. - When nothing needs fixing, do nothing. An empty run is a valid outcome. diff --git a/src/browser/features/Memory/MemoryBrowser.tsx b/src/browser/features/Memory/MemoryBrowser.tsx index 1b8aea988b..e38a298560 100644 --- a/src/browser/features/Memory/MemoryBrowser.tsx +++ b/src/browser/features/Memory/MemoryBrowser.tsx @@ -19,6 +19,7 @@ import { cn } from "@/common/lib/utils"; import { MEMORY_SCOPES, MEMORY_VIRTUAL_ROOT, type MemoryScope } from "@/common/constants/memory"; import type { MemoryConsolidationRecordPayload, + MemoryConsolidationStatusPayload, MemoryFileInfo, } from "@/common/orpc/schemas/memory"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; @@ -468,6 +469,12 @@ function MemoryFileRow(props: MemoryFileRowProps) { ); } +function formatConsolidationRecord(record: MemoryConsolidationRecordPayload | null): string { + if (record === null) return "never"; + const appliedCount = record.ops.filter((op) => op.applied).length; + return `${formatRelativeTime(record.lastRunAt)} · ${record.trigger} · ${appliedCount} change${appliedCount === 1 ? "" : "s"}`; +} + /** * Dream consolidation surface (memory-consolidation experiment, PRD #3534): * "last consolidated" line + manual run button. Self-contained — fetches its @@ -487,7 +494,7 @@ function ConsolidationFooter(props: { }) { const { api } = useAPI(); const enabled = useExperimentValue(EXPERIMENT_IDS.MEMORY_CONSOLIDATION); - const [record, setRecord] = useState(null); + const [status, setStatus] = useState(null); const [running, setRunning] = useState(false); const [runError, setRunError] = useState(null); @@ -498,7 +505,7 @@ function ConsolidationFooter(props: { .consolidationStatus({ workspaceId: props.workspaceId }, { signal: controller.signal }) .then((result) => { if (controller.signal.aborted) return; - if (result.success) setRecord(result.data); + if (result.success) setStatus(result.data); }) .catch((err: unknown) => { if (isAbortError(err) || controller.signal.aborted) return; @@ -515,7 +522,12 @@ function ConsolidationFooter(props: { try { const result = await api.memory.consolidate({ workspaceId: props.workspaceId }); if (result.success) { - setRecord(result.data); + setStatus((prev) => ({ + workspaceRecord: result.data, + projectRecord: prev?.projectAvailable === false ? null : result.data, + globalRecord: result.data, + projectAvailable: prev?.projectAvailable ?? true, + })); props.onConsolidated(); } else { setRunError(result.error); @@ -527,17 +539,30 @@ function ConsolidationFooter(props: { } }; - // "Changes" counts applied ops only; the journal also records rejected and - // failed commands, which must not inflate the user-facing number. - const appliedCount = record?.ops.filter((op) => op.applied).length ?? 0; + const projectLabel = + status?.projectAvailable === false + ? "unavailable" + : formatConsolidationRecord(status?.projectRecord ?? null); + const summaryTitle = [ + status?.workspaceRecord?.summary, + status?.projectRecord?.summary, + status?.globalRecord?.summary, + ] + .filter(Boolean) + .join("\n"); + return (
- - {record === null - ? "Never consolidated" - : `Last consolidated ${formatRelativeTime(record.lastRunAt)} (${record.trigger}, ${appliedCount} change${appliedCount === 1 ? "" : "s"})`} - {runError !== null && — {runError}} - +
+
+ Workspace: {formatConsolidationRecord(status?.workspaceRecord ?? null)} +
+
Project: {projectLabel}
+
+ Global: {formatConsolidationRecord(status?.globalRecord ?? null)} +
+ {runError !== null &&
{runError}
} +
{props.workspaceId !== null && ( setRefreshTick((tick) => tick + 1)} @@ -522,12 +523,10 @@ function ConsolidationFooter(props: { try { const result = await api.memory.consolidate({ workspaceId: props.workspaceId }); if (result.success) { - setStatus((prev) => ({ - workspaceRecord: result.data, - projectRecord: prev?.projectAvailable === false ? null : result.data, - globalRecord: result.data, - projectAvailable: prev?.projectAvailable ?? true, - })); + // The manual run returns a bare run record; only consolidationStatus knows + // whether project memory is available for this workspace, so avoid + // inventing scope coverage while the authoritative refetch is pending. + setStatus(null); props.onConsolidated(); } else { setRunError(result.error); diff --git a/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx b/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx index c875848865..df50b8f65a 100644 --- a/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx +++ b/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx @@ -7,7 +7,13 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { createContext, type ReactNode } from "react"; import { installDom } from "../../../../../tests/ui/dom"; -import type { MemoryFileInfo } from "@/common/orpc/schemas/memory"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments"; +import type { + MemoryConsolidationRecordPayload, + MemoryConsolidationStatusPayload, + MemoryFileInfo, +} from "@/common/orpc/schemas/memory"; interface MemoryChangeEvent { scope: "global" | "project" | "workspace"; @@ -17,17 +23,41 @@ interface MemoryChangeEvent { projectPath: string; } +interface FakeMemoryApiOptions { + consolidationStatus?: MemoryConsolidationStatusPayload; + consolidationStatusFailuresRemaining?: number; + consolidateRecord?: MemoryConsolidationRecordPayload; +} + +const DEFAULT_CONSOLIDATION_RECORD: MemoryConsolidationRecordPayload = { + lastRunAt: Date.now(), + trigger: "manual", + summary: "consolidated", + ops: [{ command: "create", path: "/memories/global/prefs.md", applied: true }], +}; + +const DEFAULT_CONSOLIDATION_STATUS: MemoryConsolidationStatusPayload = { + workspaceRecord: null, + projectRecord: null, + globalRecord: null, + projectAvailable: true, +}; + /** * Minimal fake of the api.memory surface the tab consumes. Tests drive change * events through `emitChange` to exercise the live-refresh path. */ -function createFakeMemoryApi(initialFiles: MemoryFileInfo[]) { +function createFakeMemoryApi(initialFiles: MemoryFileInfo[], options: FakeMemoryApiOptions = {}) { const state = { files: initialFiles, contents: new Map(), saveCalls: [] as Array<{ path: string; content: string; expectedSha256: string | null }>, deleteCalls: [] as string[], pinCalls: [] as Array<{ path: string; pinned: boolean }>, + consolidateCalls: 0, + consolidationStatus: options.consolidationStatus ?? DEFAULT_CONSOLIDATION_STATUS, + consolidationStatusFailuresRemaining: options.consolidationStatusFailuresRemaining ?? 0, + consolidateRecord: options.consolidateRecord ?? DEFAULT_CONSOLIDATION_RECORD, nextSaveConflict: false, listeners: new Set<(event: MemoryChangeEvent) => void>(), }; @@ -83,6 +113,17 @@ function createFakeMemoryApi(initialFiles: MemoryFileInfo[]) { ); return Promise.resolve({ success: true as const, data: undefined }); }, + consolidationStatus: (_input: { workspaceId: string }, _opts?: { signal?: AbortSignal }) => { + if (state.consolidationStatusFailuresRemaining > 0) { + state.consolidationStatusFailuresRemaining -= 1; + return Promise.reject(new Error("status unavailable")); + } + return Promise.resolve({ success: true as const, data: state.consolidationStatus }); + }, + consolidate: (_input: { workspaceId: string }) => { + state.consolidateCalls += 1; + return Promise.resolve({ success: true as const, data: state.consolidateRecord }); + }, onChange: (_input: { workspaceId: string }, opts?: { signal?: AbortSignal }) => { async function* iterate(): AsyncGenerator { const queue: MemoryChangeEvent[] = []; @@ -207,6 +248,23 @@ describe("MemoryTab", () => { await findByText(/No memory files/); }); + test("manual consolidation does not invent project coverage when status is unavailable", async () => { + updatePersistedState(getExperimentKey(EXPERIMENT_IDS.MEMORY_CONSOLIDATION), true); + fake = createFakeMemoryApi([], { consolidationStatusFailuresRemaining: 20 }); + const { findByRole, findByText, getByText } = render(); + + await findByText(/No memory files/); + expect(getByText(/^Project:/).textContent).not.toContain("manual"); + + fireEvent.click(await findByRole("button", { name: "Consolidate now" })); + + await waitFor(() => { + expect(fake!.state.consolidateCalls).toBe(1); + }); + await findByRole("button", { name: "Consolidate now" }); + expect(getByText(/^Project:/).textContent).not.toContain("manual"); + }); + test("shows usage stats for used files and omits them for never-used files", async () => { fake = createFakeMemoryApi([ fileInfo({ From e6d41c9a0da412b1e62a30697ff116487b823b99 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 14 Jun 2026 11:03:38 +0000 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=A4=96=20fix:=20notify=20memory=20c?= =?UTF-8?q?onsolidation=20status=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit a dedicated consolidation-status invalidation after coverage sidecar writes, including successful no-op dream runs, and route it through memory subscriptions so open Memory tabs refetch footer status without relying on file mutations. Validation: - bun test src/node/services/memoryConsolidationService.test.ts src/node/orpc/router.memory.test.ts - make typecheck - bun test src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx - make fmt-check - git diff --check --- src/browser/features/Memory/MemoryBrowser.tsx | 29 ++++++------ src/common/orpc/schemas/memory.ts | 24 +++++++++- src/node/orpc/router.memory.test.ts | 44 ++++++++++++++++++- src/node/orpc/router.ts | 15 ++++++- .../memoryConsolidationService.test.ts | 21 +++++++++ .../services/memoryConsolidationService.ts | 17 ++++++- 6 files changed, 130 insertions(+), 20 deletions(-) diff --git a/src/browser/features/Memory/MemoryBrowser.tsx b/src/browser/features/Memory/MemoryBrowser.tsx index 6089eedae7..094ecd12f7 100644 --- a/src/browser/features/Memory/MemoryBrowser.tsx +++ b/src/browser/features/Memory/MemoryBrowser.tsx @@ -66,6 +66,7 @@ export function MemoryBrowser(props: MemoryBrowserProps) { // Virtual paths the agent touched since the user last opened them. const [agentEditedPaths, setAgentEditedPaths] = useState>(new Set()); const [refreshTick, setRefreshTick] = useState(0); + const [statusRefreshTick, setStatusRefreshTick] = useState(0); useEffect(() => { if (!api) return; @@ -90,10 +91,11 @@ export function MemoryBrowser(props: MemoryBrowserProps) { // Live updates: any memory change (agent tool call or UI edit) in a // displayed scope refreshes the list; agent edits additionally badge the - // touched file. The scope filter keeps Settings → Memory (global only) - // from reacting to project/workspace traffic. The backend subscription - // already drops workspace/project-scope events from other workspaces/ - // projects (the same virtual path elsewhere is a different file). + // touched file. Sidecar-only consolidation events refresh just the footer. + // The scope filter keeps Settings → Memory (global only) from reacting to + // project/workspace traffic. The backend subscription already drops + // workspace/project-scope events from other workspaces/projects (the same + // virtual path elsewhere is a different file). useEffect(() => { if (!api) return; const controller = new AbortController(); @@ -105,6 +107,10 @@ export function MemoryBrowser(props: MemoryBrowserProps) { ); for await (const event of iterator) { if (controller.signal.aborted) break; + if (event.kind === "consolidation_status") { + setStatusRefreshTick((n) => n + 1); + continue; + } if (!scopes.includes(event.scope)) continue; if (event.actor === "agent") { setAgentEditedPaths((prev) => { @@ -239,8 +245,11 @@ export function MemoryBrowser(props: MemoryBrowserProps) { setRefreshTick((tick) => tick + 1)} + refreshTick={refreshTick + statusRefreshTick} + onConsolidated={() => { + setRefreshTick((tick) => tick + 1); + setStatusRefreshTick((tick) => tick + 1); + }} /> )} {deleteTarget !== null && ( @@ -483,13 +492,7 @@ function formatConsolidationRecord(record: MemoryConsolidationRecordPayload | nu */ function ConsolidationFooter(props: { workspaceId: string; - /** - * Parent's memory-change tick: dream runs triggered outside this footer - * (/dream, compaction, archive) edit memory files, which bumps the tick via - * the onChange subscription — re-fetch the status line on each bump so - * "last consolidated" stays live (found via dogfooding: a /dream run left - * the footer on "Never consolidated"). - */ + /** Parent invalidation tick for refetching decorative consolidation status. */ refreshTick: number; onConsolidated: () => void; }) { diff --git a/src/common/orpc/schemas/memory.ts b/src/common/orpc/schemas/memory.ts index adcaf4a356..48a58914a0 100644 --- a/src/common/orpc/schemas/memory.ts +++ b/src/common/orpc/schemas/memory.ts @@ -27,8 +27,9 @@ export const MemoryFileInfoSchema = z.object({ }); export type MemoryFileInfo = z.infer; -/** Change event emitted by the MemoryService (agent tool + UI writes). */ -export const MemoryChangeEventSchema = z.object({ +/** File change emitted by the MemoryService (agent tool + UI writes). */ +export const MemoryFileChangeEventSchema = z.object({ + kind: z.literal("file").optional(), scope: MemoryScopeSchema, /** Virtual path of the changed file or directory. */ path: z.string(), @@ -38,6 +39,25 @@ export const MemoryChangeEventSchema = z.object({ /** Stable project identity of the emitting scope context. */ projectPath: z.string(), }); +export type MemoryFileChangeEventPayload = z.infer; + +/** Sidecar-only consolidation coverage changed; subscribers should refetch status. */ +export const MemoryConsolidationStatusChangeEventSchema = z.object({ + kind: z.literal("consolidation_status"), + /** Workspace whose run advanced its workspace coverage record. */ + workspaceId: z.string(), + /** Single-project identity covered by the run, or "" for multi-project workspaces. */ + projectPath: z.string(), +}); +export type MemoryConsolidationStatusChangeEventPayload = z.infer< + typeof MemoryConsolidationStatusChangeEventSchema +>; + +export const MemoryChangeEventSchema = z.union([ + MemoryFileChangeEventSchema, + MemoryConsolidationStatusChangeEventSchema, +]); +export type MemoryChangeEventPayload = z.infer; /** * Save failures distinguish conflicts (sha precondition failed; the UI shows diff --git a/src/node/orpc/router.memory.test.ts b/src/node/orpc/router.memory.test.ts index f00e76b89b..2098c8776d 100644 --- a/src/node/orpc/router.memory.test.ts +++ b/src/node/orpc/router.memory.test.ts @@ -1,9 +1,14 @@ /* eslint-disable @typescript-eslint/await-thenable, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/require-await */ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { createRouterClient } from "@orpc/server"; +import { EventEmitter } from "events"; import * as fsPromises from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import type { + MemoryChangeEventPayload, + MemoryConsolidationStatusChangeEventPayload, +} from "@/common/orpc/schemas/memory"; import { Config } from "@/node/config"; import { MemoryService, type MemoryChangeEvent } from "@/node/services/memoryService"; import { MemoryMetaService } from "@/node/services/memoryMeta"; @@ -16,6 +21,7 @@ describe("router memory routes", () => { let projectPath: string; let memoryService: MemoryService; let memoryMetaService: MemoryMetaService; + let memoryConsolidationEvents: EventEmitter; beforeEach(async () => { tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-router-memory-test-")); @@ -24,6 +30,7 @@ describe("router memory routes", () => { await fsPromises.mkdir(projectPath, { recursive: true }); memoryMetaService = new MemoryMetaService(config.rootDir); memoryService = new MemoryService(config, memoryMetaService); + memoryConsolidationEvents = new EventEmitter(); }); afterEach(async () => { @@ -51,6 +58,7 @@ describe("router memory routes", () => { config, memoryService, memoryMetaService, + memoryConsolidationService: memoryConsolidationEvents, workspaceService: { // In-place workspace shape (projectPath === name) so the checkout cwd // resolves to projectPath itself without a worktree. @@ -397,7 +405,7 @@ describe("router memory routes", () => { const client = createClient({ enabled: true }); const iterator = await client.memory.onChange({ workspaceId: "ws-mem" }); - const received: MemoryChangeEvent[] = []; + const received: MemoryChangeEventPayload[] = []; const consumer = (async () => { for await (const event of iterator) { received.push(event); @@ -453,10 +461,42 @@ describe("router memory routes", () => { projectPath, }); await consumer; - expect(received.map((e) => [e.scope, e.workspaceId])).toEqual([ + expect( + received.map((event) => { + if (event.kind === "consolidation_status") throw new Error("unexpected status event"); + return [event.scope, event.workspaceId]; + }) + ).toEqual([ ["global", "ws-other"], ["workspace", "ws-mem"], ["project", "ws-other"], ]); }); + + test("onChange forwards consolidation status events", async () => { + const client = createClient({ enabled: true }); + const iterator = await client.memory.onChange({ workspaceId: "ws-mem" }); + + const received: MemoryChangeEventPayload[] = []; + const consumer = (async () => { + for await (const event of iterator) { + received.push(event); + break; + } + })(); + // The route attaches its status listener lazily (on first pull). + while (memoryConsolidationEvents.listenerCount("statusChange") === 0) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + + const event: MemoryConsolidationStatusChangeEventPayload = { + kind: "consolidation_status", + workspaceId: "ws-other", + projectPath: "/somewhere/else", + }; + memoryConsolidationEvents.emit("statusChange", event); + + await consumer; + expect(received).toEqual([event]); + }); }); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 5a944d6e13..43869873a2 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -50,6 +50,10 @@ import { type MemoryChangeEvent, type MemoryScopeContext, } from "@/node/services/memoryService"; +import type { + MemoryChangeEventPayload, + MemoryConsolidationStatusChangeEventPayload, +} from "@/common/orpc/schemas/memory"; import { memoryLogicalKey } from "@/node/services/memoryMeta"; import { secretsToRecord } from "@/common/types/secrets"; import { roundToBase2 } from "@/common/telemetry/utils"; @@ -3849,9 +3853,9 @@ export const router = (authToken?: string) => { const boundProjectPath = boundMetadata ? resolveMemoryProjectIdentity(boundMetadata) : null; - const queue = createAsyncEventQueue(); + const queue = createAsyncEventQueue(); - // Global events are always forwarded (shared across everything). + // Global file events are always forwarded (shared across everything). const onChange = (event: MemoryChangeEvent) => { if (event.scope === "workspace" && event.workspaceId !== boundWorkspaceId) return; // Project memory is keyed by project identity: the same virtual @@ -3861,7 +3865,13 @@ export const router = (authToken?: string) => { } queue.push(event); }; + // Consolidation status includes global coverage, so every open Memory + // tab should refetch when a run completes, even if it made no file changes. + const onStatusChange = (event: MemoryConsolidationStatusChangeEventPayload) => { + queue.push(event); + }; context.memoryService.on("change", onChange); + context.memoryConsolidationService.on("statusChange", onStatusChange); const onAbort = () => queue.end(); if (signal) { @@ -3878,6 +3888,7 @@ export const router = (authToken?: string) => { queue.end(); signal?.removeEventListener("abort", onAbort); context.memoryService.off("change", onChange); + context.memoryConsolidationService.off("statusChange", onStatusChange); } }), }, diff --git a/src/node/services/memoryConsolidationService.test.ts b/src/node/services/memoryConsolidationService.test.ts index 3203649152..47c9498088 100644 --- a/src/node/services/memoryConsolidationService.test.ts +++ b/src/node/services/memoryConsolidationService.test.ts @@ -5,6 +5,7 @@ import * as path from "node:path"; import { MockLanguageModelV3, simulateReadableStream } from "ai/test"; import type { LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import type { MemoryConsolidationStatusChangeEventPayload } from "@/common/orpc/schemas/memory"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { @@ -259,6 +260,26 @@ describe("MemoryConsolidationService", () => { expect(raw).toContain("ws-dream"); }); + it("emits status invalidation for successful no-op runs", async () => { + using fixture = await createFixture(); + const events: MemoryConsolidationStatusChangeEventPayload[] = []; + fixture.service.on("statusChange", (event: MemoryConsolidationStatusChangeEventPayload) => { + events.push(event); + }); + + const result = await fixture.service.maybeRun("ws-dream", "manual"); + + expect(result.success).toBe(true); + if (result.success) expect(result.data.ops).toHaveLength(0); + expect(events).toEqual([ + { + kind: "consolidation_status", + workspaceId: "ws-dream", + projectPath: "/projects/demo", + }, + ]); + }); + it("records project coverage for single-project workspace runs", async () => { using fixture = await createFixture(); diff --git a/src/node/services/memoryConsolidationService.ts b/src/node/services/memoryConsolidationService.ts index 1a61230279..fd537e91a7 100644 --- a/src/node/services/memoryConsolidationService.ts +++ b/src/node/services/memoryConsolidationService.ts @@ -10,6 +10,7 @@ * failed run logs and waits for the next trigger. Nothing here may block a * stream, compaction, archival, or app launch. */ +import { EventEmitter } from "events"; import * as fsPromises from "node:fs/promises"; import * as path from "node:path"; import writeFileAtomic from "write-file-atomic"; @@ -28,6 +29,7 @@ import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { MemoryConsolidationRecordSchema, type MemoryConsolidationRecordPayload, + type MemoryConsolidationStatusChangeEventPayload, type MemoryConsolidationStatusPayload, type MemoryConsolidationTrigger, } from "@/common/orpc/schemas/memory"; @@ -155,7 +157,7 @@ export function resolveConsolidationProjectPath(workspace: { ); } -export class MemoryConsolidationService { +export class MemoryConsolidationService extends EventEmitter { private readonly sidecarPath: string; /** Serializes sidecar read-modify-write cycles (journal persistence only). */ private readonly locks = new MutexMap(); @@ -174,6 +176,7 @@ export class MemoryConsolidationService { private readonly modelFactory: ModelFactoryLike, private readonly experiments: ExperimentsCheck ) { + super(); this.sidecarPath = path.join(config.rootDir, "memory-consolidation.json"); } @@ -219,6 +222,15 @@ export class MemoryConsolidationService { }; } + private emitStatusChange(workspaceId: string, projectPath: string): void { + const event: MemoryConsolidationStatusChangeEventPayload = { + kind: "consolidation_status", + workspaceId, + projectPath, + }; + this.emit("statusChange", event); + } + private async saveRecord( workspaceId: string, projectPath: string, @@ -232,6 +244,9 @@ export class MemoryConsolidationService { } await writeFileAtomic(this.sidecarPath, JSON.stringify(file, null, 2)); }); + // The sidecar write does not touch memory files, so open Memory tabs need + // this explicit status invalidation even when a run made zero mutations. + this.emitStatusChange(workspaceId, projectPath); } /** From 3555fc31ae69ee2189d581f148273d11afb44e93 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 14 Jun 2026 11:13:49 +0000 Subject: [PATCH 07/10] fix: satisfy memory consolidation lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the repo-preferred ReadonlyArray type for consolidation project refs. Validation: bun test src/node/services/memoryConsolidation.test.ts src/node/services/memoryConsolidationService.test.ts src/node/orpc/router.memory.test.ts src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx && make static-check --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$35.69`_ --- src/node/services/memoryConsolidationService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/services/memoryConsolidationService.ts b/src/node/services/memoryConsolidationService.ts index fd537e91a7..38c8cac26c 100644 --- a/src/node/services/memoryConsolidationService.ts +++ b/src/node/services/memoryConsolidationService.ts @@ -144,7 +144,7 @@ export async function resolveDreamAgentBody(muxRoot: string): Promise; }): string { // Task/fork multi-project workspaces can live under a real project bucket; // only the workspace's actual project refs prove a single stable identity. From 83946beff60508bdc9149473f907965ff9367e07 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 14 Jun 2026 11:24:45 +0000 Subject: [PATCH 08/10] fix: debounce project-only launch coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat recent project coverage as the debounce anchor when project memory is the only launch-sweep qualifier, so sibling workspaces do not duplicate provider runs within the debounce window. Validation: bun test src/node/services/memoryConsolidation.test.ts src/node/services/memoryConsolidationService.test.ts src/node/orpc/router.memory.test.ts src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx && make static-check --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$38.29`_ --- .../memoryConsolidationService.test.ts | 32 +++++++++++++++++++ .../services/memoryConsolidationService.ts | 31 +++++++++++------- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/node/services/memoryConsolidationService.test.ts b/src/node/services/memoryConsolidationService.test.ts index 47c9498088..d9cdc69d53 100644 --- a/src/node/services/memoryConsolidationService.test.ts +++ b/src/node/services/memoryConsolidationService.test.ts @@ -591,6 +591,38 @@ describe("MemoryConsolidationService", () => { expect(fixture.modelCalls).toHaveLength(1); }); + it("debounces project-only launch writes against recent project coverage", async () => { + using fixture = await createFixture(); + await fixture.addWorkspace("ws-other"); + const dayAgo = Date.now() - 25 * 60 * 60 * 1000; + const recentProjectRunAt = Date.now() - 60_000; + await fsPromises.writeFile( + path.join(fixture.muxHome, "memory-consolidation.json"), + JSON.stringify({ + workspaces: {}, + projects: { + "/projects/demo": { + lastRunAt: recentProjectRunAt, + trigger: "launch", + summary: "recent project coverage", + ops: [], + }, + }, + }) + ); + await fixture.metaService.recordAccess( + memoryLogicalKey("project", "new.md", { + projectPath: "/projects/demo", + workspaceId: "ws-dream", + }), + { write: true } + ); + + await fixture.service.runLaunchSweep(new Map([["ws-other", dayAgo]])); + + expect(fixture.modelCalls).toHaveLength(0); + }); + it("does not qualify real-bucket multi-project workspaces from project-only writes", async () => { using fixture = await createFixture(); await fixture.addMultiProjectWorkspace("ws-task", { bucket: "/projects/demo" }); diff --git a/src/node/services/memoryConsolidationService.ts b/src/node/services/memoryConsolidationService.ts index 38c8cac26c..1e0460880d 100644 --- a/src/node/services/memoryConsolidationService.ts +++ b/src/node/services/memoryConsolidationService.ts @@ -462,34 +462,41 @@ export class MemoryConsolidationService extends EventEmitter { projectPath, workspaceId, }); - let hasNewWrites = false; - let projectWriteNeedsCoverage = false; + let hasWorkspaceWrites = false; + let hasProjectWrites = false; + let hasGlobalWrites = false; for (const [key, entry] of meta) { if (entry.lastWriteAt === null) continue; if (key.startsWith(workspaceKeyPrefix) && entry.lastWriteAt > lastRunAt) { - hasNewWrites = true; - break; + hasWorkspaceWrites = true; + continue; } if ( projectKeyPrefix !== null && key.startsWith(projectKeyPrefix) && entry.lastWriteAt > projectRunAt ) { - hasNewWrites = true; - projectWriteNeedsCoverage = true; - break; + hasProjectWrites = true; + continue; } if (key.startsWith("global:") && entry.lastWriteAt > globalLastRunAt) { - hasNewWrites = true; - break; + hasGlobalWrites = true; } } - if (!hasNewWrites) continue; + if (!hasWorkspaceWrites && !hasProjectWrites && !hasGlobalWrites) continue; // Project coverage is anchored separately from workspace coverage. Recent // legacy workspace-only records must not debounce away the first project - // pass, and skipped debounce candidates must not spend the launch cap. + // pass, but once project coverage exists, project-only writes obey the + // project debounce anchor before another sibling spends a provider run. const projectDebounceAllowsRun = - projectWriteNeedsCoverage && now - projectRunAt >= MEMORY_CONSOLIDATION_DEBOUNCE_MS; + hasProjectWrites && now - projectRunAt >= MEMORY_CONSOLIDATION_DEBOUNCE_MS; + const projectDebounceWouldSkip = + hasProjectWrites && + !hasWorkspaceWrites && + !hasGlobalWrites && + projectRunAt !== 0 && + now - projectRunAt < MEMORY_CONSOLIDATION_DEBOUNCE_MS; + if (projectDebounceWouldSkip) continue; const workspaceDebounceWouldSkip = lastRunAt !== 0 && now - lastRunAt < MEMORY_CONSOLIDATION_DEBOUNCE_MS; const skipWorkspaceDebounce = workspaceDebounceWouldSkip && projectDebounceAllowsRun; From 8fb77df4aaec1ee3224b6cd362bb144fbc50be60 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 14 Jun 2026 11:35:55 +0000 Subject: [PATCH 09/10] test: update findWorkspace expectation for project refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config.findWorkspace now exposes stored project refs for consolidation identity checks, so update the exact-shape regression to include them. Validation: bun test src/node/config.test.ts -t 'preserves the config key' && bun test src/node/services/memoryConsolidation.test.ts src/node/services/memoryConsolidationService.test.ts src/node/orpc/router.memory.test.ts src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx && make static-check --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$38.29`_ --- src/node/config.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 8ce5d29996..b4aaef9f81 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -1529,8 +1529,13 @@ describe("Config", () => { workspacePath, projectPath: MULTI_PROJECT_CONFIG_KEY, attributionProjectPath: primaryProjectPath, + projects: [ + { projectName: "project-a", projectPath: primaryProjectPath }, + { projectName: "project-b", projectPath: secondaryProjectPath }, + ], workspaceName: "feature-branch", parentWorkspaceId: undefined, + pendingAutoTitle: undefined, }); }); }); From 8031ba85e2c11ae74513993318b8ad8307f10276 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 14 Jun 2026 11:49:34 +0000 Subject: [PATCH 10/10] test: stabilize MemoryTab consolidation footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mock the experiment hook in MemoryTab tests instead of relying on persisted experiment state, preventing full-suite state contamination from hiding the consolidation footer. Validation: bun test src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx && bun test src/node/config.test.ts -t 'preserves the config key' && bun test src/node/services/memoryConsolidation.test.ts src/node/services/memoryConsolidationService.test.ts src/node/orpc/router.memory.test.ts && make static-check --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$38.29`_ --- .../features/RightSidebar/Memory/MemoryTab.test.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx b/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx index df50b8f65a..9fbfb64c63 100644 --- a/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx +++ b/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx @@ -7,8 +7,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { createContext, type ReactNode } from "react"; import { installDom } from "../../../../../tests/ui/dom"; -import { updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import type { MemoryConsolidationRecordPayload, MemoryConsolidationStatusPayload, @@ -184,6 +183,11 @@ void mock.module("@/browser/contexts/API", () => ({ }), })); +void mock.module("@/browser/hooks/useExperiments", () => ({ + useExperimentValue: (experimentId: string) => + experimentId === EXPERIMENT_IDS.MEMORY_CONSOLIDATION, +})); + // The delete flow confirms through ConfirmationModal, which renders via a // Radix Dialog portal that happy-dom cannot see. Mock the Dialog primitives // to render inline so the real confirm/cancel behavior stays under test. @@ -249,7 +253,6 @@ describe("MemoryTab", () => { }); test("manual consolidation does not invent project coverage when status is unavailable", async () => { - updatePersistedState(getExperimentKey(EXPERIMENT_IDS.MEMORY_CONSOLIDATION), true); fake = createFakeMemoryApi([], { consolidationStatusFailuresRemaining: 20 }); const { findByRole, findByText, getByText } = render();