From 54c1056c765c01cc88f5e0d4b4bd87974985b59d Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:40:12 +0000 Subject: [PATCH 1/3] refactor: extract memory-context hot-block upgrade helper Deduplicate the two near-identical post-policy 'upgrade index-only memory context to the token-budgeted hot block for this model' blocks added in #3548 (the final-stream-context path and the fallback-model path) into a single upgradeMemoryContextForModel closure. Behavior-preserving: the helper returns the unchanged pre-policy memoryContext reference when hot preloading is off or the memory tool was stripped, so the existing identity comparison that decides whether to rebuild the system prompt is unaffected. --- src/node/services/aiService.ts | 37 +++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 879aaceb09..bfd6283c72 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1335,6 +1335,21 @@ export class AIService extends EventEmitter { this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY) === true; const memoryHotSetExperimentEnabled = this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY_HOT_SET) === true; + // Once final tool policy keeps the memory tool, upgrade the index-only + // memory context (resolved pre-policy with includeHotMemories: false) to + // the token-budgeted hot block for the model that will actually stream. + // Returns the unchanged pre-policy `memoryContext` reference when hot + // preloading is off or the memory tool was stripped, so callers can use + // identity comparison to decide whether the system prompt must be rebuilt. + const upgradeMemoryContextForModel = async ( + memoryToolAvailableForModel: boolean, + modelStringForContext: string + ): Promise => + memoryToolAvailableForModel && + memoryHotSetExperimentEnabled && + resolveMemoryContext !== undefined + ? await resolveMemoryContext(modelStringForContext, { includeHotMemories: true }) + : memoryContext; emitStartupBreadcrumb("loading_workspace_context"); const resolveAgentForStreamStartedAt = Date.now(); const agentResult = await resolveAgentForStream({ @@ -2089,11 +2104,10 @@ export class AIService extends EventEmitter { const advisorToolAvailable = tools.advisor !== undefined; const memoryToolAvailable = tools.memory !== undefined; - const shouldUpgradeMemoryContext = - memoryToolAvailable && memoryHotSetExperimentEnabled && resolveMemoryContext !== undefined; - const finalMemoryContext = shouldUpgradeMemoryContext - ? await resolveMemoryContext(modelString, { includeHotMemories: true }) - : memoryContext; + const finalMemoryContext = await upgradeMemoryContextForModel( + memoryToolAvailable, + modelString + ); const finalStreamSystemContext = advisorToolAvailable === advisorToolEligible && memoryToolAvailable === memoryToolEligible && @@ -2473,15 +2487,10 @@ export class AIService extends EventEmitter { }); const nextToolNamesForSentinel = Object.keys(nextTools).sort(); const nextMemoryToolAvailable = nextTools.memory !== undefined; - const shouldUpgradeNextMemoryContext = - nextMemoryToolAvailable && - memoryHotSetExperimentEnabled && - resolveMemoryContext !== undefined; - const nextMemoryContext = shouldUpgradeNextMemoryContext - ? await resolveMemoryContext(next.canonicalModelString, { - includeHotMemories: true, - }) - : memoryContext; + const nextMemoryContext = await upgradeMemoryContextForModel( + nextMemoryToolAvailable, + next.canonicalModelString + ); // Rebuild the system prompt for the fallback model (tool // instructions and "Model:" sections are model-keyed), keeping From 9d800bd2022fc33b9afa2a31f1ab08142d2974b2 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:35:26 +0000 Subject: [PATCH 2/3] refactor: dedup identical memory access-policy branches After #3547 made project memory host-local, plan-like agents gained `project: "readwrite"`, so resolveMemoryAccessPolicy's plan-like and editing-capable branches now return byte-identical matrices. Collapse them into one branch backed by a shared READ_WRITE_ACCESS constant, mirroring the existing READ_ONLY_ACCESS pattern. Behavior-preserving: the policy object is read-only at every callsite. --- src/node/services/tools/memory.ts | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/node/services/tools/memory.ts b/src/node/services/tools/memory.ts index 635a9e5310..cbf44f6921 100644 --- a/src/node/services/tools/memory.ts +++ b/src/node/services/tools/memory.ts @@ -20,31 +20,27 @@ const READ_ONLY_ACCESS: MemoryScopeAccess = { workspace: "read", }; +/** Full write access to every scope, shared by plan-like and exec-like agents. */ +const READ_WRITE_ACCESS: MemoryScopeAccess = { + global: "readwrite", + project: "readwrite", + workspace: "readwrite", +}; + /** * Map an agent class onto the per-scope memory write matrix * (see MemoryScopeAccess in src/common/constants/memory.ts): - * - Plan-like agents get read-write memory access; project memory is host-local - * and never mutates the repo checkout. - * - Editing-capable (exec-like) agents get read-write everywhere. + * - Plan-like and editing-capable (exec-like) agents get read-write everywhere; + * project memory is host-local and never mutates the repo checkout, so even + * plan agents may write it. * - Everything else (explore/read-only agents) is view-only. */ export function resolveMemoryAccessPolicy(options: { planLike: boolean; editingCapable: boolean; }): MemoryScopeAccess { - if (options.planLike) { - return { - global: "readwrite", - project: "readwrite", - workspace: "readwrite", - }; - } - if (options.editingCapable) { - return { - global: "readwrite", - project: "readwrite", - workspace: "readwrite", - }; + if (options.planLike || options.editingCapable) { + return READ_WRITE_ACCESS; } return READ_ONLY_ACCESS; } From 4718ae4dacafc1ef110a510c4db734db7508eb22 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:36:48 +0000 Subject: [PATCH 3/3] refactor: extract findNewestWorkspaceRecord helper getStatus and runLaunchSweep both independently computed the newest consolidation run across all workspaces (one as a record reduce, one as a Math.max over lastRunAt). Extract a single findNewestWorkspaceRecord helper and derive globalLastRunAt from it. Behavior-preserving: empty -> null -> 0 floor matches the prior Math.max(0, ...). --- .../services/memoryConsolidationService.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/node/services/memoryConsolidationService.ts b/src/node/services/memoryConsolidationService.ts index 1e0460880d..a43a91a3da 100644 --- a/src/node/services/memoryConsolidationService.ts +++ b/src/node/services/memoryConsolidationService.ts @@ -157,6 +157,20 @@ export function resolveConsolidationProjectPath(workspace: { ); } +/** + * Newest completed run across all workspaces, or null when none have run. + * Every run consolidates global scope, so the newest run anywhere doubles as + * the last time global memory was covered. + */ +function findNewestWorkspaceRecord( + workspaces: Record +): MemoryConsolidationRecord | null { + return Object.values(workspaces).reduce( + (latest, record) => (latest === null || record.lastRunAt > latest.lastRunAt ? record : latest), + null + ); +} + export class MemoryConsolidationService extends EventEmitter { private readonly sidecarPath: string; /** Serializes sidecar read-modify-write cycles (journal persistence only). */ @@ -209,11 +223,7 @@ export class MemoryConsolidationService extends EventEmitter { const file = await this.load(); const workspace = this.config.findWorkspace(workspaceId); const projectPath = workspace == null ? "" : resolveConsolidationProjectPath(workspace); - const globalRecord = Object.values(file.workspaces).reduce( - (latest, record) => - latest === null || record.lastRunAt > latest.lastRunAt ? record : latest, - null - ); + const globalRecord = findNewestWorkspaceRecord(file.workspaces); return { workspaceRecord: file.workspaces[workspaceId] ?? null, projectRecord: projectPath === "" ? null : (file.projects[projectPath] ?? null), @@ -409,10 +419,7 @@ export class MemoryConsolidationService extends EventEmitter { // (every run consolidates global). Derived, so no sidecar schema change. // Advanced after each successful run below: a global-only write needs ONE // covering pass, not one per idle workspace in the same sweep. - let globalLastRunAt = Math.max( - 0, - ...Object.values(sidecar.workspaces).map((record) => record.lastRunAt) - ); + let globalLastRunAt = findNewestWorkspaceRecord(sidecar.workspaces)?.lastRunAt ?? 0; const archivedById = new Map(); const projectPathByWorkspace = new Map(); for (const [configProjectPath, project] of this.config.loadConfigOrDefault().projects) {