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..094ecd12f7 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"; @@ -65,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; @@ -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(); @@ -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) => { @@ -236,9 +243,13 @@ export function MemoryBrowser(props: MemoryBrowserProps) { {props.workspaceId !== null && ( setRefreshTick((tick) => tick + 1)} + refreshTick={refreshTick + statusRefreshTick} + onConsolidated={() => { + setRefreshTick((tick) => tick + 1); + setStatusRefreshTick((tick) => tick + 1); + }} /> )} {deleteTarget !== null && ( @@ -468,6 +479,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 @@ -475,19 +492,13 @@ function MemoryFileRow(props: MemoryFileRowProps) { */ 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(null); + const [status, setStatus] = useState(null); const [running, setRunning] = useState(false); const [runError, setRunError] = useState(null); @@ -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; @@ -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); @@ -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 (
- - {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}
} +