Skip to content
Merged
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
31 changes: 31 additions & 0 deletions docs/design/2026-07-11-managed-memory-microcompaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Managed Memory Microcompaction Preservation

## Problem

Managed-memory topic files are loaded lazily with `read_file`. Microcompaction currently treats those results like ordinary tool output and replaces older content with `[Old tool result content cleared]`. The memory index remains available, and recent fixes let a later `read_file` return real bytes again, but the active model is not guaranteed to notice that it must reload the memory.

Issue #6487 also reports a stale index after `/remember`; PR #6497 already owns that part. This design only addresses managed-memory content removed by microcompaction.

## Chosen design

Add a narrow `MicrocompactOptions` callback that identifies `read_file` paths whose successful results must be preserved. Before building idle, forced, or size-based clearing plans, microcompaction correlates each response with its request-side `file_path` and removes protected results from the compactable set. Other tools, ordinary file reads, errors, and responses whose path cannot be resolved retain the current behavior.

Every production microcompaction entry point supplies the same predicate:

- pre-send idle and size-based compaction
- `/compress-fast`
- memory-pressure history compaction

The predicate recognizes project, user, and team managed-memory roots using realpath-aware containment. Symlinks that escape a managed root are not protected.

## Why this level

Injecting every loaded memory body into the system instruction would make memory permanently consume context and would replace the existing index-plus-lazy-read design. Reattaching every memory file after full compaction needs a separate token budget and restoration policy. Preserving only managed-memory reads from microcompaction directly fixes the reproduced clearing behavior with a bounded change and leaves full compaction as the existing hard context-reduction boundary.

Full compaction is therefore intentionally not byte-preserving. Its summary sees the pre-compaction memory content, `MEMORY.md` indexes remain in the system instruction, and the file-read cache is cleared so the model can reload exact bytes. This change guarantees preservation only across microcompaction.

## Risk and tests

Repeated reads of managed-memory files can retain multiple copies until full compaction. That is an intentional tradeoff: durable guidance is more important than reclaiming those tool-result tokens, while full compaction remains available as the hard cap.

Tests cover project, user, and team roots; ordinary reads; symlink escapes; idle, forced, and size-based paths; mixed protected and compactable results; ambiguous or missing response IDs; and eviction metadata.
68 changes: 68 additions & 0 deletions docs/plans/2026-07-11-managed-memory-microcompaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Managed Memory Microcompaction Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Keep successful managed-memory `read_file` results available across every microcompaction trigger without changing ordinary tool-result compaction.

**Architecture:** A realpath-safe memory-path helper classifies project, user, and team memory. Microcompaction receives a pure preservation predicate, correlates response IDs to request paths, and excludes protected reads before calculating clear plans and metadata. The three production callers pass the same predicate.

**Tech Stack:** TypeScript, Vitest, Node.js filesystem/path APIs.

### Task 1: Specify preservation behavior

**Files:**

- Modify: `packages/core/src/services/microcompaction/microcompact.test.ts`

1. Add helpers that build paired `read_file` calls/results with IDs and paths.
2. Add failing tests for idle/force and size-only preservation, ordinary reads, mixed/ambiguous IDs, and eviction metadata.
3. Run `cd packages/core && npx vitest run src/services/microcompaction/microcompact.test.ts` and confirm the new assertions fail because the option is ignored.

### Task 2: Add safe memory path classification

**Files:**

- Modify: `packages/core/src/memory/paths.ts`
- Modify: `packages/core/src/memory/team-paths.test.ts`

1. Add failing tests for project, user, team, outside, and symlink-escape paths.
2. Add a read/retention-specific helper that resolves the nearest existing real path and checks all three managed roots without changing write-approval semantics.
3. Run the path tests and confirm they pass.

### Task 3: Exclude protected reads from clear plans

**Files:**

- Modify: `packages/core/src/services/microcompaction/microcompact.ts`

1. Extend `MicrocompactOptions` with the preservation predicate.
2. Correlate `functionResponse.id` to request-side `read_file` paths.
3. Exclude a result only when its path mapping is unambiguous and every candidate path is protected.
4. Apply the filtered tool-reference set before idle/force and size-based planning so token counts and eviction metadata stay accurate.
5. Run the focused microcompaction tests and confirm they pass.

### Task 4: Wire every production caller

**Files:**

- Modify: `packages/core/src/core/client.ts`
- Modify: `packages/core/src/core/geminiChat.ts`
- Modify: `packages/core/src/services/memoryPressureMonitor.ts`
- Test: corresponding focused test files

1. Resolve relative paths against the configured target directory.
2. Pass the same managed-memory predicate through pre-send idle/size, `/compress-fast`, and memory-pressure compaction.
3. Add or update focused caller tests that verify the option reaches microcompaction.
4. Run all affected focused tests.

### Task 5: Verify and review

**Files:**

- Review all changed files.

1. Run Prettier on changed files.
2. Run focused tests for microcompaction, path classification, client, GeminiChat, and memory-pressure behavior.
3. Run `npm run build && npm run typecheck` from the worktree root.
4. Run an independent code review, fix important findings, and repeat focused verification.
5. Re-run the original E2E reproduction against `node dist/cli.js` after a fresh bundle.
9 changes: 8 additions & 1 deletion packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { CommitAttributionService } from '../services/commitAttribution.js';
// Tools
import type { RelevantAutoMemoryPromptResult } from '../memory/manager.js';
import { AUTO_SKILL_THRESHOLD } from '../memory/manager.js';
import { isManagedMemoryPath } from '../memory/paths.js';
import { DEFAULT_AUTO_SKILL_MAX_TURNS } from '../memory/skillReviewAgentPlanner.js';
import { isProjectSkillPath } from '../skills/skill-paths.js';
import { ToolNames } from '../tools/tool-names.js';
Expand Down Expand Up @@ -1759,11 +1760,17 @@ export class GeminiClient {
opts?: MicrocompactOptions,
): Promise<boolean> {
try {
const projectRoot = this.config.getProjectRoot();
const targetDir = this.config.getTargetDir?.() ?? projectRoot;
Comment thread
yiliang114 marked this conversation as resolved.
const mcResult = microcompactHistory(
this.getHistoryShallow(),
lastCompletionTimestamp,
this.config.getClearContextOnIdle(),
opts,
{
Comment thread
yiliang114 marked this conversation as resolved.
...opts,
preserveReadFileResult: (filePath) =>
Comment thread
yiliang114 marked this conversation as resolved.
isManagedMemoryPath(filePath, projectRoot, targetDir),
},
);
if (!mcResult.meta) {
return false;
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/core/geminiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from './tokenLimits.js';
import { hasCycleInSchema } from '../tools/tools.js';
import { ToolNames } from '../tools/tool-names.js';
import { isManagedMemoryPath } from '../memory/paths.js';
import { STRUCTURED_OUTPUT_REDACTED_ARGS } from '../tools/syntheticOutput.js';
import type { StructuredError } from './turn.js';
import {
Expand Down Expand Up @@ -1769,13 +1770,19 @@ export class GeminiChat {
// apples to apples. The API-authoritative lastPromptTokenCount is
// then adjusted by the estimated delta — never replaced wholesale.
const beforeEstimate = estimateContentTokens(this.history);
const projectRoot = this.config.getProjectRoot();
const targetDir = this.config.getTargetDir?.() ?? projectRoot;
Comment thread
yiliang114 marked this conversation as resolved.

// Step 1: force microcompaction (clear old tool results + media)
const mcResult = microcompactHistory(
this.history,
null,
this.config.getClearContextOnIdle(),
{ force: true },
{
force: true,
preserveReadFileResult: (filePath) =>
isManagedMemoryPath(filePath, projectRoot, targetDir),
},
);
const mcMeta = mcResult.meta;

Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/memory/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,33 @@ export function isTeamAutoMemPath(
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}

/**
* Returns true when the resolved file lives in any managed-memory layer.
*
* Unlike {@link isAnyAutoMemPath}, this helper includes team memory and is
* intended only for read retention. It does not grant write permissions.
* Resolving the nearest existing path prevents a symlink inside a memory root
* from protecting content that actually lives outside that root.
*/
export function isManagedMemoryPath(
filePath: string,
projectRoot: string,
baseDir: string = projectRoot,
): boolean {
const absolutePath = path.resolve(baseDir, filePath);
const resolvedPath = path.normalize(realpathNearestExisting(absolutePath));
const roots = [
getAutoMemoryRoot(projectRoot),
getUserAutoMemoryRoot(),
getTeamAutoMemoryRoot(projectRoot),
Comment thread
yiliang114 marked this conversation as resolved.
];
return roots.some((root) => {
const resolvedRoot = path.normalize(realpathNearestExisting(root));
const rel = path.relative(resolvedRoot, resolvedPath);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
});
}

/**
* Follow a leading symlink chain at `inputPath` to its eventual target, even
* when that target does not exist yet (a dangling link).
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/memory/team-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
clearAutoMemoryRootCache,
getAutoMemoryRoot,
getTeamAutoMemoryIndexPath,
getTeamAutoMemoryRoot,
getUserAutoMemoryRoot,
isAnyAutoMemPath,
isManagedMemoryPath,
isTeamAutoMemPath,
TEAM_AUTO_MEMORY_DIRNAME,
} from './paths.js';
Expand Down Expand Up @@ -93,6 +96,54 @@ describe('team auto-memory paths', () => {
expect(isAnyAutoMemPath(teamFile, projectRoot)).toBe(false);
});

it('classifies all managed roots for read retention and rejects symlink escapes', () => {
const previousBaseDir = process.env['QWEN_CODE_MEMORY_BASE_DIR'];
const memoryBaseDir = path.join(projectRoot, '.runtime');
process.env['QWEN_CODE_MEMORY_BASE_DIR'] = memoryBaseDir;
clearAutoMemoryRootCache();
try {
const projectMemory = path.join(
getAutoMemoryRoot(projectRoot),
'project',
'context.md',
);
const userMemory = path.join(
getUserAutoMemoryRoot(),
'feedback',
'testing.md',
);
const teamMemory = path.join(
getTeamAutoMemoryRoot(projectRoot),
'reference',
'architecture.md',
);
expect(isManagedMemoryPath(projectMemory, projectRoot)).toBe(true);
expect(isManagedMemoryPath(userMemory, projectRoot)).toBe(true);
expect(isManagedMemoryPath(teamMemory, projectRoot)).toBe(true);
expect(
isManagedMemoryPath(
path.join(projectRoot, 'src', 'main.ts'),
projectRoot,
),
).toBe(false);

const projectMemoryRoot = getAutoMemoryRoot(projectRoot);
const outside = path.join(projectRoot, 'outside.md');
const escaped = path.join(projectMemoryRoot, 'escaped.md');
fs.mkdirSync(projectMemoryRoot, { recursive: true });
fs.writeFileSync(outside, 'outside');
fs.symlinkSync(outside, escaped);
expect(isManagedMemoryPath(escaped, projectRoot)).toBe(false);
} finally {
if (previousBaseDir === undefined) {
delete process.env['QWEN_CODE_MEMORY_BASE_DIR'];
} else {
process.env['QWEN_CODE_MEMORY_BASE_DIR'] = previousBaseDir;
}
clearAutoMemoryRootCache();
}
});

it('recognizes a first-ever write before the team-memory dir exists', () => {
const root = getTeamAutoMemoryRoot(projectRoot);
// Normal first-write state: nothing under .qwen has been created yet, so
Expand Down
21 changes: 18 additions & 3 deletions packages/core/src/services/memoryPressureMonitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,14 @@ const {
};
});

vi.mock('node:os', () => ({
vi.mock('node:os', async (importOriginal) => ({
...(await importOriginal<typeof import('node:os')>()),
totalmem: () => getMockOsTotalmem(),
cpus: () => [{ model: 'mock', speed: 0, times: {} }],
}));

vi.mock('node:fs', () => ({
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<typeof import('node:fs')>()),
readFileSync: (path: string) => getMockCgroupFile(path),
}));

Expand Down Expand Up @@ -162,6 +164,8 @@ function createMockConfig(
}
: overrides.geminiClient;
return {
getProjectRoot: () => '/mock/project',
getTargetDir: () => '/mock/project',
getFileReadCache: () =>
({
clear: vi.fn(),
Expand Down Expand Up @@ -1355,14 +1359,19 @@ describe('MemoryPressureMonitor', () => {
// Build history with 7 read_file tool results (keep=5, so 2 get cleared)
const toolHistory: Content[] = [];
for (let i = 0; i < 7; i++) {
const filePath =
i === 0
? '/mock/project/.qwen/team-memory/feedback/testing.md'
: `/f${i}.ts`;
toolHistory.push(
{
role: 'model',
parts: [
{
functionCall: {
id: `call_${i}`,
name: 'read_file',
args: { path: `/f${i}.ts` },
args: { file_path: filePath },
},
},
],
Expand Down Expand Up @@ -1419,6 +1428,12 @@ describe('MemoryPressureMonitor', () => {
),
);
expect(blankedResponses.length).toBeGreaterThan(0);
const memoryResult = compacted
.flatMap((entry) => entry.parts ?? [])
.find((part) => part.functionResponse?.id === 'call_0');
expect(memoryResult?.functionResponse?.response?.['output']).toBe(
'content of f0',
);
});

it('overrides positive toolResultsThresholdMinutes to 0', async () => {
Expand Down
25 changes: 18 additions & 7 deletions packages/core/src/services/memoryPressureMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getErrorMessage } from '../utils/errors.js';
import type { Config } from '../config/config.js';
import { MemoryDiagnosticsDumper } from './memoryDiagnosticsDumper.js';
import { microcompactHistory } from './microcompaction/microcompact.js';
import { isManagedMemoryPath } from '../memory/paths.js';
import {
recordMemoryUsage,
recordCpuUsage,
Expand Down Expand Up @@ -716,13 +717,23 @@ export class MemoryPressureMonitor extends EventEmitter {
const chat = client.getChat();
const history = chat.getHistoryShallow?.() ?? chat.getHistory();
const settings = this.coreConfig.getClearContextOnIdle();
const result = microcompactHistory(history, Date.now() - 1, {
...settings,
toolResultsThresholdMinutes:
(settings.toolResultsThresholdMinutes ?? 0) < 0
? settings.toolResultsThresholdMinutes
: 0,
});
const projectRoot = this.coreConfig.getProjectRoot();
const targetDir = this.coreConfig.getTargetDir?.() ?? projectRoot;
Comment thread
yiliang114 marked this conversation as resolved.
const result = microcompactHistory(
history,
Date.now() - 1,
{
...settings,
toolResultsThresholdMinutes:
(settings.toolResultsThresholdMinutes ?? 0) < 0
? settings.toolResultsThresholdMinutes
: 0,
},
{
preserveReadFileResult: (filePath) =>
isManagedMemoryPath(filePath, projectRoot, targetDir),
},
);
if (result.meta) {
chat.setHistory(result.history);
// Explicitly clear fileReadCache here instead of relying on
Expand Down
Loading
Loading