Skip to content
Merged
4 changes: 2 additions & 2 deletions docs/agents/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
77 changes: 52 additions & 25 deletions src/browser/features/Memory/MemoryBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -65,6 +66,7 @@ export function MemoryBrowser(props: MemoryBrowserProps) {
// Virtual paths the agent touched since the user last opened them.
const [agentEditedPaths, setAgentEditedPaths] = useState<ReadonlySet<string>>(new Set());
const [refreshTick, setRefreshTick] = useState(0);
const [statusRefreshTick, setStatusRefreshTick] = useState(0);

useEffect(() => {
if (!api) return;
Expand All @@ -89,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();
Expand All @@ -104,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) => {
Expand Down Expand Up @@ -236,9 +243,13 @@ export function MemoryBrowser(props: MemoryBrowserProps) {
</div>
{props.workspaceId !== null && (
<ConsolidationFooter
key={props.workspaceId}
workspaceId={props.workspaceId}
refreshTick={refreshTick}
onConsolidated={() => setRefreshTick((tick) => tick + 1)}
refreshTick={refreshTick + statusRefreshTick}
onConsolidated={() => {
setRefreshTick((tick) => tick + 1);
setStatusRefreshTick((tick) => tick + 1);
}}
/>
)}
{deleteTarget !== null && (
Expand Down Expand Up @@ -468,26 +479,26 @@ 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
* own status so the file list above never re-renders on status polls.
*/
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;
}) {
const { api } = useAPI();
const enabled = useExperimentValue(EXPERIMENT_IDS.MEMORY_CONSOLIDATION);
const [record, setRecord] = useState<MemoryConsolidationRecordPayload | null>(null);
const [status, setStatus] = useState<MemoryConsolidationStatusPayload | null>(null);
const [running, setRunning] = useState(false);
const [runError, setRunError] = useState<string | null>(null);

Expand All @@ -498,7 +509,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;
Expand All @@ -515,7 +526,10 @@ function ConsolidationFooter(props: {
try {
const result = await api.memory.consolidate({ workspaceId: props.workspaceId });
if (result.success) {
setRecord(result.data);
// 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);
Expand All @@ -527,17 +541,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 (
<div className="border-border-light flex items-center justify-between gap-2 border-t px-3 py-2 text-xs">
<span className="text-muted truncate" title={record?.summary}>
{record === null
? "Never consolidated"
: `Last consolidated ${formatRelativeTime(record.lastRunAt)} (${record.trigger}, ${appliedCount} change${appliedCount === 1 ? "" : "s"})`}
{runError !== null && <span className="text-error"> — {runError}</span>}
</span>
<div className="text-muted min-w-0 flex-1 space-y-0.5" title={summaryTitle}>
<div className="counter-nums truncate">
Workspace: {formatConsolidationRecord(status?.workspaceRecord ?? null)}
</div>
<div className="counter-nums truncate">Project: {projectLabel}</div>
<div className="counter-nums truncate">
Global: {formatConsolidationRecord(status?.globalRecord ?? null)}
</div>
{runError !== null && <div className="text-error truncate">{runError}</div>}
</div>
<button
type="button"
className="border-border-light text-foreground hover:bg-hover shrink-0 rounded border px-2 py-0.5 disabled:opacity-50"
Expand Down
28 changes: 26 additions & 2 deletions src/browser/features/RightSidebar/Memory/MemoryTab.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within } from "@storybook/test";

import { updatePersistedState } from "@/browser/hooks/usePersistedState";
import { APIProvider } from "@/browser/contexts/API";
import { createMockORPCClient } from "@/browser/stories/mocks/orpc";
import type { MemoryFileInfo } from "@/common/orpc/schemas/memory";
import type {
MemoryConsolidationRecordPayload,
MemoryFileInfo,
} from "@/common/orpc/schemas/memory";

import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments";
import { MemoryTab } from "./MemoryTab";

const meta: Meta<typeof MemoryTab> = {
Expand Down Expand Up @@ -80,11 +85,29 @@ const MEMORY_FILES: MemoryFileInfo[] = [
},
];

const CONSOLIDATION_RECORD: MemoryConsolidationRecordPayload = {
lastRunAt: Date.now() - 30 * 60 * 1000,
trigger: "manual",
summary: "Merged duplicate project notes",
ops: [{ command: "delete", path: "/memories/project/duplicate.md", applied: true }],
};

// The Memory tab lives in the narrow right sidebar, so pin the story to a
// sidebar-like width (also exercises the ~375px mobile layout contract).
function renderTab(width: string) {
updatePersistedState(getExperimentKey(EXPERIMENT_IDS.MEMORY_CONSOLIDATION), true);
return (
<APIProvider client={createMockORPCClient({ memoryFiles: MEMORY_FILES })}>
<APIProvider
client={createMockORPCClient({
memoryFiles: MEMORY_FILES,
memoryConsolidationStatus: {
workspaceRecord: null,
projectRecord: CONSOLIDATION_RECORD,
globalRecord: CONSOLIDATION_RECORD,
projectAvailable: true,
},
})}
>
<div className="border-border-light h-[480px] border" style={{ width }}>
<MemoryTab workspaceId={STORY_WORKSPACE_ID} />
</div>
Expand All @@ -97,6 +120,7 @@ export const List: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("preferences.md");
await canvas.findByText(/Project:/);
await canvas.findByText("scratch.md");
},
};
Expand Down
65 changes: 63 additions & 2 deletions src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ 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 { EXPERIMENT_IDS } from "@/common/constants/experiments";
import type {
MemoryConsolidationRecordPayload,
MemoryConsolidationStatusPayload,
MemoryFileInfo,
} from "@/common/orpc/schemas/memory";

interface MemoryChangeEvent {
scope: "global" | "project" | "workspace";
Expand All @@ -17,17 +22,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<string, { content: string; sha256: string }>(),
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>(),
};
Expand Down Expand Up @@ -83,6 +112,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<MemoryChangeEvent> {
const queue: MemoryChangeEvent[] = [];
Expand Down Expand Up @@ -143,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.
Expand Down Expand Up @@ -207,6 +252,22 @@ describe("MemoryTab", () => {
await findByText(/No memory files/);
});

test("manual consolidation does not invent project coverage when status is unavailable", async () => {
fake = createFakeMemoryApi([], { consolidationStatusFailuresRemaining: 20 });
const { findByRole, findByText, getByText } = render(<MemoryTab workspaceId="ws-1" />);

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({
Expand Down
Loading
Loading