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 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) { 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; }