Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 23 additions & 14 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MemorySessionContext | undefined> =>
memoryToolAvailableForModel &&
memoryHotSetExperimentEnabled &&
resolveMemoryContext !== undefined
? await resolveMemoryContext(modelStringForContext, { includeHotMemories: true })
: memoryContext;
emitStartupBreadcrumb("loading_workspace_context");
const resolveAgentForStreamStartedAt = Date.now();
const agentResult = await resolveAgentForStream({
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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
Expand Down
25 changes: 16 additions & 9 deletions src/node/services/memoryConsolidationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, MemoryConsolidationRecord>
): MemoryConsolidationRecord | null {
return Object.values(workspaces).reduce<MemoryConsolidationRecord | null>(
(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). */
Expand Down Expand Up @@ -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<MemoryConsolidationRecord | null>(
(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),
Expand Down Expand Up @@ -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<string, boolean>();
const projectPathByWorkspace = new Map<string, string>();
for (const [configProjectPath, project] of this.config.loadConfigOrDefault().projects) {
Expand Down
28 changes: 12 additions & 16 deletions src/node/services/tools/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading