From 1965fc1d715e504ee2a6ed55bb68685daf7577f3 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 11:28:34 +0800 Subject: [PATCH 01/56] docs: add auto-memory implementation log --- docs/auto-memory-work-log.md | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/auto-memory-work-log.md diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md new file mode 100644 index 00000000000..f16faab9680 --- /dev/null +++ b/docs/auto-memory-work-log.md @@ -0,0 +1,61 @@ +# Auto-Memory Implementation Work Log + +## Overall Goal + +Implement a Claude Code parity memory system in Qwen Code incrementally: + +1. Human-managed context: `QWEN.md` / `AGENTS.md` +2. Explicit user memory: keep and narrow `save_memory` +3. Managed auto-memory: `.qwen/memory/` +4. Recall / Extract / Dream lifecycle +5. `/memory` / `/dream` / `/remember` UX and observability + +## Working Rules + +- Each part should be independently deliverable. +- Each part must include tests. +- Each part must finish with: + - functional verification + - targeted tests passing + - regression checks passing + - git commit completed + - work log updated +- At the start and end of each part, review the overall plan and this log. + +--- + +## Part 0 - Baseline and breakdown + +### Start review + +- Source plan: `auto-memory-doc/02-technical-design.md` in the analysis repo. +- Current implementation repo: `/Users/mochi/code/memory-worktree` +- Branch: `feat/auto-memory` +- Working tree baseline: clean + +### Goal + +- Confirm repo baseline +- Create implementation work log in repo +- Break work into independently verifiable parts + +### Result + +- Confirmed target repo and clean baseline +- Confirmed implementation branch `feat/auto-memory` +- Created in-repo work log +- Established staged plan: + 1. storage skeleton + 2. prompt integration + 3. recall + 4. extraction + 5. dream and commands + +### Verification + +- `git status --short` was empty before changes +- No code behavior changed in this part + +### Status + +Completed From 9892e8803c5e2a6244c22658905f68c0e897b0b0 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 11:34:26 +0800 Subject: [PATCH 02/56] feat(core): add managed auto-memory storage scaffold --- docs/auto-memory-work-log.md | 56 +++++++++ packages/core/src/index.ts | 8 ++ packages/core/src/memory/paths.ts | 57 +++++++++ packages/core/src/memory/store.test.ts | 116 ++++++++++++++++++ packages/core/src/memory/store.ts | 160 +++++++++++++++++++++++++ packages/core/src/memory/types.ts | 54 +++++++++ 6 files changed, 451 insertions(+) create mode 100644 packages/core/src/memory/paths.ts create mode 100644 packages/core/src/memory/store.test.ts create mode 100644 packages/core/src/memory/store.ts create mode 100644 packages/core/src/memory/types.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index f16faab9680..39d5d42c780 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -59,3 +59,59 @@ Implement a Claude Code parity memory system in Qwen Code incrementally: ### Status Completed + +--- + +## Part 1 - Managed auto-memory storage scaffold + +### Start review + +- Overall plan remains unchanged: build the full memory system incrementally, starting with the lowest-risk storage layer. +- Current repo baseline after Part 0: clean branch plus work log commit. +- Scope for this part: add independent managed auto-memory storage primitives without touching main prompt flow or existing `/memory` behavior. + +### Goal + +- Add managed auto-memory types +- Add managed auto-memory path helpers +- Add scaffold creation for `.qwen/memory/` +- Add tests for path stability, scaffold creation, idempotency, and read behavior + +### Implemented + +- Added `packages/core/src/memory/types.ts` +- Added `packages/core/src/memory/paths.ts` +- Added `packages/core/src/memory/store.ts` +- Added `packages/core/src/memory/store.test.ts` +- Exported the new modules from `packages/core/src/index.ts` + +### Functional verification + +- `ensureAutoMemoryScaffold(projectRoot)` now creates: + - `.qwen/memory/` + - `MEMORY.md` + - `meta.json` + - `extract-cursor.json` + - `user.md` + - `feedback.md` + - `project.md` + - `reference.md` +- Re-running scaffold creation preserves existing files. + +### Test verification + +- Passed targeted test: + - `npm exec --workspace=packages/core -- vitest run src/memory/store.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/utils/memoryDiscovery.test.ts src/core/prompts.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This part intentionally does not integrate auto-memory into prompt assembly yet. +- Existing `QWEN.md` / `AGENTS.md` behavior remains unchanged. + +### Status + +Completed diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 66359a8652c..935add90b23 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -110,6 +110,14 @@ export * from './services/gitWorktreeService.js'; export * from './services/sessionService.js'; export * from './services/shellExecutionService.js'; +// ============================================================================ +// Managed Auto-Memory +// ============================================================================ + +export * from './memory/types.js'; +export * from './memory/paths.js'; +export * from './memory/store.js'; + // ============================================================================ // IDE Support // ============================================================================ diff --git a/packages/core/src/memory/paths.ts b/packages/core/src/memory/paths.ts new file mode 100644 index 00000000000..64aa296055d --- /dev/null +++ b/packages/core/src/memory/paths.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { QWEN_DIR } from '../utils/paths.js'; +import type { AutoMemoryType } from './types.js'; + +export const AUTO_MEMORY_DIRNAME = 'memory'; +export const AUTO_MEMORY_INDEX_FILENAME = 'MEMORY.md'; +export const AUTO_MEMORY_METADATA_FILENAME = 'meta.json'; +export const AUTO_MEMORY_EXTRACT_CURSOR_FILENAME = 'extract-cursor.json'; +export const AUTO_MEMORY_CONSOLIDATION_LOCK_FILENAME = 'consolidation.lock'; + +export function getAutoMemoryRoot(projectRoot: string): string { + return path.join(projectRoot, QWEN_DIR, AUTO_MEMORY_DIRNAME); +} + +export function getAutoMemoryIndexPath(projectRoot: string): string { + return path.join(getAutoMemoryRoot(projectRoot), AUTO_MEMORY_INDEX_FILENAME); +} + +export function getAutoMemoryMetadataPath(projectRoot: string): string { + return path.join( + getAutoMemoryRoot(projectRoot), + AUTO_MEMORY_METADATA_FILENAME, + ); +} + +export function getAutoMemoryExtractCursorPath(projectRoot: string): string { + return path.join( + getAutoMemoryRoot(projectRoot), + AUTO_MEMORY_EXTRACT_CURSOR_FILENAME, + ); +} + +export function getAutoMemoryConsolidationLockPath( + projectRoot: string, +): string { + return path.join( + getAutoMemoryRoot(projectRoot), + AUTO_MEMORY_CONSOLIDATION_LOCK_FILENAME, + ); +} + +export function getAutoMemoryTopicFilename(type: AutoMemoryType): string { + return `${type}.md`; +} + +export function getAutoMemoryTopicPath( + projectRoot: string, + type: AutoMemoryType, +): string { + return path.join(getAutoMemoryRoot(projectRoot), getAutoMemoryTopicFilename(type)); +} \ No newline at end of file diff --git a/packages/core/src/memory/store.test.ts b/packages/core/src/memory/store.test.ts new file mode 100644 index 00000000000..10804227f0c --- /dev/null +++ b/packages/core/src/memory/store.test.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + getAutoMemoryConsolidationLockPath, + getAutoMemoryExtractCursorPath, + getAutoMemoryIndexPath, + getAutoMemoryMetadataPath, + getAutoMemoryRoot, + getAutoMemoryTopicPath, +} from './paths.js'; +import { + createDefaultAutoMemoryIndex, + createDefaultAutoMemoryMetadata, + createDefaultAutoMemoryTopic, + ensureAutoMemoryScaffold, + readAutoMemoryIndex, +} from './store.js'; +import { AUTO_MEMORY_TYPES } from './types.js'; + +describe('auto-memory storage scaffold', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('builds stable auto-memory paths under project .qwen directory', () => { + expect(getAutoMemoryRoot(projectRoot)).toBe( + path.join(projectRoot, '.qwen', 'memory'), + ); + expect(getAutoMemoryIndexPath(projectRoot)).toBe( + path.join(projectRoot, '.qwen', 'memory', 'MEMORY.md'), + ); + expect(getAutoMemoryMetadataPath(projectRoot)).toBe( + path.join(projectRoot, '.qwen', 'memory', 'meta.json'), + ); + expect(getAutoMemoryExtractCursorPath(projectRoot)).toBe( + path.join(projectRoot, '.qwen', 'memory', 'extract-cursor.json'), + ); + expect(getAutoMemoryConsolidationLockPath(projectRoot)).toBe( + path.join(projectRoot, '.qwen', 'memory', 'consolidation.lock'), + ); + expect(getAutoMemoryTopicPath(projectRoot, 'feedback')).toBe( + path.join(projectRoot, '.qwen', 'memory', 'feedback.md'), + ); + }); + + it('creates a complete managed auto-memory scaffold', async () => { + const now = new Date('2026-04-01T08:00:00.000Z'); + await ensureAutoMemoryScaffold(projectRoot, now); + + const index = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); + expect(index).toBe(createDefaultAutoMemoryIndex()); + + const metadata = JSON.parse( + await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'), + ); + expect(metadata).toEqual(createDefaultAutoMemoryMetadata(now)); + + const cursor = JSON.parse( + await fs.readFile(getAutoMemoryExtractCursorPath(projectRoot), 'utf-8'), + ); + expect(cursor).toEqual({ + updatedAt: '2026-04-01T08:00:00.000Z', + }); + + for (const type of AUTO_MEMORY_TYPES) { + await expect(fs.readFile(getAutoMemoryTopicPath(projectRoot, type), 'utf-8')).resolves.toBe( + createDefaultAutoMemoryTopic(type), + ); + } + }); + + it('is idempotent and preserves existing index content', async () => { + await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T08:00:00.000Z')); + const customIndex = '# Existing Index\n\n- keep me\n'; + await fs.writeFile(getAutoMemoryIndexPath(projectRoot), customIndex, 'utf-8'); + + await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-02T08:00:00.000Z')); + + await expect(fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8')).resolves.toBe( + customIndex, + ); + }); + + it('returns null when the auto-memory index does not exist yet', async () => { + await expect(readAutoMemoryIndex(projectRoot)).resolves.toBeNull(); + }); + + it('reads the managed auto-memory index after scaffold creation', async () => { + await ensureAutoMemoryScaffold(projectRoot); + await expect(readAutoMemoryIndex(projectRoot)).resolves.toContain( + '# Managed Auto-Memory Index', + ); + }); +}); \ No newline at end of file diff --git a/packages/core/src/memory/store.ts b/packages/core/src/memory/store.ts new file mode 100644 index 00000000000..e5be62a3621 --- /dev/null +++ b/packages/core/src/memory/store.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { + AUTO_MEMORY_INDEX_FILENAME, + getAutoMemoryExtractCursorPath, + getAutoMemoryIndexPath, + getAutoMemoryMetadataPath, + getAutoMemoryRoot, + getAutoMemoryTopicPath, +} from './paths.js'; +import { + AUTO_MEMORY_SCHEMA_VERSION, + AUTO_MEMORY_TYPES, + type AutoMemoryExtractCursor, + type AutoMemoryMetadata, + type AutoMemoryType, +} from './types.js'; + +const TOPIC_DESCRIPTIONS: Record = { + user: 'User profile, preferences, background, and stable collaboration context.', + feedback: + 'Corrections and validated guidance about how the assistant should work with this user/project.', + project: + 'Non-derivable project facts, goals, constraints, incidents, and coordination context.', + reference: + 'Pointers to durable external systems, dashboards, tickets, and reference resources.', +}; + +function buildTopicTitle(type: AutoMemoryType): string { + switch (type) { + case 'user': + return 'User Memory'; + case 'feedback': + return 'Feedback Memory'; + case 'project': + return 'Project Memory'; + case 'reference': + return 'Reference Memory'; + } +} + +export function createDefaultAutoMemoryMetadata( + now = new Date(), +): AutoMemoryMetadata { + const iso = now.toISOString(); + return { + version: AUTO_MEMORY_SCHEMA_VERSION, + createdAt: iso, + updatedAt: iso, + }; +} + +export function createDefaultAutoMemoryExtractCursor( + now = new Date(), +): AutoMemoryExtractCursor { + return { + updatedAt: now.toISOString(), + }; +} + +export function createDefaultAutoMemoryIndex(): string { + const lines = [ + '# Managed Auto-Memory Index', + '', + 'This index is maintained by Qwen Code. Keep entries concise and store durable details in topic files.', + '', + ...AUTO_MEMORY_TYPES.map( + (type) => + `- [${buildTopicTitle(type)}](${type}.md) — ${TOPIC_DESCRIPTIONS[type]}`, + ), + '', + ]; + return lines.join('\n'); +} + +export function createDefaultAutoMemoryTopic(type: AutoMemoryType): string { + const title = buildTopicTitle(type); + return [ + '---', + `type: ${type}`, + `title: ${title}`, + `description: ${TOPIC_DESCRIPTIONS[type]}`, + '---', + '', + `# ${title}`, + '', + '_No entries yet._', + '', + ].join('\n'); +} + +async function writeFileIfMissing( + filePath: string, + content: string, +): Promise { + try { + await fs.writeFile(filePath, content, { + encoding: 'utf-8', + flag: 'wx', + }); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== 'EEXIST') { + throw error; + } + } +} + +export async function ensureAutoMemoryScaffold( + projectRoot: string, + now = new Date(), +): Promise { + const root = getAutoMemoryRoot(projectRoot); + await fs.mkdir(root, { recursive: true }); + + await writeFileIfMissing( + getAutoMemoryIndexPath(projectRoot), + createDefaultAutoMemoryIndex(), + ); + await writeFileIfMissing( + getAutoMemoryMetadataPath(projectRoot), + JSON.stringify(createDefaultAutoMemoryMetadata(now), null, 2) + '\n', + ); + await writeFileIfMissing( + getAutoMemoryExtractCursorPath(projectRoot), + JSON.stringify(createDefaultAutoMemoryExtractCursor(now), null, 2) + '\n', + ); + + await Promise.all( + AUTO_MEMORY_TYPES.map((type) => + writeFileIfMissing( + getAutoMemoryTopicPath(projectRoot, type), + createDefaultAutoMemoryTopic(type), + ), + ), + ); +} + +export async function readAutoMemoryIndex( + projectRoot: string, +): Promise { + try { + return await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + return null; + } + throw error; + } +} + +export { + AUTO_MEMORY_INDEX_FILENAME, +}; \ No newline at end of file diff --git a/packages/core/src/memory/types.ts b/packages/core/src/memory/types.ts new file mode 100644 index 00000000000..c7ab7d50ff6 --- /dev/null +++ b/packages/core/src/memory/types.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const AUTO_MEMORY_TYPES = [ + 'user', + 'feedback', + 'project', + 'reference', +] as const; + +export type AutoMemoryType = (typeof AUTO_MEMORY_TYPES)[number]; + +export const AUTO_MEMORY_SCHEMA_VERSION = 1; + +export interface AutoMemorySourceRef { + sessionId?: string; + transcriptPath?: string; + recordedAt: string; + messageIds?: string[]; +} + +export interface AutoMemoryEntry { + id: string; + type: AutoMemoryType; + title: string; + summary: string; + tags: string[]; + lastUpdated: string; + stability: 'working' | 'stable'; + sources: AutoMemorySourceRef[]; +} + +export interface AutoMemoryTopicDocument { + type: AutoMemoryType; + title: string; + description: string; + entries: AutoMemoryEntry[]; +} + +export interface AutoMemoryMetadata { + version: typeof AUTO_MEMORY_SCHEMA_VERSION; + createdAt: string; + updatedAt: string; +} + +export interface AutoMemoryExtractCursor { + sessionId?: string; + transcriptPath?: string; + processedOffset?: number; + updatedAt: string; +} \ No newline at end of file From 7fb90faaf5c7f7063b95404f8945f378654be719 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 11:37:17 +0800 Subject: [PATCH 03/56] feat(core): load managed auto-memory index --- docs/auto-memory-work-log.md | 50 ++++++++++++++++++++++ packages/core/src/config/config.test.ts | 40 +++++++++++++++++ packages/core/src/config/config.ts | 12 +++++- packages/core/src/index.ts | 1 + packages/core/src/memory/prompt.test.ts | 57 +++++++++++++++++++++++++ packages/core/src/memory/prompt.ts | 55 ++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/memory/prompt.test.ts create mode 100644 packages/core/src/memory/prompt.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 39d5d42c780..4383de8f452 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -115,3 +115,53 @@ Completed ### Status Completed + +--- + +## Part 2 - Managed auto-memory index prompt integration + +### Start review + +- Overall plan remains: storage first, then controlled prompt integration, then recall/extract/dream. +- Part 1 already established a safe on-disk scaffold under `.qwen/memory/`. +- Scope for this part: load the managed `MEMORY.md` index into prompt memory without changing existing `QWEN.md` / `AGENTS.md` discovery behavior. + +### Goal + +- Add managed auto-memory prompt formatting helpers +- Append managed auto-memory index after hierarchical memory when present +- Preserve legacy behavior when no managed index exists +- Add tests for prompt formatting and config integration + +### Implemented + +- Added `packages/core/src/memory/prompt.ts` +- Added `packages/core/src/memory/prompt.test.ts` +- Updated `Config.refreshHierarchicalMemory()` to append managed auto-memory index content +- Added config tests for merge and legacy fallback behavior +- Exported the new prompt helpers from `packages/core/src/index.ts` + +### Functional verification + +- If `.qwen/memory/MEMORY.md` exists, it is appended to `userMemory` as a dedicated `Managed Auto-Memory` block. +- If managed auto-memory does not exist, `userMemory` remains exactly the same as before. +- Oversized managed indexes are truncated to a safe prompt budget. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/prompt.test.ts src/config/config.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/core/prompts.test.ts src/utils/memoryDiscovery.test.ts src/memory/store.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This part only integrates the managed memory index, not relevant recall. +- Existing hierarchical memory file discovery is unchanged. +- Existing `save_memory` behavior is unchanged. + +### Status + +Completed diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index aefe25ea126..49ed2f75d98 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -38,6 +38,8 @@ import { RipgrepFallbackEvent } from '../telemetry/types.js'; import { ToolRegistry } from '../tools/tool-registry.js'; import { fireNotificationHook } from '../core/toolHookTriggers.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js'; +import { readAutoMemoryIndex } from '../memory/store.js'; function createToolMock(toolName: string) { const ToolMock = vi.fn(); @@ -82,6 +84,10 @@ vi.mock('../utils/memoryDiscovery.js', () => ({ .mockResolvedValue({ memoryContent: '', fileCount: 0 }), })); +vi.mock('../memory/store.js', () => ({ + readAutoMemoryIndex: vi.fn().mockResolvedValue(null), +})); + // Mock individual tools if their constructors are complex or have side effects vi.mock('../tools/ls', () => ({ LSTool: createToolMock('list_directory'), @@ -558,6 +564,40 @@ describe('Server Config (config.ts)', () => { expect(config.getUserMemory()).toBe(''); }); + it('refreshHierarchicalMemory should append managed auto-memory index when present', async () => { + const config = new Config(baseParams); + + vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ + memoryContent: '--- Context from: QWEN.md ---\nProject rules', + fileCount: 1, + }); + vi.mocked(readAutoMemoryIndex).mockResolvedValue( + '# Managed Auto-Memory Index\n\n- [Project Memory](project.md)', + ); + + await config.refreshHierarchicalMemory(); + + expect(config.getUserMemory()).toContain('Project rules'); + expect(config.getUserMemory()).toContain('## Managed Auto-Memory'); + expect(config.getUserMemory()).toContain('[Project Memory](project.md)'); + }); + + it('refreshHierarchicalMemory should preserve legacy behavior when no managed auto-memory index exists', async () => { + const config = new Config(baseParams); + + vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ + memoryContent: '--- Context from: QWEN.md ---\nProject rules', + fileCount: 1, + }); + vi.mocked(readAutoMemoryIndex).mockResolvedValue(null); + + await config.refreshHierarchicalMemory(); + + expect(config.getUserMemory()).toBe( + '--- Context from: QWEN.md ---\nProject rules', + ); + }); + it('Config constructor should call setGeminiMdFilename with contextFileName if provided', () => { const contextFileName = 'CUSTOM_AGENTS.md'; const paramsWithContextFile: ConfigParameters = { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c2d1319915a..ea244863316 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -129,6 +129,8 @@ import { setDebugLogSession, type DebugLogger, } from '../utils/debugLogger.js'; +import { readAutoMemoryIndex } from '../memory/store.js'; +import { appendManagedAutoMemoryToUserMemory } from '../memory/prompt.js'; import { ModelsConfig, @@ -991,7 +993,15 @@ export class Config { this.isTrustedFolder(), this.getImportFormat(), ); - this.setUserMemory(memoryContent); + const managedAutoMemoryIndex = await readAutoMemoryIndex( + this.getProjectRoot(), + ); + this.setUserMemory( + appendManagedAutoMemoryToUserMemory( + memoryContent, + managedAutoMemoryIndex, + ), + ); this.setGeminiMdFileCount(fileCount); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 935add90b23..6243de94c0d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -117,6 +117,7 @@ export * from './services/shellExecutionService.js'; export * from './memory/types.js'; export * from './memory/paths.js'; export * from './memory/store.js'; +export * from './memory/prompt.js'; // ============================================================================ // IDE Support diff --git a/packages/core/src/memory/prompt.test.ts b/packages/core/src/memory/prompt.test.ts new file mode 100644 index 00000000000..0accd642a69 --- /dev/null +++ b/packages/core/src/memory/prompt.test.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + appendManagedAutoMemoryToUserMemory, + buildManagedAutoMemoryPrompt, + MANAGED_AUTO_MEMORY_HEADER, + MAX_MANAGED_AUTO_MEMORY_CHARS, +} from './prompt.js'; + +describe('managed auto-memory prompt helpers', () => { + it('returns empty string when no managed index content exists', () => { + expect(buildManagedAutoMemoryPrompt()).toBe(''); + expect(buildManagedAutoMemoryPrompt(' \n\n ')).toBe(''); + }); + + it('builds a managed auto-memory prompt block from the index content', () => { + const prompt = buildManagedAutoMemoryPrompt('# Managed Auto-Memory Index'); + + expect(prompt).toContain(MANAGED_AUTO_MEMORY_HEADER); + expect(prompt).toContain('# Managed Auto-Memory Index'); + expect(prompt).toContain('durable project memory'); + }); + + it('appends managed auto-memory after existing hierarchical memory', () => { + const result = appendManagedAutoMemoryToUserMemory( + '--- Context from: QWEN.md ---\nProject rules', + '# Managed Auto-Memory Index', + ); + + expect(result).toContain('Project rules'); + expect(result).toContain('\n\n---\n\n'); + expect(result).toContain(MANAGED_AUTO_MEMORY_HEADER); + }); + + it('returns only managed auto-memory when hierarchical memory is empty', () => { + const result = appendManagedAutoMemoryToUserMemory( + ' ', + '# Managed Auto-Memory Index', + ); + + expect(result).toContain(MANAGED_AUTO_MEMORY_HEADER); + expect(result.startsWith(MANAGED_AUTO_MEMORY_HEADER)).toBe(true); + }); + + it('truncates oversized managed auto-memory index content', () => { + const oversizedIndex = 'x'.repeat(MAX_MANAGED_AUTO_MEMORY_CHARS + 100); + const result = buildManagedAutoMemoryPrompt(oversizedIndex); + + expect(result.length).toBeLessThan(13_000); + expect(result).toContain('truncated for prompt budget'); + }); +}); \ No newline at end of file diff --git a/packages/core/src/memory/prompt.ts b/packages/core/src/memory/prompt.ts new file mode 100644 index 00000000000..ccf8733a9bc --- /dev/null +++ b/packages/core/src/memory/prompt.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const MANAGED_AUTO_MEMORY_HEADER = '## Managed Auto-Memory'; +const MAX_MANAGED_AUTO_MEMORY_CHARS = 12_000; + +function truncateManagedAutoMemoryIndex(indexContent: string): string { + const trimmed = indexContent.trim(); + if (trimmed.length <= MAX_MANAGED_AUTO_MEMORY_CHARS) { + return trimmed; + } + + const truncated = trimmed.slice(0, MAX_MANAGED_AUTO_MEMORY_CHARS).trimEnd(); + return `${truncated}\n\n> NOTE: Managed auto-memory index truncated for prompt budget.`; +} + +export function buildManagedAutoMemoryPrompt(indexContent?: string | null): string { + const trimmed = indexContent?.trim(); + if (!trimmed) { + return ''; + } + + return [ + MANAGED_AUTO_MEMORY_HEADER, + '', + 'Use this as durable project memory when relevant. The detailed topic files remain on disk; this block is the loaded index.', + '', + truncateManagedAutoMemoryIndex(trimmed), + ].join('\n'); +} + +export function appendManagedAutoMemoryToUserMemory( + userMemory: string, + indexContent?: string | null, +): string { + const managedPrompt = buildManagedAutoMemoryPrompt(indexContent); + const trimmedUserMemory = userMemory.trim(); + + if (!managedPrompt) { + return userMemory; + } + if (!trimmedUserMemory) { + return managedPrompt; + } + + return `${trimmedUserMemory}\n\n---\n\n${managedPrompt}`; +} + +export { + MANAGED_AUTO_MEMORY_HEADER, + MAX_MANAGED_AUTO_MEMORY_CHARS, +}; \ No newline at end of file From c19045ed73212fdc462a180cf6638e80c60e209a Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 11:45:36 +0800 Subject: [PATCH 04/56] feat(core): add managed auto-memory recall --- docs/auto-memory-work-log.md | 52 ++++++++++ packages/core/src/core/client.test.ts | 44 ++++++++ packages/core/src/core/client.ts | 10 ++ packages/core/src/index.ts | 2 + packages/core/src/memory/recall.test.ts | 60 +++++++++++ packages/core/src/memory/recall.ts | 127 ++++++++++++++++++++++++ packages/core/src/memory/scan.test.ts | 86 ++++++++++++++++ packages/core/src/memory/scan.ts | 71 +++++++++++++ 8 files changed, 452 insertions(+) create mode 100644 packages/core/src/memory/recall.test.ts create mode 100644 packages/core/src/memory/recall.ts create mode 100644 packages/core/src/memory/scan.test.ts create mode 100644 packages/core/src/memory/scan.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 4383de8f452..74df9677f54 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -165,3 +165,55 @@ Completed ### Status Completed + +--- + +## Part 3 - Relevant managed auto-memory recall + +### Start review + +- Overall plan remains: storage → managed index integration → relevant recall → extraction → dream and commands. +- Parts 1 and 2 already established the managed `.qwen/memory/` scaffold and appended `MEMORY.md` into `userMemory` safely. +- Scope for this part: add low-risk, query-sensitive relevant recall from managed topic files without changing legacy hierarchical memory discovery or `save_memory` semantics. + +### Goal + +- Scan managed auto-memory topic files into structured documents +- Select relevant managed memory for a user query +- Inject the relevant memory block into the per-request reminder path +- Add tests for scanning, recall selection, and client integration + +### Implemented + +- Added `packages/core/src/memory/scan.ts` +- Added `packages/core/src/memory/scan.test.ts` +- Added `packages/core/src/memory/recall.ts` +- Added `packages/core/src/memory/recall.test.ts` +- Updated `packages/core/src/core/client.ts` to prepend relevant managed auto-memory for `UserQuery` +- Updated `packages/core/src/core/client.test.ts` with recall prompt injection coverage +- Exported the new scan/recall helpers from `packages/core/src/index.ts` + +### Functional verification + +- Managed topic files are parsed into structured recall candidates with title/frontmatter/body support. +- User queries now receive a dedicated `Relevant Managed Auto-Memory` reminder block when matching managed topic content exists. +- If no managed topic files exist or no relevant content is found, request behavior remains unchanged. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/scan.test.ts src/memory/recall.test.ts src/core/client.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/config/config.test.ts src/core/prompts.test.ts src/utils/memoryDiscovery.test.ts src/memory/prompt.test.ts src/memory/store.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This part uses heuristic recall selection for a safe first integration point. +- The recall prompt is injected through the existing request reminder path, minimizing surface-area risk. +- Extraction and dream/consolidation are intentionally deferred to later parts. + +### Status + +Completed diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 3c181ba8ff7..6b3e49c9828 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -38,6 +38,7 @@ import { promptIdContext } from '../utils/promptIdContext.js'; import { setSimulate429 } from '../utils/testUtils.js'; import { ideContextStore } from '../ide/ideContext.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; +import { buildRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; // Mock fs module to prevent actual file system operations during tests const mockFileSystem = new Map(); @@ -90,6 +91,9 @@ vi.mock('./turn', async (importOriginal) => { vi.mock('../config/config.js'); vi.mock('./prompts'); +vi.mock('../memory/recall.js', () => ({ + buildRelevantAutoMemoryPromptForQuery: vi.fn().mockResolvedValue(''), +})); vi.mock('../utils/getFolderStructure', () => ({ getFolderStructure: vi.fn().mockResolvedValue('Mock Folder Structure'), })); @@ -1292,6 +1296,46 @@ hello }); }); + it('should prepend relevant managed auto-memory prompt when recall returns content', async () => { + vi.mocked(buildRelevantAutoMemoryPromptForQuery).mockResolvedValue( + '## Relevant Managed Auto-Memory\n\n- User prefers terse responses.', + ); + + const mockStream = (async function* () { + yield { type: 'content', value: 'Hello' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), + }; + client['chat'] = mockChat as GeminiChat; + + const stream = client.sendMessageStream( + [{ text: 'Please answer tersely' }], + new AbortController().signal, + 'prompt-id-memory', + ); + for await (const _ of stream) { + // consume stream + } + + expect(buildRelevantAutoMemoryPromptForQuery).toHaveBeenCalledWith( + '/test/project/root', + 'Please answer tersely', + ); + expect(mockTurnRunFn).toHaveBeenCalledWith( + 'test-model', + expect.arrayContaining([ + '## Relevant Managed Auto-Memory\n\n- User prefers terse responses.', + 'Please answer tersely', + ]), + expect.any(AbortSignal), + ); + }); + it('should add context if ideMode is enabled and there are open files but no active file', async () => { // Arrange vi.mocked(ideContextStore.get).mockReturnValue({ diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 43c6f556f75..5d2088ea9de 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -47,6 +47,7 @@ import { LoopDetectionService } from '../services/loopDetectionService.js'; // Tools import { AgentTool } from '../tools/agent.js'; +import { buildRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; // Telemetry import { @@ -612,6 +613,15 @@ export class GeminiClient { let requestToSent = await flatMapTextParts(request, async (text) => [text]); if (messageType === SendMessageType.UserQuery) { const systemReminders = []; + const relevantAutoMemoryPrompt = + await buildRelevantAutoMemoryPromptForQuery( + this.config.getProjectRoot(), + partToString(request), + ); + + if (relevantAutoMemoryPrompt) { + systemReminders.push(relevantAutoMemoryPrompt); + } // add subagent system reminder if there are subagents const hasAgentTool = this.config diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6243de94c0d..21ce82b3b4d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,6 +118,8 @@ export * from './memory/types.js'; export * from './memory/paths.js'; export * from './memory/store.js'; export * from './memory/prompt.js'; +export * from './memory/scan.js'; +export * from './memory/recall.js'; // ============================================================================ // IDE Support diff --git a/packages/core/src/memory/recall.test.ts b/packages/core/src/memory/recall.test.ts new file mode 100644 index 00000000000..327df9e23bc --- /dev/null +++ b/packages/core/src/memory/recall.test.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + buildRelevantAutoMemoryPrompt, + selectRelevantAutoMemoryDocuments, +} from './recall.js'; +import type { ScannedAutoMemoryDocument } from './scan.js'; + +const docs: ScannedAutoMemoryDocument[] = [ + { + type: 'reference', + filePath: '/tmp/reference.md', + title: 'Reference Memory', + description: 'Dashboards and external docs', + body: '# Reference Memory\n\n- Grafana dashboard: grafana.internal/d/api-latency', + }, + { + type: 'project', + filePath: '/tmp/project.md', + title: 'Project Memory', + description: 'Project constraints and release context', + body: '# Project Memory\n\n- Release freeze starts Friday.', + }, + { + type: 'user', + filePath: '/tmp/user.md', + title: 'User Memory', + description: 'User preferences', + body: '# User Memory\n\n- User prefers terse responses.', + }, +]; + +describe('auto-memory relevant recall', () => { + it('selects the most relevant documents for a query', () => { + const selected = selectRelevantAutoMemoryDocuments( + 'check the dashboard reference for latency', + docs, + ); + + expect(selected[0]?.type).toBe('reference'); + expect(selected.map((doc) => doc.type)).toContain('reference'); + }); + + it('returns an empty list for an empty query', () => { + expect(selectRelevantAutoMemoryDocuments(' ', docs)).toEqual([]); + }); + + it('formats selected documents as a prompt block', () => { + const prompt = buildRelevantAutoMemoryPrompt([docs[0], docs[2]]); + + expect(prompt).toContain('## Relevant Managed Auto-Memory'); + expect(prompt).toContain('Reference Memory (reference.md)'); + expect(prompt).toContain('User Memory (user.md)'); + }); +}); \ No newline at end of file diff --git a/packages/core/src/memory/recall.ts b/packages/core/src/memory/recall.ts new file mode 100644 index 00000000000..2799df369d3 --- /dev/null +++ b/packages/core/src/memory/recall.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { + scanAutoMemoryTopicDocuments, + type ScannedAutoMemoryDocument, +} from './scan.js'; + +const MAX_RELEVANT_DOCS = 3; +const MAX_DOC_BODY_CHARS = 1_200; + +const TYPE_KEYWORDS: Record = { + user: ['user', 'preference', 'preferences', 'background', 'role', 'terse'], + feedback: ['feedback', 'rule', 'rules', 'avoid', 'style', 'summary'], + project: ['project', 'goal', 'goals', 'incident', 'deadline', 'release'], + reference: ['reference', 'dashboard', 'ticket', 'docs', 'doc', 'link'], +}; + +function tokenize(text: string): string[] { + return Array.from( + new Set( + text + .toLowerCase() + .split(/[^a-z0-9]+/) + .map((token) => token.trim()) + .filter((token) => token.length >= 3), + ), + ); +} + +function normalizeBody(body: string): string { + const trimmed = body.trim(); + if (trimmed === '_No entries yet._') { + return ''; + } + return trimmed; +} + +function scoreDocument( + queryTokens: string[], + doc: ScannedAutoMemoryDocument, +): number { + const normalizedBody = normalizeBody(doc.body); + const haystack = [doc.type, doc.title, doc.description, normalizedBody] + .join(' ') + .toLowerCase(); + + let score = 0; + for (const token of queryTokens) { + if (haystack.includes(token)) { + score += 2; + } + if (TYPE_KEYWORDS[doc.type]?.includes(token)) { + score += 1; + } + } + + if (normalizedBody.length > 0) { + score += 1; + } + + return score; +} + +export function selectRelevantAutoMemoryDocuments( + query: string, + docs: ScannedAutoMemoryDocument[], + limit = MAX_RELEVANT_DOCS, +): ScannedAutoMemoryDocument[] { + const queryTokens = tokenize(query); + if (queryTokens.length === 0) { + return []; + } + + return docs + .map((doc) => ({ doc, score: scoreDocument(queryTokens, doc) })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score || a.doc.type.localeCompare(b.doc.type)) + .slice(0, limit) + .map(({ doc }) => doc); +} + +function truncateBody(body: string): string { + const normalized = normalizeBody(body); + if (normalized.length <= MAX_DOC_BODY_CHARS) { + return normalized; + } + return `${normalized.slice(0, MAX_DOC_BODY_CHARS).trimEnd()}\n\n> NOTE: Relevant memory truncated for prompt budget.`; +} + +export function buildRelevantAutoMemoryPrompt( + docs: ScannedAutoMemoryDocument[], +): string { + if (docs.length === 0) { + return ''; + } + + return [ + '## Relevant Managed Auto-Memory', + '', + 'Use the following project memory only when it is directly relevant to the current request.', + '', + ...docs.flatMap((doc) => { + const body = truncateBody(doc.body); + return [ + `### ${doc.title} (${path.basename(doc.filePath)})`, + doc.description, + '', + body || '_No detailed entries yet._', + '', + ]; + }), + ].join('\n'); +} + +export async function buildRelevantAutoMemoryPromptForQuery( + projectRoot: string, + query: string, +): Promise { + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + const selected = selectRelevantAutoMemoryDocuments(query, docs); + return buildRelevantAutoMemoryPrompt(selected); +} \ No newline at end of file diff --git a/packages/core/src/memory/scan.test.ts b/packages/core/src/memory/scan.test.ts new file mode 100644 index 00000000000..207f803db9c --- /dev/null +++ b/packages/core/src/memory/scan.test.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getAutoMemoryTopicPath } from './paths.js'; +import { + parseAutoMemoryTopicDocument, + scanAutoMemoryTopicDocuments, +} from './scan.js'; +import { ensureAutoMemoryScaffold } from './store.js'; + +describe('auto-memory topic scanning', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-scan-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot); + }); + + afterEach(async () => { + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('parses a managed auto-memory topic document', () => { + const parsed = parseAutoMemoryTopicDocument( + '/tmp/project.md', + [ + '---', + 'type: project', + 'title: Project Memory', + 'description: Project context', + '---', + '', + '# Project Memory', + '', + '- Release freeze starts Friday.', + ].join('\n'), + ); + + expect(parsed).toEqual({ + type: 'project', + filePath: '/tmp/project.md', + title: 'Project Memory', + description: 'Project context', + body: '# Project Memory\n\n- Release freeze starts Friday.', + }); + }); + + it('scans existing auto-memory topic files from the project scaffold', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'reference'), + [ + '---', + 'type: reference', + 'title: Reference Memory', + 'description: External references', + '---', + '', + '# Reference Memory', + '', + '- Oncall dashboard: grafana.internal/d/api-latency', + ].join('\n'), + 'utf-8', + ); + + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + const referenceDoc = docs.find((doc) => doc.type === 'reference'); + + expect(referenceDoc?.description).toBe('External references'); + expect(referenceDoc?.body).toContain('grafana.internal/d/api-latency'); + }); +}); \ No newline at end of file diff --git a/packages/core/src/memory/scan.ts b/packages/core/src/memory/scan.ts new file mode 100644 index 00000000000..2f0863ca3ea --- /dev/null +++ b/packages/core/src/memory/scan.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { AUTO_MEMORY_TYPES, type AutoMemoryType } from './types.js'; +import { getAutoMemoryTopicPath } from './paths.js'; + +export interface ScannedAutoMemoryDocument { + type: AutoMemoryType; + filePath: string; + title: string; + description: string; + body: string; +} + +function parseFrontmatterValue( + frontmatter: string, + key: string, +): string | undefined { + const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, 'm')); + return match?.[1]?.trim(); +} + +export function parseAutoMemoryTopicDocument( + filePath: string, + content: string, +): ScannedAutoMemoryDocument | null { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!frontmatterMatch) { + return null; + } + + const [, frontmatter, bodyContent] = frontmatterMatch; + const rawType = parseFrontmatterValue(frontmatter, 'type'); + if (!rawType || !AUTO_MEMORY_TYPES.includes(rawType as AutoMemoryType)) { + return null; + } + + return { + type: rawType as AutoMemoryType, + filePath, + title: parseFrontmatterValue(frontmatter, 'title') ?? rawType, + description: parseFrontmatterValue(frontmatter, 'description') ?? '', + body: bodyContent.trim(), + }; +} + +export async function scanAutoMemoryTopicDocuments( + projectRoot: string, +): Promise { + const docs = await Promise.all( + AUTO_MEMORY_TYPES.map(async (type) => { + const filePath = getAutoMemoryTopicPath(projectRoot, type); + try { + const content = await fs.readFile(filePath, 'utf-8'); + return parseAutoMemoryTopicDocument(filePath, content); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + return null; + } + throw error; + } + }), + ); + + return docs.filter((doc): doc is ScannedAutoMemoryDocument => doc !== null); +} \ No newline at end of file From a5b6683f8afcff0f098344961b64a09a4cc97a23 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 11:52:49 +0800 Subject: [PATCH 05/56] feat(core): add managed auto-memory extraction --- docs/auto-memory-work-log.md | 56 ++++ packages/core/src/core/client.test.ts | 56 ++++ packages/core/src/core/client.ts | 29 +- packages/core/src/index.ts | 2 + packages/core/src/memory/extract.test.ts | 128 +++++++++ packages/core/src/memory/extract.ts | 336 +++++++++++++++++++++++ packages/core/src/memory/state.test.ts | 27 ++ packages/core/src/memory/state.ts | 23 ++ 8 files changed, 656 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/memory/extract.test.ts create mode 100644 packages/core/src/memory/extract.ts create mode 100644 packages/core/src/memory/state.test.ts create mode 100644 packages/core/src/memory/state.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 74df9677f54..0e4e9f1423f 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -217,3 +217,59 @@ Completed ### Status Completed + +--- + +## Part 4 - Managed auto-memory extraction flow + +### Start review + +- Overall plan remains: storage → managed index integration → relevant recall → extraction → dream and commands. +- Parts 1 to 3 already provide scaffold, index loading, and request-time relevant recall. +- Scope for this part: add a safe MVP extraction pipeline that runs after a completed user query, tracks incremental cursor state, and writes durable summaries into managed topic files. + +### Goal + +- Add extraction running-state guards +- Add transcript slicing and cursor persistence +- Add heuristic durable-memory patch extraction and topic-file application +- Trigger extraction after completed user queries in the client flow +- Add tests for extraction state, cursor/idempotency, and client integration + +### Implemented + +- Added `packages/core/src/memory/state.ts` +- Added `packages/core/src/memory/state.test.ts` +- Added `packages/core/src/memory/extract.ts` +- Added `packages/core/src/memory/extract.test.ts` +- Updated `packages/core/src/core/client.ts` to trigger managed extraction after completed `UserQuery` turns +- Updated `packages/core/src/core/client.test.ts` with extraction integration coverage +- Exported the new extraction/state helpers from `packages/core/src/index.ts` + +### Functional verification + +- Managed extraction now reads the current session transcript incrementally using `extract-cursor.json`. +- Durable user statements are heuristically classified into `user`, `feedback`, `project`, or `reference` topic patches. +- Topic files are updated idempotently, metadata is bumped, and duplicate writes are avoided on repeated runs. +- Completed user queries can emit a `Managed auto-memory updated` system message when extraction writes topic files. +- Concurrent extraction attempts for the same project are skipped safely in-process. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/state.test.ts src/memory/extract.test.ts src/core/client.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/config/config.test.ts src/core/prompts.test.ts src/utils/memoryDiscovery.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/recall.test.ts src/memory/scan.test.ts src/memory/state.test.ts src/memory/extract.test.ts src/core/client.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This MVP extraction path is intentionally heuristic and host-driven; it does not yet launch a dedicated extractor agent. +- Cursor persistence is session-aware and sufficient for incremental turn-end extraction in the current process model. +- Dream/consolidation and richer extraction prompts remain deferred to the next parts. + +### Status + +Completed + diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 6b3e49c9828..0d31cd5a795 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -38,6 +38,7 @@ import { promptIdContext } from '../utils/promptIdContext.js'; import { setSimulate429 } from '../utils/testUtils.js'; import { ideContextStore } from '../ide/ideContext.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; +import { scheduleAutoMemoryExtract } from '../memory/extract.js'; import { buildRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; // Mock fs module to prevent actual file system operations during tests @@ -91,6 +92,13 @@ vi.mock('./turn', async (importOriginal) => { vi.mock('../config/config.js'); vi.mock('./prompts'); +vi.mock('../memory/extract.js', () => ({ + scheduleAutoMemoryExtract: vi.fn().mockResolvedValue({ + patches: [], + touchedTopics: [], + cursor: { updatedAt: new Date(0).toISOString() }, + }), +})); vi.mock('../memory/recall.js', () => ({ buildRelevantAutoMemoryPromptForQuery: vi.fn().mockResolvedValue(''), })); @@ -1336,6 +1344,54 @@ hello ); }); + it('should run managed auto-memory extraction after a completed user query', async () => { + vi.mocked(scheduleAutoMemoryExtract).mockResolvedValue({ + patches: [{ topic: 'user', summary: 'I prefer terse responses.', sourceOffset: 0 }], + touchedTopics: ['user'], + cursor: { + sessionId: 'test-session-id', + processedOffset: 2, + updatedAt: new Date(0).toISOString(), + }, + systemMessage: 'Managed auto-memory updated: user.md', + }); + + const mockStream = (async function* () { + yield { type: GeminiEventType.Content, value: 'Done' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + { role: 'model', parts: [{ text: 'Done' }] }, + ]), + stripThoughtsFromHistory: vi.fn(), + }; + client['chat'] = mockChat as GeminiChat; + + const events = await fromAsync( + client.sendMessageStream( + [{ text: 'Please answer tersely' }], + new AbortController().signal, + 'prompt-id-extract', + ), + ); + + const recordedHistory = mockChat.getHistory?.(); + + expect(scheduleAutoMemoryExtract).toHaveBeenCalledWith({ + projectRoot: '/test/project/root', + sessionId: 'test-session-id', + history: recordedHistory, + }); + expect(events).toContainEqual({ + type: GeminiEventType.HookSystemMessage, + value: 'Managed auto-memory updated: user.md', + }); + }); + it('should add context if ideMode is enabled and there are open files but no active file', async () => { // Arrange vi.mocked(ideContextStore.get).mockReturnValue({ diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 5d2088ea9de..24388aa4a38 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -47,6 +47,7 @@ import { LoopDetectionService } from '../services/loopDetectionService.js'; // Tools import { AgentTool } from '../tools/agent.js'; +import { scheduleAutoMemoryExtract } from '../memory/extract.js'; import { buildRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; // Telemetry @@ -450,6 +451,27 @@ export class GeminiClient { } } + private async *runManagedAutoMemoryExtraction( + messageType: SendMessageType, + ): AsyncGenerator { + if (messageType !== SendMessageType.UserQuery) { + return; + } + + const result = await scheduleAutoMemoryExtract({ + projectRoot: this.config.getProjectRoot(), + sessionId: this.config.getSessionId(), + history: this.getHistory(), + }); + + if (result?.systemMessage) { + yield { + type: GeminiEventType.HookSystemMessage, + value: result.systemMessage, + }; + } + } + async *sendMessageStream( request: PartListUnion, signal: AbortSignal, @@ -770,6 +792,7 @@ export class GeminiClient { if (!turn.pendingToolCalls.length && signal && !signal.aborted) { if (this.config.getSkipNextSpeakerCheck()) { + yield* this.runManagedAutoMemoryExtraction(messageType); // Report completed before returning — agent has no more work to do if (arenaAgentClient) { await arenaAgentClient.reportCompleted(); @@ -802,7 +825,11 @@ export class GeminiClient { options, boundedTurns - 1, ); - } else if (arenaAgentClient) { + } + + yield* this.runManagedAutoMemoryExtraction(messageType); + + if (arenaAgentClient) { // No continuation needed — agent completed its task await arenaAgentClient.reportCompleted(); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 21ce82b3b4d..ab848720215 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,6 +118,8 @@ export * from './memory/types.js'; export * from './memory/paths.js'; export * from './memory/store.js'; export * from './memory/prompt.js'; +export * from './memory/state.js'; +export * from './memory/extract.js'; export * from './memory/scan.js'; export * from './memory/recall.js'; diff --git a/packages/core/src/memory/extract.test.ts b/packages/core/src/memory/extract.test.ts new file mode 100644 index 00000000000..03db2990be1 --- /dev/null +++ b/packages/core/src/memory/extract.test.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getAutoMemoryExtractCursorPath, getAutoMemoryTopicPath } from './paths.js'; +import { + applyExtractedMemoryPatches, + buildTranscriptMessages, + extractMemoryPatchesFromTranscript, + loadUnprocessedTranscriptSlice, + runAutoMemoryExtract, + scheduleAutoMemoryExtract, +} from './extract.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { markExtractRunning, resetAutoMemoryStateForTests } from './state.js'; + +describe('auto-memory extraction', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-extract-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot); + }); + + afterEach(async () => { + resetAutoMemoryStateForTests(); + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('builds transcript slices from history and cursor state', () => { + const transcript = buildTranscriptMessages([ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'world' }] }, + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + ]); + + const slice = loadUnprocessedTranscriptSlice( + 'session-1', + transcript, + { + sessionId: 'session-1', + processedOffset: 2, + updatedAt: new Date().toISOString(), + }, + ); + + expect(slice.messages).toHaveLength(1); + expect(slice.messages[0]?.text).toBe('I prefer terse responses.'); + expect(slice.nextProcessedOffset).toBe(3); + }); + + it('extracts and applies durable memory patches', async () => { + const transcript = buildTranscriptMessages([ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + { + role: 'user', + parts: [{ text: 'The latency dashboard is https://grafana.internal/d/api-latency' }], + }, + ]); + + const patches = extractMemoryPatchesFromTranscript(transcript); + expect(patches.map((patch) => patch.topic)).toEqual(['user', 'reference']); + + const touched = await applyExtractedMemoryPatches(projectRoot, patches); + expect(touched).toEqual(['user', 'reference']); + + const userTopic = await fs.readFile(getAutoMemoryTopicPath(projectRoot, 'user'), 'utf-8'); + const referenceTopic = await fs.readFile(getAutoMemoryTopicPath(projectRoot, 'reference'), 'utf-8'); + + expect(userTopic).toContain('- I prefer terse responses.'); + expect(referenceTopic).toContain('grafana.internal/d/api-latency'); + }); + + it('updates cursor and avoids duplicate writes for repeated extraction', async () => { + const history = [ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + { role: 'model', parts: [{ text: 'Understood.' }] }, + ]; + + const first = await runAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + history: [...history], + }); + const second = await runAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + history: [...history], + }); + + expect(first.touchedTopics).toEqual(['user']); + expect(second.touchedTopics).toEqual([]); + + const cursor = JSON.parse( + await fs.readFile(getAutoMemoryExtractCursorPath(projectRoot), 'utf-8'), + ) as { processedOffset: number; sessionId: string }; + + expect(cursor.sessionId).toBe('session-1'); + expect(cursor.processedOffset).toBe(2); + }); + + it('skips scheduled extraction while the project is already running', async () => { + markExtractRunning(projectRoot); + + const result = await scheduleAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], + }); + + expect(result.skippedReason).toBe('already_running'); + expect(result.touchedTopics).toEqual([]); + }); +}); \ No newline at end of file diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts new file mode 100644 index 00000000000..cfaf063027a --- /dev/null +++ b/packages/core/src/memory/extract.ts @@ -0,0 +1,336 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import type { Content } from '@google/genai'; +import { partToString } from '../utils/partUtils.js'; +import { getAutoMemoryExtractCursorPath, getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { parseAutoMemoryTopicDocument } from './scan.js'; +import { + type AutoMemoryExtractCursor, + type AutoMemoryMetadata, + type AutoMemoryType, +} from './types.js'; +import { + clearExtractRunning, + isExtractRunning, + markExtractRunning, +} from './state.js'; + +const MIN_CANDIDATE_LENGTH = 12; + +export interface AutoMemoryTranscriptMessage { + offset: number; + role: 'user' | 'model'; + text: string; +} + +export interface AutoMemoryExtractPatch { + topic: AutoMemoryType; + summary: string; + sourceOffset: number; +} + +export interface AutoMemoryExtractResult { + patches: AutoMemoryExtractPatch[]; + touchedTopics: AutoMemoryType[]; + skippedReason?: 'already_running'; + systemMessage?: string; + cursor: AutoMemoryExtractCursor; +} + +function normalizeSummary(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function stripRememberLead(text: string): string { + return text + .replace(/^please\s+/i, '') + .replace(/^(remember|save|note)\s+(that\s+)?/i, '') + .replace(/^[:\-\s]+/, '') + .trim(); +} + +function isTemporaryTask(text: string): boolean { + return /\b(today|now|currently|for this task|this session|temporary|temporarily)\b/i.test( + text, + ); +} + +function classifyTopic(text: string): AutoMemoryType | null { + if (/https?:\/\/|\b(grafana|dashboard|runbook|ticket|docs?|wiki|notion|jira)\b/i.test(text)) { + return 'reference'; + } + if (/\b(i|we)\s+(prefer|like|need|want)\b|\bmy\s+(preferred|favorite)\b/i.test(text)) { + return 'user'; + } + if (/\b(please|always|never|avoid|respond|format|style|terse|concise|detailed)\b/i.test(text)) { + return 'feedback'; + } + if (/\b(project|repo|repository|service|release|deadline|freeze|incident|environment|stack)\b/i.test(text)) { + return 'project'; + } + return null; +} + +function extractCandidateSummary(text: string): string | null { + const trimmed = normalizeSummary(text); + if (trimmed.length < MIN_CANDIDATE_LENGTH || trimmed.endsWith('?')) { + return null; + } + + if (isTemporaryTask(trimmed)) { + return null; + } + + const explicitRemember = trimmed.match( + /^(?:please\s+)?(?:remember|save|note)\s+(?:that\s+)?(.+)$/i, + ); + if (explicitRemember?.[1]) { + return normalizeSummary(stripRememberLead(explicitRemember[1])); + } + + if ( + /\b(i|we)\s+(prefer|like|need|want)\b/i.test(trimmed) || + /\bmy\s+(preferred|favorite)\b/i.test(trimmed) || + /https?:\/\//i.test(trimmed) || + /\b(grafana|dashboard|runbook|ticket|docs?|wiki|notion|jira|release|deadline|freeze|incident)\b/i.test(trimmed) + ) { + return trimmed; + } + + if (/\b(please|always|never|avoid|respond)\b/i.test(trimmed)) { + return trimmed; + } + + return null; +} + +export function buildTranscriptMessages( + history: Content[], +): AutoMemoryTranscriptMessage[] { + return history + .map((message, index) => ({ + offset: index, + role: message.role, + text: normalizeSummary(partToString(message.parts ?? [])), + })) + .filter( + (message): message is AutoMemoryTranscriptMessage => + (message.role === 'user' || message.role === 'model') && + message.text.length > 0, + ); +} + +export function loadUnprocessedTranscriptSlice( + sessionId: string, + messages: AutoMemoryTranscriptMessage[], + cursor: AutoMemoryExtractCursor, +): { messages: AutoMemoryTranscriptMessage[]; nextProcessedOffset: number } { + const startOffset = cursor.sessionId === sessionId ? cursor.processedOffset ?? 0 : 0; + return { + messages: messages.filter((message) => message.offset >= startOffset), + nextProcessedOffset: messages.length, + }; +} + +export function extractMemoryPatchesFromTranscript( + messages: AutoMemoryTranscriptMessage[], +): AutoMemoryExtractPatch[] { + const seen = new Set(); + const patches: AutoMemoryExtractPatch[] = []; + + for (const message of messages) { + if (message.role !== 'user') { + continue; + } + + const summary = extractCandidateSummary(message.text); + if (!summary) { + continue; + } + + const topic = classifyTopic(summary); + if (!topic) { + continue; + } + + const dedupeKey = `${topic}:${summary.toLowerCase()}`; + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + + patches.push({ + topic, + summary, + sourceOffset: message.offset, + }); + } + + return patches; +} + +async function readExtractCursor( + projectRoot: string, +): Promise { + try { + const content = await fs.readFile( + getAutoMemoryExtractCursorPath(projectRoot), + 'utf-8', + ); + return JSON.parse(content) as AutoMemoryExtractCursor; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + return { updatedAt: new Date(0).toISOString() }; + } + throw error; + } +} + +async function writeExtractCursor( + projectRoot: string, + cursor: AutoMemoryExtractCursor, +): Promise { + await fs.writeFile( + getAutoMemoryExtractCursorPath(projectRoot), + `${JSON.stringify(cursor, null, 2)}\n`, + 'utf-8', + ); +} + +async function bumpMetadata(projectRoot: string, now: Date): Promise { + try { + const content = await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'); + const metadata = JSON.parse(content) as AutoMemoryMetadata; + metadata.updatedAt = now.toISOString(); + await fs.writeFile( + getAutoMemoryMetadataPath(projectRoot), + `${JSON.stringify(metadata, null, 2)}\n`, + 'utf-8', + ); + } catch { + // Scaffold creation already writes metadata; ignore non-critical update errors. + } +} + +function appendSummaryToTopicContent(content: string, summary: string): string | null { + const parsed = parseAutoMemoryTopicDocument('/virtual/topic.md', content); + if (!parsed) { + return null; + } + + const normalizedSummary = summary.toLowerCase(); + const hasDuplicate = parsed.body + .split('\n') + .map((line) => line.replace(/^[-*]\s+/, '').trim().toLowerCase()) + .some((line) => line === normalizedSummary); + + if (hasDuplicate) { + return null; + } + + const replacement = parsed.body.includes('_No entries yet._') + ? `- ${summary}` + : `${parsed.body.trimEnd()}\n- ${summary}`; + + return content.replace(parsed.body, replacement); +} + +export async function applyExtractedMemoryPatches( + projectRoot: string, + patches: AutoMemoryExtractPatch[], + now = new Date(), +): Promise { + const touchedTopics = new Set(); + + for (const patch of patches) { + const topicPath = getAutoMemoryTopicPath(projectRoot, patch.topic); + const current = await fs.readFile(topicPath, 'utf-8'); + const next = appendSummaryToTopicContent(current, patch.summary); + if (!next) { + continue; + } + + await fs.writeFile(topicPath, next, 'utf-8'); + touchedTopics.add(patch.topic); + } + + if (touchedTopics.size > 0) { + await bumpMetadata(projectRoot, now); + } + + return [...touchedTopics]; +} + +export async function runAutoMemoryExtract(params: { + projectRoot: string; + sessionId: string; + history: Content[]; + now?: Date; +}): Promise { + const now = params.now ?? new Date(); + await ensureAutoMemoryScaffold(params.projectRoot, now); + + const transcript = buildTranscriptMessages(params.history); + const currentCursor = await readExtractCursor(params.projectRoot); + const slice = loadUnprocessedTranscriptSlice( + params.sessionId, + transcript, + currentCursor, + ); + const patches = extractMemoryPatchesFromTranscript(slice.messages); + const touchedTopics = await applyExtractedMemoryPatches( + params.projectRoot, + patches, + now, + ); + + const cursor: AutoMemoryExtractCursor = { + sessionId: params.sessionId, + processedOffset: slice.nextProcessedOffset, + updatedAt: now.toISOString(), + }; + await writeExtractCursor(params.projectRoot, cursor); + + return { + patches, + touchedTopics, + cursor, + systemMessage: + touchedTopics.length > 0 + ? `Managed auto-memory updated: ${touchedTopics.map((topic) => `${topic}.md`).join(', ')}` + : undefined, + }; +} + +export async function scheduleAutoMemoryExtract(params: { + projectRoot: string; + sessionId: string; + history: Content[]; + now?: Date; +}): Promise { + if (isExtractRunning(params.projectRoot)) { + return { + patches: [], + touchedTopics: [], + skippedReason: 'already_running', + cursor: { + sessionId: params.sessionId, + updatedAt: (params.now ?? new Date()).toISOString(), + }, + }; + } + + markExtractRunning(params.projectRoot); + try { + return await runAutoMemoryExtract(params); + } finally { + clearExtractRunning(params.projectRoot); + } +} \ No newline at end of file diff --git a/packages/core/src/memory/state.test.ts b/packages/core/src/memory/state.test.ts new file mode 100644 index 00000000000..dc757f77eeb --- /dev/null +++ b/packages/core/src/memory/state.test.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { + clearExtractRunning, + isExtractRunning, + markExtractRunning, + resetAutoMemoryStateForTests, +} from './state.js'; + +describe('auto-memory state', () => { + afterEach(() => { + resetAutoMemoryStateForTests(); + }); + + it('tracks extract running state per project', () => { + expect(isExtractRunning('/tmp/project')).toBe(false); + markExtractRunning('/tmp/project'); + expect(isExtractRunning('/tmp/project')).toBe(true); + clearExtractRunning('/tmp/project'); + expect(isExtractRunning('/tmp/project')).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/core/src/memory/state.ts b/packages/core/src/memory/state.ts new file mode 100644 index 00000000000..b3cfad5cbd1 --- /dev/null +++ b/packages/core/src/memory/state.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const runningExtractProjects = new Set(); + +export function isExtractRunning(projectRoot: string): boolean { + return runningExtractProjects.has(projectRoot); +} + +export function markExtractRunning(projectRoot: string): void { + runningExtractProjects.add(projectRoot); +} + +export function clearExtractRunning(projectRoot: string): void { + runningExtractProjects.delete(projectRoot); +} + +export function resetAutoMemoryStateForTests(): void { + runningExtractProjects.clear(); +} \ No newline at end of file From eefd3e9d01dad48992cadfb86e67230c51af8a37 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 13:48:47 +0800 Subject: [PATCH 06/56] feat(cli): add managed auto-memory dream commands --- docs/auto-memory-work-log.md | 62 +++++++++ packages/cli/src/i18n/locales/en.js | 23 ++++ packages/cli/src/i18n/locales/zh.js | 21 +++ .../src/services/BuiltinCommandLoader.test.ts | 22 +++ .../cli/src/services/BuiltinCommandLoader.ts | 4 + .../cli/src/ui/commands/dreamCommand.test.ts | 55 ++++++++ packages/cli/src/ui/commands/dreamCommand.ts | 41 ++++++ .../cli/src/ui/commands/memoryCommand.test.ts | 114 ++++++++++++++++ packages/cli/src/ui/commands/memoryCommand.ts | 129 ++++++++++++++++++ .../src/ui/commands/rememberCommand.test.ts | 44 ++++++ .../cli/src/ui/commands/rememberCommand.ts | 64 +++++++++ packages/core/src/index.ts | 1 + packages/core/src/memory/dream.test.ts | 89 ++++++++++++ packages/core/src/memory/dream.ts | 104 ++++++++++++++ 14 files changed, 773 insertions(+) create mode 100644 packages/cli/src/ui/commands/dreamCommand.test.ts create mode 100644 packages/cli/src/ui/commands/dreamCommand.ts create mode 100644 packages/cli/src/ui/commands/rememberCommand.test.ts create mode 100644 packages/cli/src/ui/commands/rememberCommand.ts create mode 100644 packages/core/src/memory/dream.test.ts create mode 100644 packages/core/src/memory/dream.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 0e4e9f1423f..e1d243139d5 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -273,3 +273,65 @@ Completed Completed +--- + +## Part 5 - Dream and command entrypoints + +### Start review + +- Overall plan remains: storage → managed index integration → relevant recall → extraction → dream and commands. +- Parts 1 to 4 already provide the managed scaffold, prompt integration, recall, and turn-end extraction. +- Scope for this part: add a safe managed auto-memory dream/consolidation primitive plus basic `/memory`, `/dream`, and `/remember` command entrypoints in the CLI. + +### Goal + +- Add a managed auto-memory dream/consolidation function +- Enhance `/memory` with managed status and manual extraction entrypoints +- Add `/dream` and `/remember` built-in commands +- Register the new commands and add tests for command behavior and loader coverage + +### Implemented + +- Added `packages/core/src/memory/dream.ts` +- Added `packages/core/src/memory/dream.test.ts` +- Exported dream helpers from `packages/core/src/index.ts` +- Enhanced `packages/cli/src/ui/commands/memoryCommand.ts` with `status` and `extract-now` +- Added `packages/cli/src/ui/commands/dreamCommand.ts` +- Added `packages/cli/src/ui/commands/rememberCommand.ts` +- Added tests in `packages/cli/src/ui/commands/memoryCommand.test.ts` +- Added `packages/cli/src/ui/commands/dreamCommand.test.ts` +- Added `packages/cli/src/ui/commands/rememberCommand.test.ts` +- Updated `packages/cli/src/services/BuiltinCommandLoader.ts` and `packages/cli/src/services/BuiltinCommandLoader.test.ts` +- Added CLI i18n strings in `packages/cli/src/i18n/locales/en.js` and `packages/cli/src/i18n/locales/zh.js` + +### Functional verification + +- Managed dream now deduplicates topic-file bullet entries, restores the empty placeholder when needed, and updates metadata best-effort. +- `/memory status` shows the managed memory root, extract cursor summary, and per-topic entry counts. +- `/memory extract-now` manually runs managed extraction for the current session transcript and reports the outcome. +- `/dream` manually runs managed consolidation and reports changed topic files plus deduplication count. +- `/remember` provides a direct built-in entrypoint for `save_memory`, including optional project/global scope selection. +- New commands are registered in the built-in command loader. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/dream.test.ts` + - `npm exec --workspace=packages/cli -- vitest run src/ui/commands/memoryCommand.test.ts src/ui/commands/dreamCommand.test.ts src/ui/commands/rememberCommand.test.ts src/services/BuiltinCommandLoader.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/config/config.test.ts src/core/prompts.test.ts src/utils/memoryDiscovery.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/recall.test.ts src/memory/scan.test.ts src/memory/state.test.ts src/memory/extract.test.ts src/memory/dream.test.ts src/core/client.test.ts` + - `npm exec --workspace=packages/cli -- vitest run src/ui/commands/memoryCommand.test.ts src/ui/commands/dreamCommand.test.ts src/ui/commands/rememberCommand.test.ts src/services/BuiltinCommandLoader.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + - `npm run generate && npm run build --workspace=packages/web-templates && npm run typecheck --workspace=packages/cli` + +### Notes + +- This dream implementation is intentionally mechanical and low-risk; it deduplicates and normalizes managed memory rather than invoking a separate consolidation agent. +- `/memory` enhancement is kept minimal for MVP: status inspection and manual extraction trigger. +- The full staged implementation plan is now complete. + +### Status + +Completed + diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 3178ea533d8..faa9c25cbbf 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -930,6 +930,29 @@ export default { 'Usage: /memory add [--global|--project] ', 'Attempting to save to memory {{scope}}: "{{fact}}"': 'Attempting to save to memory {{scope}}: "{{fact}}"', + 'Show managed auto-memory status.': 'Show managed auto-memory status.', + 'Run managed auto-memory extraction for the current session.': + 'Run managed auto-memory extraction for the current session.', + 'Managed auto-memory root: {{root}}': 'Managed auto-memory root: {{root}}', + 'Managed auto-memory topics:': 'Managed auto-memory topics:', + 'No extraction cursor found yet.': 'No extraction cursor found yet.', + 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}': + 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}', + 'No chat client available to extract memory.': + 'No chat client available to extract memory.', + 'Managed auto-memory extraction is already running.': + 'Managed auto-memory extraction is already running.', + 'Managed auto-memory extraction found no new durable memories.': + 'Managed auto-memory extraction found no new durable memories.', + 'Consolidate managed auto-memory topic files.': + 'Consolidate managed auto-memory topic files.', + 'Managed auto-memory dream found nothing to improve.': + 'Managed auto-memory dream found nothing to improve.', + 'Deduplicated entries: {{count}}': 'Deduplicated entries: {{count}}', + 'Save a durable memory using the save_memory tool.': + 'Save a durable memory using the save_memory tool.', + 'Usage: /remember [--global|--project] ': + 'Usage: /remember [--global|--project] ', // ============================================================================ // Commands - MCP diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index ad755b721e8..74ca2d799be 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -880,6 +880,27 @@ export default { '用法:/memory add [--global|--project] <要记住的文本>', 'Attempting to save to memory {{scope}}: "{{fact}}"': '正在尝试保存到记忆 {{scope}}:"{{fact}}"', + 'Show managed auto-memory status.': '显示托管自动记忆状态', + 'Run managed auto-memory extraction for the current session.': + '为当前会话运行托管自动记忆提炼', + 'Managed auto-memory root: {{root}}': '托管自动记忆根目录:{{root}}', + 'Managed auto-memory topics:': '托管自动记忆主题:', + 'No extraction cursor found yet.': '尚未找到提炼游标。', + 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}': + '游标:session={{sessionId}},offset={{offset}},updated={{updatedAt}}', + 'No chat client available to extract memory.': '没有可用于提炼记忆的聊天客户端。', + 'Managed auto-memory extraction is already running.': + '托管自动记忆提炼已在运行中。', + 'Managed auto-memory extraction found no new durable memories.': + '托管自动记忆提炼未发现新的持久记忆。', + 'Consolidate managed auto-memory topic files.': '整理托管自动记忆主题文件', + 'Managed auto-memory dream found nothing to improve.': + '托管自动记忆 dream 未发现可改进内容。', + 'Deduplicated entries: {{count}}': '去重条目数:{{count}}', + 'Save a durable memory using the save_memory tool.': + '使用 save_memory 工具保存一条持久记忆', + 'Usage: /remember [--global|--project] ': + '用法:/remember [--global|--project] <要记住的文本>', // ============================================================================ // Commands - MCP diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 43da3235c59..e2b8c874fcf 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -81,18 +81,34 @@ vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} })); vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} })); vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} })); vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} })); +vi.mock('../ui/commands/exportCommand.js', () => ({ exportCommand: {} })); +vi.mock('../ui/commands/dreamCommand.js', () => ({ + dreamCommand: { + name: 'dream', + description: 'Dream command', + kind: 'built-in', + }, +})); vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} })); vi.mock('../ui/commands/extensionsCommand.js', () => ({ extensionsCommand: {}, })); vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} })); vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} })); +vi.mock('../ui/commands/insightCommand.js', () => ({ insightCommand: {} })); vi.mock('../ui/commands/modelCommand.js', () => ({ modelCommand: { name: 'model' }, })); vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {}, })); +vi.mock('../ui/commands/rememberCommand.js', () => ({ + rememberCommand: { + name: 'remember', + description: 'Remember command', + kind: 'built-in', + }, +})); vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} })); vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} })); vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} })); @@ -182,6 +198,12 @@ describe('BuiltinCommandLoader', () => { const modelCmd = commands.find((c) => c.name === 'model'); expect(modelCmd).toBeDefined(); + + const dreamCmd = commands.find((c) => c.name === 'dream'); + expect(dreamCmd).toBeDefined(); + + const rememberCmd = commands.find((c) => c.name === 'remember'); + expect(rememberCmd).toBeDefined(); }); it('should include trust command when folder trust is enabled', async () => { diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index f379a39dea0..3ce7a784c32 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -19,6 +19,7 @@ import { compressCommand } from '../ui/commands/compressCommand.js'; import { contextCommand } from '../ui/commands/contextCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; +import { dreamCommand } from '../ui/commands/dreamCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { exportCommand } from '../ui/commands/exportCommand.js'; @@ -32,6 +33,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { modelCommand } from '../ui/commands/modelCommand.js'; import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; +import { rememberCommand } from '../ui/commands/rememberCommand.js'; import { trustCommand } from '../ui/commands/trustCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; @@ -75,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader { contextCommand, copyCommand, docsCommand, + dreamCommand, directoryCommand, editorCommand, exportCommand, @@ -88,6 +91,7 @@ export class BuiltinCommandLoader implements ICommandLoader { memoryCommand, modelCommand, permissionsCommand, + rememberCommand, ...(this.config?.getFolderTrust() ? [trustCommand] : []), quitCommand, restoreCommand(this.config), diff --git a/packages/cli/src/ui/commands/dreamCommand.test.ts b/packages/cli/src/ui/commands/dreamCommand.test.ts new file mode 100644 index 00000000000..c62fc3cf908 --- /dev/null +++ b/packages/cli/src/ui/commands/dreamCommand.test.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { dreamCommand } from './dreamCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { runManagedAutoMemoryDream } from '@qwen-code/qwen-code-core'; + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + runManagedAutoMemoryDream: vi.fn(), + }; +}); + +describe('dreamCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns an error when config is unavailable', async () => { + const context = createMockCommandContext({ services: { config: null } }); + const result = await dreamCommand.action?.(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('runs managed auto-memory dream and returns the summary', async () => { + vi.mocked(runManagedAutoMemoryDream).mockResolvedValue({ + touchedTopics: ['user'], + dedupedEntries: 2, + systemMessage: 'Managed auto-memory dream updated: user.md', + }); + const context = createMockCommandContext({ + services: { config: { getProjectRoot: vi.fn().mockReturnValue('/test/project') } as never }, + }); + + const result = await dreamCommand.action?.(context, ''); + + expect(runManagedAutoMemoryDream).toHaveBeenCalledWith('/test/project'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Managed auto-memory dream updated: user.md\nDeduplicated entries: 2', + }); + }); +}); \ No newline at end of file diff --git a/packages/cli/src/ui/commands/dreamCommand.ts b/packages/cli/src/ui/commands/dreamCommand.ts new file mode 100644 index 00000000000..7da7459d4cf --- /dev/null +++ b/packages/cli/src/ui/commands/dreamCommand.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + runManagedAutoMemoryDream, +} from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; +import type { SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; + +export const dreamCommand: SlashCommand = { + name: 'dream', + get description() { + return t('Consolidate managed auto-memory topic files.'); + }, + kind: CommandKind.BUILT_IN, + action: async (context) => { + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const result = await runManagedAutoMemoryDream(config.getProjectRoot()); + return { + type: 'message', + messageType: 'info', + content: result.systemMessage + ? `${result.systemMessage}\n${t('Deduplicated entries: {{count}}', { + count: String(result.dedupedEntries), + })}` + : t('Managed auto-memory dream found nothing to improve.'), + }; + }, +}; \ No newline at end of file diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 2634a7b2392..2c92d161b0f 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -15,9 +15,13 @@ import { readFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { + AUTO_MEMORY_TYPES, + getAutoMemoryExtractCursorPath, + getAutoMemoryTopicPath, getErrorMessage, loadServerHierarchicalMemory, QWEN_DIR, + scheduleAutoMemoryExtract, setGeminiMdFilename, type FileDiscoveryService, type LoadServerHierarchicalMemoryResponse, @@ -33,6 +37,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { return String(error); }), loadServerHierarchicalMemory: vi.fn(), + scheduleAutoMemoryExtract: vi.fn(), }; }); @@ -47,6 +52,7 @@ vi.mock('node:fs/promises', () => { }); const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock; +const mockScheduleAutoMemoryExtract = scheduleAutoMemoryExtract as Mock; const mockReadFile = readFile as unknown as Mock; describe('memoryCommand', () => { @@ -280,6 +286,114 @@ describe('memoryCommand', () => { }); }); + describe('/memory status', () => { + let statusCommand: SlashCommand; + + beforeEach(() => { + statusCommand = memoryCommand.subCommands?.find( + (cmd) => cmd.name === 'status', + ) as SlashCommand; + mockReadFile.mockReset(); + mockContext = createMockCommandContext({ + services: { + config: { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }); + }); + + it('shows managed auto-memory root, cursor and topic counts', async () => { + mockReadFile.mockImplementation(async (filePath: string) => { + if (filePath === getAutoMemoryExtractCursorPath('/test/project')) { + return JSON.stringify({ + sessionId: 'session-1', + processedOffset: 3, + updatedAt: '2026-04-01T00:00:00.000Z', + }); + } + + for (const topic of AUTO_MEMORY_TYPES) { + if (filePath === getAutoMemoryTopicPath('/test/project', topic)) { + return [ + '---', + `type: ${topic}`, + `title: ${topic}`, + 'description: topic', + '---', + '', + `# ${topic}`, + '', + '- one', + '- two', + ].join('\n'); + } + } + + throw new Error('ENOENT'); + }); + + await statusCommand.action?.(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Managed auto-memory root: /test/project/.qwen/memory'), + }), + expect.any(Number), + ); + const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; + expect(text).toContain('Cursor: session=session-1, offset=3'); + expect(text).toContain('- user.md: 2 entries'); + }); + }); + + describe('/memory extract-now', () => { + let extractCommand: SlashCommand; + + beforeEach(() => { + extractCommand = memoryCommand.subCommands?.find( + (cmd) => cmd.name === 'extract-now', + ) as SlashCommand; + mockScheduleAutoMemoryExtract.mockReset(); + mockContext = createMockCommandContext({ + services: { + config: { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + getSessionId: vi.fn().mockReturnValue('session-1'), + getGeminiClient: vi.fn().mockReturnValue({ + getChat: vi.fn().mockReturnValue({ + getHistory: vi.fn().mockReturnValue([ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + ]), + }), + }), + }, + }, + }); + }); + + it('runs extraction and shows the returned system message', async () => { + mockScheduleAutoMemoryExtract.mockResolvedValue({ + patches: [], + touchedTopics: ['user'], + cursor: { updatedAt: '2026-04-01T00:00:00.000Z' }, + systemMessage: 'Managed auto-memory updated: user.md', + }); + + await extractCommand.action?.(mockContext, ''); + + expect(mockScheduleAutoMemoryExtract).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Managed auto-memory updated: user.md', + }, + expect.any(Number), + ); + }); + }); + describe('/memory add', () => { let addCommand: SlashCommand; diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 709c00cd00b..99ba97dfea3 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -5,10 +5,16 @@ */ import { + AUTO_MEMORY_TYPES, getErrorMessage, + getAutoMemoryExtractCursorPath, + getAutoMemoryRoot, + getAutoMemoryTopicPath, getAllGeminiMdFilenames, loadServerHierarchicalMemory, + parseAutoMemoryTopicDocument, QWEN_DIR, + scheduleAutoMemoryExtract, } from '@qwen-code/qwen-code-core'; import path from 'node:path'; import os from 'node:os'; @@ -18,6 +24,55 @@ import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; +async function buildManagedMemoryStatus(projectRoot: string): Promise { + const root = getAutoMemoryRoot(projectRoot); + + let cursorSummary = t('No extraction cursor found yet.'); + try { + const cursor = JSON.parse( + await fs.readFile(getAutoMemoryExtractCursorPath(projectRoot), 'utf-8'), + ) as { sessionId?: string; processedOffset?: number; updatedAt?: string }; + cursorSummary = t( + 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}', + { + sessionId: cursor.sessionId || 'n/a', + offset: String(cursor.processedOffset ?? 0), + updatedAt: cursor.updatedAt || 'n/a', + }, + ); + } catch { + // Keep default summary. + } + + const topicSummaries = await Promise.all( + AUTO_MEMORY_TYPES.map(async (topic) => { + try { + const content = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, topic), + 'utf-8', + ); + const parsed = parseAutoMemoryTopicDocument( + getAutoMemoryTopicPath(projectRoot, topic), + content, + ); + const entryCount = parsed?.body + .split('\n') + .filter((line) => /^[-*]\s+/.test(line.trim())).length; + return `- ${topic}.md: ${entryCount ?? 0} entries`; + } catch { + return `- ${topic}.md: 0 entries`; + } + }), + ); + + return [ + t('Managed auto-memory root: {{root}}', { root }), + cursorSummary, + t('Managed auto-memory topics:'), + ...topicSummaries, + ].join('\n'); +} + /** * Read all existing memory files from the configured filenames in a directory. * Returns an array of found files with their paths and contents. @@ -151,6 +206,80 @@ export const memoryCommand: SlashCommand = { }, ], }, + { + name: 'status', + get description() { + return t('Show managed auto-memory status.'); + }, + kind: CommandKind.BUILT_IN, + action: async (context) => { + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const status = await buildManagedMemoryStatus(config.getProjectRoot()); + context.ui.addItem( + { + type: MessageType.INFO, + text: status, + }, + Date.now(), + ); + + return; + }, + }, + { + name: 'extract-now', + get description() { + return t('Run managed auto-memory extraction for the current session.'); + }, + kind: CommandKind.BUILT_IN, + action: async (context) => { + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const geminiClient = config.getGeminiClient(); + if (!geminiClient) { + return { + type: 'message', + messageType: 'error', + content: t('No chat client available to extract memory.'), + }; + } + + const result = await scheduleAutoMemoryExtract({ + projectRoot: config.getProjectRoot(), + sessionId: config.getSessionId(), + history: geminiClient.getChat().getHistory(), + }); + + const text = result.skippedReason === 'already_running' + ? t('Managed auto-memory extraction is already running.') + : result.systemMessage || t('Managed auto-memory extraction found no new durable memories.'); + + context.ui.addItem( + { + type: MessageType.INFO, + text, + }, + Date.now(), + ); + + return; + }, + }, { name: 'add', get description() { diff --git a/packages/cli/src/ui/commands/rememberCommand.test.ts b/packages/cli/src/ui/commands/rememberCommand.test.ts new file mode 100644 index 00000000000..bbe3d1f278e --- /dev/null +++ b/packages/cli/src/ui/commands/rememberCommand.test.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { rememberCommand } from './rememberCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('rememberCommand', () => { + it('returns usage error when no args are provided', () => { + const result = rememberCommand.action?.(createMockCommandContext(), ' '); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /remember [--global|--project] ', + }); + }); + + it('creates a save_memory tool action without scope by default', () => { + const result = rememberCommand.action?.( + createMockCommandContext(), + 'Remember this fact', + ); + expect(result).toEqual({ + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact: 'Remember this fact' }, + }); + }); + + it('creates a project-scoped save_memory tool action', () => { + const result = rememberCommand.action?.( + createMockCommandContext(), + '--project Project-specific fact', + ); + expect(result).toEqual({ + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact: 'Project-specific fact', scope: 'project' }, + }); + }); +}); \ No newline at end of file diff --git a/packages/cli/src/ui/commands/rememberCommand.ts b/packages/cli/src/ui/commands/rememberCommand.ts new file mode 100644 index 00000000000..657ad11619f --- /dev/null +++ b/packages/cli/src/ui/commands/rememberCommand.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { t } from '../../i18n/index.js'; +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { CommandKind } from './types.js'; + +function parseRememberArgs(args: string): + | { fact: string; scope?: 'global' | 'project' } + | null { + const trimmedArgs = args.trim(); + if (!trimmedArgs) { + return null; + } + + if (trimmedArgs.startsWith('--global ')) { + return { + scope: 'global', + fact: trimmedArgs.slice('--global '.length).trim(), + }; + } + + if (trimmedArgs.startsWith('--project ')) { + return { + scope: 'project', + fact: trimmedArgs.slice('--project '.length).trim(), + }; + } + + if (trimmedArgs === '--global' || trimmedArgs === '--project') { + return null; + } + + return { fact: trimmedArgs }; +} + +export const rememberCommand: SlashCommand = { + name: 'remember', + get description() { + return t('Save a durable memory using the save_memory tool.'); + }, + kind: CommandKind.BUILT_IN, + action: (_context, args): SlashCommandActionReturn | void => { + const parsed = parseRememberArgs(args); + if (!parsed?.fact) { + return { + type: 'message', + messageType: 'error', + content: t('Usage: /remember [--global|--project] '), + }; + } + + return { + type: 'tool', + toolName: 'save_memory', + toolArgs: parsed.scope + ? { fact: parsed.fact, scope: parsed.scope } + : { fact: parsed.fact }, + }; + }, +}; \ No newline at end of file diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ab848720215..e54f75c3eed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -120,6 +120,7 @@ export * from './memory/store.js'; export * from './memory/prompt.js'; export * from './memory/state.js'; export * from './memory/extract.js'; +export * from './memory/dream.js'; export * from './memory/scan.js'; export * from './memory/recall.js'; diff --git a/packages/core/src/memory/dream.test.ts b/packages/core/src/memory/dream.test.ts new file mode 100644 index 00000000000..a3fd905f313 --- /dev/null +++ b/packages/core/src/memory/dream.test.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getAutoMemoryTopicPath } from './paths.js'; +import { runManagedAutoMemoryDream } from './dream.js'; +import { ensureAutoMemoryScaffold } from './store.js'; + +describe('managed auto-memory dream', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-dream-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot); + }); + + afterEach(async () => { + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('deduplicates repeated bullet entries in topic files', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + [ + '---', + 'type: user', + 'title: User Memory', + 'description: User profile', + '---', + '', + '# User Memory', + '', + '- User prefers terse responses.', + '- User prefers terse responses.', + '- User likes dark mode.', + ].join('\n'), + 'utf-8', + ); + + const result = await runManagedAutoMemoryDream(projectRoot); + const content = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + 'utf-8', + ); + + expect(result.touchedTopics).toContain('user'); + expect(result.dedupedEntries).toBe(1); + expect(content.match(/User prefers terse responses\./g)).toHaveLength(1); + }); + + it('restores the empty placeholder when no bullet entries remain', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'project'), + [ + '---', + 'type: project', + 'title: Project Memory', + 'description: Project facts', + '---', + '', + '# Project Memory', + '', + ].join('\n'), + 'utf-8', + ); + + await runManagedAutoMemoryDream(projectRoot); + const content = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'project'), + 'utf-8', + ); + + expect(content).toContain('_No entries yet._'); + }); +}); \ No newline at end of file diff --git a/packages/core/src/memory/dream.ts b/packages/core/src/memory/dream.ts new file mode 100644 index 00000000000..e797103e8aa --- /dev/null +++ b/packages/core/src/memory/dream.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; +import { parseAutoMemoryTopicDocument } from './scan.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { + AUTO_MEMORY_TYPES, + type AutoMemoryMetadata, + type AutoMemoryType, +} from './types.js'; + +export interface AutoMemoryDreamResult { + touchedTopics: AutoMemoryType[]; + dedupedEntries: number; + systemMessage?: string; +} + +function normalizeBullet(line: string): string { + return line.replace(/^[-*]\s+/, '').replace(/\s+/g, ' ').trim(); +} + +function buildDreamedBody(body: string): { body: string; dedupedEntries: number } { + const lines = body + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0); + + const heading = lines.find((line) => line.startsWith('# ')) ?? '# Memory'; + const bullets = lines + .filter((line) => /^[-*]\s+/.test(line)) + .map(normalizeBullet) + .filter((line) => line.length > 0); + + const uniqueBullets = Array.from( + new Map(bullets.map((line) => [line.toLowerCase(), line])).values(), + ).sort((a, b) => a.localeCompare(b)); + + return { + body: + uniqueBullets.length > 0 + ? [heading, '', ...uniqueBullets.map((line) => `- ${line}`)].join('\n') + : [heading, '', '_No entries yet._'].join('\n'), + dedupedEntries: Math.max(0, bullets.length - uniqueBullets.length), + }; +} + +async function bumpMetadata(projectRoot: string, now: Date): Promise { + const metadataPath = getAutoMemoryMetadataPath(projectRoot); + try { + const content = await fs.readFile(metadataPath, 'utf-8'); + const metadata = JSON.parse(content) as AutoMemoryMetadata; + metadata.updatedAt = now.toISOString(); + await fs.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf-8'); + } catch { + // Best-effort metadata bump. + } +} + +export async function runManagedAutoMemoryDream( + projectRoot: string, + now = new Date(), +): Promise { + await ensureAutoMemoryScaffold(projectRoot, now); + + const touchedTopics = new Set(); + let dedupedEntries = 0; + + for (const topic of AUTO_MEMORY_TYPES) { + const topicPath = getAutoMemoryTopicPath(projectRoot, topic); + const current = await fs.readFile(topicPath, 'utf-8'); + const parsed = parseAutoMemoryTopicDocument(topicPath, current); + if (!parsed) { + continue; + } + + const dreamed = buildDreamedBody(parsed.body); + dedupedEntries += dreamed.dedupedEntries; + if (dreamed.body === parsed.body.trim()) { + continue; + } + + const next = current.replace(parsed.body, dreamed.body); + await fs.writeFile(topicPath, next, 'utf-8'); + touchedTopics.add(topic); + } + + if (touchedTopics.size > 0) { + await bumpMetadata(projectRoot, now); + } + + return { + touchedTopics: [...touchedTopics], + dedupedEntries, + systemMessage: + touchedTopics.size > 0 + ? `Managed auto-memory dream updated: ${[...touchedTopics].map((topic) => `${topic}.md`).join(', ')}` + : undefined, + }; +} \ No newline at end of file From 2e0d7170c53e797574336bcc902e22352913bcc1 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 17:14:29 +0800 Subject: [PATCH 07/56] feat(core): add auxiliary side-query foundation --- docs/auto-memory-work-log.md | 49 +++++++ packages/core/src/auxiliary/sideQuery.test.ts | 125 ++++++++++++++++++ packages/core/src/auxiliary/sideQuery.ts | 64 +++++++++ packages/core/src/index.ts | 2 + packages/core/src/utils/nextSpeakerChecker.ts | 17 +-- packages/core/src/utils/subagentGenerator.ts | 40 +++--- 6 files changed, 266 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/auxiliary/sideQuery.test.ts create mode 100644 packages/core/src/auxiliary/sideQuery.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index e1d243139d5..0c795fa29d0 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -62,6 +62,55 @@ Completed --- +## Part 6 - Auxiliary side-query foundation + +### Start review + +- Overall plan has shifted from memory-only MVP work to shared runtime infrastructure needed for model-driven recall, extraction, and future background work. +- Parts 1 to 5 already completed the memory MVP; the next slice should be reusable outside memory. +- Scope for this part: introduce a small, independently testable side-query foundation for structured auxiliary inference and migrate existing lightweight JSON inference call sites onto it. + +### Goal + +- Add a reusable side-query helper under a shared auxiliary module +- Centralize structured-response schema validation for lightweight auxiliary inference +- Migrate existing JSON-only helper call sites onto the new side-query layer +- Add targeted tests for helper behavior and migrated call sites + +### Implemented + +- Added `packages/core/src/auxiliary/sideQuery.ts` +- Added `packages/core/src/auxiliary/sideQuery.test.ts` +- Updated `packages/core/src/utils/nextSpeakerChecker.ts` to use shared side-query execution +- Updated `packages/core/src/utils/subagentGenerator.ts` to use shared side-query execution +- Exported side-query helpers from `packages/core/src/index.ts` + +### Functional verification + +- Structured auxiliary inference now has a shared entrypoint that defaults model selection, prompt IDs, and schema validation. +- Invalid structured side-query responses now fail fast through schema validation instead of each caller re-implementing checks. +- Existing next-speaker detection and subagent generation now run through the same auxiliary inference path while preserving their caller-facing behavior. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/utils/nextSpeakerChecker.test.ts src/utils/subagentGenerator.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/core/baseLlmClient.test.ts src/utils/schemaValidator.test.ts src/utils/nextSpeakerChecker.test.ts src/utils/subagentGenerator.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This part intentionally does not introduce background task scheduling or fork-agent execution yet. +- The new helper is scoped to lightweight, single-shot structured inference and serves as the first reusable building block for later model-driven memory work. + +### Status + +Completed + +--- + ## Part 1 - Managed auto-memory storage scaffold ### Start review diff --git a/packages/core/src/auxiliary/sideQuery.test.ts b/packages/core/src/auxiliary/sideQuery.test.ts new file mode 100644 index 00000000000..a107f1d03aa --- /dev/null +++ b/packages/core/src/auxiliary/sideQuery.test.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { BaseLlmClient } from '../core/baseLlmClient.js'; +import type { Config } from '../config/config.js'; +import { runSideQuery } from './sideQuery.js'; + +describe('runSideQuery', () => { + let mockBaseLlmClient: BaseLlmClient; + let mockConfig: Config; + let abortController: AbortController; + + beforeEach(() => { + abortController = new AbortController(); + mockBaseLlmClient = { + generateJson: vi.fn(), + } as unknown as BaseLlmClient; + mockConfig = { + getBaseLlmClient: vi.fn().mockReturnValue(mockBaseLlmClient), + getModel: vi.fn().mockReturnValue('qwen3-coder-plus'), + } as unknown as Config; + }); + + it('should call BaseLlmClient.generateJson with side-query defaults', async () => { + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue({ + decision: 'user', + }); + + const result = await runSideQuery<{ decision: string }>(mockConfig, { + purpose: 'next-speaker', + contents: [{ role: 'user', parts: [{ text: 'Who speaks next?' }] }], + schema: { + type: 'object', + properties: { + decision: { type: 'string' }, + }, + required: ['decision'], + }, + abortSignal: abortController.signal, + }); + + expect(result).toEqual({ decision: 'user' }); + expect(mockBaseLlmClient.generateJson).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'qwen3-coder-plus', + promptId: 'side-query:next-speaker', + abortSignal: abortController.signal, + }), + ); + }); + + it('should allow overriding model, promptId, systemInstruction, and config', async () => { + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue({ + status: 'ok', + }); + + await runSideQuery<{ status: string }>(mockConfig, { + contents: [{ role: 'user', parts: [{ text: 'Check status' }] }], + schema: { + type: 'object', + properties: { status: { type: 'string' } }, + required: ['status'], + }, + abortSignal: abortController.signal, + model: 'custom-model', + promptId: 'custom-prompt-id', + systemInstruction: 'You are a validator.', + config: { temperature: 0.1 }, + }); + + expect(mockBaseLlmClient.generateJson).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'custom-model', + promptId: 'custom-prompt-id', + systemInstruction: 'You are a validator.', + config: { temperature: 0.1 }, + }), + ); + }); + + it('should throw when the response does not satisfy the schema', async () => { + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue({ + status: 'ok', + }); + + await expect( + runSideQuery<{ status: string; decision: string }>(mockConfig, { + contents: [{ role: 'user', parts: [{ text: 'Check schema' }] }], + schema: { + type: 'object', + properties: { + status: { type: 'string' }, + decision: { type: 'string' }, + }, + required: ['status', 'decision'], + }, + abortSignal: abortController.signal, + }), + ).rejects.toThrow('Invalid side query response:'); + }); + + it('should throw when custom validation fails', async () => { + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue({ + status: '', + }); + + await expect( + runSideQuery<{ status: string }>(mockConfig, { + contents: [{ role: 'user', parts: [{ text: 'Validate me' }] }], + schema: { + type: 'object', + properties: { status: { type: 'string' } }, + required: ['status'], + }, + abortSignal: abortController.signal, + validate: (response) => + response.status.trim().length === 0 ? 'Status must be non-empty' : null, + }), + ).rejects.toThrow('Status must be non-empty'); + }); +}); diff --git a/packages/core/src/auxiliary/sideQuery.ts b/packages/core/src/auxiliary/sideQuery.ts new file mode 100644 index 00000000000..5c4b343410b --- /dev/null +++ b/packages/core/src/auxiliary/sideQuery.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Content, + GenerateContentConfig, + Part, +} from '@google/genai'; +import type { Config } from '../config/config.js'; +import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { SchemaValidator } from '../utils/schemaValidator.js'; + +export interface SideQueryOptions { + contents: Content[]; + schema: Record; + abortSignal: AbortSignal; + model?: string; + systemInstruction?: string | Part | Part[] | Content; + promptId?: string; + purpose?: string; + config?: Omit< + GenerateContentConfig, + | 'systemInstruction' + | 'responseJsonSchema' + | 'responseMimeType' + | 'tools' + | 'abortSignal' + >; + validate?: (response: TResponse) => string | null; +} + +function buildDefaultPromptId(purpose?: string): string { + return purpose ? `side-query:${purpose}` : 'side-query'; +} + +export async function runSideQuery( + config: Config, + options: SideQueryOptions, +): Promise { + const response = (await config.getBaseLlmClient().generateJson({ + contents: options.contents, + schema: options.schema, + abortSignal: options.abortSignal, + model: options.model ?? config.getModel() ?? DEFAULT_QWEN_MODEL, + systemInstruction: options.systemInstruction, + promptId: options.promptId ?? buildDefaultPromptId(options.purpose), + config: options.config, + })) as TResponse; + + const schemaError = SchemaValidator.validate(options.schema, response); + if (schemaError) { + throw new Error(`Invalid side query response: ${schemaError}`); + } + + const customError = options.validate?.(response); + if (customError) { + throw new Error(customError); + } + + return response; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e54f75c3eed..baf67eec006 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -114,6 +114,8 @@ export * from './services/shellExecutionService.js'; // Managed Auto-Memory // ============================================================================ +export * from './auxiliary/sideQuery.js'; + export * from './memory/types.js'; export * from './memory/paths.js'; export * from './memory/store.js'; diff --git a/packages/core/src/utils/nextSpeakerChecker.ts b/packages/core/src/utils/nextSpeakerChecker.ts index b69fe011d8e..6b2660c7eae 100644 --- a/packages/core/src/utils/nextSpeakerChecker.ts +++ b/packages/core/src/utils/nextSpeakerChecker.ts @@ -5,11 +5,11 @@ */ import type { Content } from '@google/genai'; -import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import type { GeminiChat } from '../core/geminiChat.js'; import { isFunctionResponse } from './messageInspectors.js'; import type { Config } from '../config/config.js'; import { createDebugLogger } from './debugLogger.js'; +import { runSideQuery } from '../auxiliary/sideQuery.js'; const debugLogger = createDebugLogger('NEXT_SPEAKER'); @@ -112,22 +112,13 @@ export async function checkNextSpeaker( ]; try { - const parsedResponse = (await config.getBaseLlmClient().generateJson({ + return await runSideQuery(config, { contents, schema: RESPONSE_SCHEMA, - model: config.getModel() || DEFAULT_QWEN_MODEL, abortSignal, promptId, - })) as unknown as NextSpeakerResponse; - - if ( - parsedResponse && - parsedResponse.next_speaker && - ['user', 'model'].includes(parsedResponse.next_speaker) - ) { - return parsedResponse; - } - return null; + purpose: 'next-speaker', + }); } catch (error) { debugLogger.warn( 'Failed to talk to Gemini endpoint when seeing if conversation should continue.', diff --git a/packages/core/src/utils/subagentGenerator.ts b/packages/core/src/utils/subagentGenerator.ts index 467998dc980..472364d440a 100644 --- a/packages/core/src/utils/subagentGenerator.ts +++ b/packages/core/src/utils/subagentGenerator.ts @@ -5,8 +5,8 @@ */ import type { Content } from '@google/genai'; -import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import type { Config } from '../config/config.js'; +import { runSideQuery } from '../auxiliary/sideQuery.js'; const SYSTEM_PROMPT = `You are an elite AI agent architect specializing in crafting high-performance agent configurations. Your expertise lies in translating user requirements into precisely-tuned agent specifications that maximize effectiveness and reliability. @@ -125,22 +125,26 @@ export async function subagentGenerator( const userPrompt = createUserPrompt(userDescription); const contents: Content[] = [{ role: 'user', parts: [{ text: userPrompt }] }]; - const parsedResponse = (await config.getBaseLlmClient().generateJson({ - model: config.getModel() || DEFAULT_QWEN_MODEL, - contents, - schema: RESPONSE_SCHEMA, - abortSignal, - systemInstruction: SYSTEM_PROMPT, - })) as unknown as SubagentGeneratedContent; - - if ( - !parsedResponse || - !parsedResponse.name || - !parsedResponse.description || - !parsedResponse.systemPrompt - ) { - throw new Error('Invalid response from LLM: missing required fields'); + try { + return await runSideQuery(config, { + contents, + schema: RESPONSE_SCHEMA, + abortSignal, + systemInstruction: SYSTEM_PROMPT, + purpose: 'subagent-generator', + validate: (response) => + !response.name || !response.description || !response.systemPrompt + ? 'Invalid response from LLM: missing required fields' + : null, + }); + } catch (error) { + if ( + error instanceof Error && + (error.message.startsWith('Invalid side query response:') || + error.message === 'Value of params must be an object') + ) { + throw new Error('Invalid response from LLM: missing required fields'); + } + throw error; } - - return parsedResponse; } From 6741218d934e659021c487442edf9c9f4670c7a3 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 17:28:45 +0800 Subject: [PATCH 08/56] feat(memory): add model-driven recall selection --- docs/auto-memory-work-log.md | 51 ++++++++ packages/core/src/core/client.test.ts | 101 ++++++++++++++- packages/core/src/core/client.ts | 16 ++- packages/core/src/index.ts | 1 + packages/core/src/memory/recall.test.ts | 65 +++++++++- packages/core/src/memory/recall.ts | 90 +++++++++++++- .../core/src/memory/relevanceSelector.test.ts | 94 ++++++++++++++ packages/core/src/memory/relevanceSelector.ts | 117 ++++++++++++++++++ 8 files changed, 522 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/memory/relevanceSelector.test.ts create mode 100644 packages/core/src/memory/relevanceSelector.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 0c795fa29d0..6c1d6bcf078 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -111,6 +111,57 @@ Completed --- +## Part 7 - Model-driven recall selection + +### Start review + +- Overall plan now moves from shared side-query infrastructure to the first memory consumer on top of it. +- Part 6 already established a reusable auxiliary inference path, so the next slice should validate that platform with a real memory workflow. +- Scope for this part: upgrade relevant auto-memory recall from heuristic-only ranking to model-driven side-query selection with safe heuristic fallback and session-level surfacing dedupe. + +### Goal + +- Add a model-driven managed memory recall selector based on side-query +- Keep heuristic recall as a safe fallback path +- Avoid repeatedly surfacing the same managed memory files within one session +- Add targeted tests for selector behavior, fallback behavior, and client integration + +### Implemented + +- Added `packages/core/src/memory/relevanceSelector.ts` +- Added `packages/core/src/memory/relevanceSelector.test.ts` +- Updated `packages/core/src/memory/recall.ts` with model-driven resolution and excluded-file filtering +- Updated `packages/core/src/memory/recall.test.ts` with model/fallback coverage +- Updated `packages/core/src/core/client.ts` to track surfaced managed memory files per session +- Updated `packages/core/src/core/client.test.ts` to cover the new recall integration path +- Exported relevance selector helpers from `packages/core/src/index.ts` + +### Functional verification + +- Relevant auto-memory recall can now ask a lightweight side-query to choose the most relevant topic files from scanned memory candidates. +- If the model selector fails or returns invalid paths, recall safely falls back to the existing heuristic selector. +- Files already surfaced in the current session are excluded from later recall passes, reducing repeated prompt injection. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/relevanceSelector.test.ts src/memory/recall.test.ts src/core/client.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extract.test.ts src/memory/dream.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This part intentionally upgrades only recall selection; extraction and dream remain unchanged. +- Session-level surfacing dedupe is in-memory for the active client process and does not yet persist across restarts. + +### Status + +Completed + +--- + ## Part 1 - Managed auto-memory storage scaffold ### Start review diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 0d31cd5a795..13f092bcb0a 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -39,7 +39,7 @@ import { setSimulate429 } from '../utils/testUtils.js'; import { ideContextStore } from '../ide/ideContext.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { scheduleAutoMemoryExtract } from '../memory/extract.js'; -import { buildRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; +import { resolveRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; // Mock fs module to prevent actual file system operations during tests const mockFileSystem = new Map(); @@ -100,7 +100,11 @@ vi.mock('../memory/extract.js', () => ({ }), })); vi.mock('../memory/recall.js', () => ({ - buildRelevantAutoMemoryPromptForQuery: vi.fn().mockResolvedValue(''), + resolveRelevantAutoMemoryPromptForQuery: vi.fn().mockResolvedValue({ + prompt: '', + selectedDocs: [], + strategy: 'none', + }), })); vi.mock('../utils/getFolderStructure', () => ({ getFolderStructure: vi.fn().mockResolvedValue('Mock Folder Structure'), @@ -283,6 +287,16 @@ describe('Gemini Client (client.ts)', () => { beforeEach(async () => { vi.resetAllMocks(); vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear(); + vi.mocked(resolveRelevantAutoMemoryPromptForQuery).mockResolvedValue({ + prompt: '', + selectedDocs: [], + strategy: 'none', + }); + vi.mocked(scheduleAutoMemoryExtract).mockResolvedValue({ + patches: [], + touchedTopics: [], + cursor: { updatedAt: new Date(0).toISOString() }, + }); mockGenerateContentFn = vi.fn().mockResolvedValue({ candidates: [{ content: { parts: [{ text: '{"key": "value"}' }] } }], @@ -1305,9 +1319,19 @@ hello }); it('should prepend relevant managed auto-memory prompt when recall returns content', async () => { - vi.mocked(buildRelevantAutoMemoryPromptForQuery).mockResolvedValue( - '## Relevant Managed Auto-Memory\n\n- User prefers terse responses.', - ); + vi.mocked(resolveRelevantAutoMemoryPromptForQuery).mockResolvedValue({ + prompt: '## Relevant Managed Auto-Memory\n\n- User prefers terse responses.', + selectedDocs: [ + { + type: 'user', + filePath: '/test/project/root/.qwen/memory/user.md', + title: 'User Memory', + description: 'User preferences', + body: '- User prefers terse responses.', + }, + ], + strategy: 'model', + }); const mockStream = (async function* () { yield { type: 'content', value: 'Hello' }; @@ -1330,9 +1354,13 @@ hello // consume stream } - expect(buildRelevantAutoMemoryPromptForQuery).toHaveBeenCalledWith( + expect(resolveRelevantAutoMemoryPromptForQuery).toHaveBeenCalledWith( '/test/project/root', 'Please answer tersely', + expect.objectContaining({ + config: mockConfig, + excludedFilePaths: expect.any(Set), + }), ); expect(mockTurnRunFn).toHaveBeenCalledWith( 'test-model', @@ -1344,6 +1372,67 @@ hello ); }); + it('should track surfaced managed memory paths across user queries', async () => { + vi.mocked(resolveRelevantAutoMemoryPromptForQuery) + .mockResolvedValueOnce({ + prompt: '## Relevant Managed Auto-Memory\n\n- User prefers terse responses.', + selectedDocs: [ + { + type: 'user', + filePath: '/test/project/root/.qwen/memory/user.md', + title: 'User Memory', + description: 'User preferences', + body: '- User prefers terse responses.', + }, + ], + strategy: 'model', + }) + .mockResolvedValueOnce({ + prompt: '', + selectedDocs: [], + strategy: 'none', + }); + + const mockStream = (async function* () { + yield { type: 'content', value: 'Hello' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), + }; + client['chat'] = mockChat as GeminiChat; + + const first = client.sendMessageStream( + [{ text: 'Please answer tersely' }], + new AbortController().signal, + 'prompt-id-memory-1', + ); + for await (const _ of first) { + // consume stream + } + + const second = client.sendMessageStream( + [{ text: 'Keep it short again' }], + new AbortController().signal, + 'prompt-id-memory-2', + ); + for await (const _ of second) { + // consume stream + } + + expect(resolveRelevantAutoMemoryPromptForQuery).toHaveBeenNthCalledWith( + 2, + '/test/project/root', + 'Keep it short again', + expect.objectContaining({ + excludedFilePaths: new Set(['/test/project/root/.qwen/memory/user.md']), + }), + ); + }); + it('should run managed auto-memory extraction after a completed user query', async () => { vi.mocked(scheduleAutoMemoryExtract).mockResolvedValue({ patches: [{ topic: 'user', summary: 'I prefer terse responses.', sourceOffset: 0 }], diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 24388aa4a38..77566d9f469 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -48,7 +48,7 @@ import { LoopDetectionService } from '../services/loopDetectionService.js'; // Tools import { AgentTool } from '../tools/agent.js'; import { scheduleAutoMemoryExtract } from '../memory/extract.js'; -import { buildRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; +import { resolveRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; // Telemetry import { @@ -103,6 +103,7 @@ export interface SendMessageOptions { export class GeminiClient { private chat?: GeminiChat; private sessionTurnCount = 0; + private readonly surfacedRelevantAutoMemoryPaths = new Set(); private readonly loopDetector: LoopDetectionService; private lastPromptId: string | undefined = undefined; @@ -188,6 +189,7 @@ export class GeminiClient { } async resetChat(): Promise { + this.surfacedRelevantAutoMemoryPaths.clear(); await this.startChat(); } @@ -635,14 +637,22 @@ export class GeminiClient { let requestToSent = await flatMapTextParts(request, async (text) => [text]); if (messageType === SendMessageType.UserQuery) { const systemReminders = []; - const relevantAutoMemoryPrompt = - await buildRelevantAutoMemoryPromptForQuery( + const relevantAutoMemory = + await resolveRelevantAutoMemoryPromptForQuery( this.config.getProjectRoot(), partToString(request), + { + config: this.config, + excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, + }, ); + const relevantAutoMemoryPrompt = relevantAutoMemory.prompt; if (relevantAutoMemoryPrompt) { systemReminders.push(relevantAutoMemoryPrompt); + for (const doc of relevantAutoMemory.selectedDocs) { + this.surfacedRelevantAutoMemoryPaths.add(doc.filePath); + } } // add subagent system reminder if there are subagents diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index baf67eec006..eb2f58bfe28 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -124,6 +124,7 @@ export * from './memory/state.js'; export * from './memory/extract.js'; export * from './memory/dream.js'; export * from './memory/scan.js'; +export * from './memory/relevanceSelector.js'; export * from './memory/recall.js'; // ============================================================================ diff --git a/packages/core/src/memory/recall.test.ts b/packages/core/src/memory/recall.test.ts index 327df9e23bc..5a92ffb43fb 100644 --- a/packages/core/src/memory/recall.test.ts +++ b/packages/core/src/memory/recall.test.ts @@ -4,12 +4,28 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { buildRelevantAutoMemoryPrompt, + resolveRelevantAutoMemoryPromptForQuery, selectRelevantAutoMemoryDocuments, } from './recall.js'; import type { ScannedAutoMemoryDocument } from './scan.js'; +import type { Config } from '../config/config.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; +import { selectRelevantAutoMemoryDocumentsByModel } from './relevanceSelector.js'; + +vi.mock('./scan.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scanAutoMemoryTopicDocuments: vi.fn(), + }; +}); + +vi.mock('./relevanceSelector.js', () => ({ + selectRelevantAutoMemoryDocumentsByModel: vi.fn(), +})); const docs: ScannedAutoMemoryDocument[] = [ { @@ -36,6 +52,10 @@ const docs: ScannedAutoMemoryDocument[] = [ ]; describe('auto-memory relevant recall', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('selects the most relevant documents for a query', () => { const selected = selectRelevantAutoMemoryDocuments( 'check the dashboard reference for latency', @@ -57,4 +77,47 @@ describe('auto-memory relevant recall', () => { expect(prompt).toContain('Reference Memory (reference.md)'); expect(prompt).toContain('User Memory (user.md)'); }); + + it('uses model-driven selection when config is provided', async () => { + vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue(docs); + vi.mocked(selectRelevantAutoMemoryDocumentsByModel).mockResolvedValue([ + docs[0], + ]); + + const result = await resolveRelevantAutoMemoryPromptForQuery( + '/tmp/project', + 'check the dashboard reference for latency', + { + config: {} as Config, + }, + ); + + expect(result.strategy).toBe('model'); + expect(result.selectedDocs).toEqual([docs[0]]); + expect(result.prompt).toContain('Reference Memory (reference.md)'); + }); + + it('falls back to heuristic selection when model-driven selection fails', async () => { + vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue(docs); + vi.mocked(selectRelevantAutoMemoryDocumentsByModel).mockRejectedValue( + new Error('selector failed'), + ); + + const result = await resolveRelevantAutoMemoryPromptForQuery( + '/tmp/project', + 'check the dashboard reference for latency', + { + config: {} as Config, + excludedFilePaths: ['/tmp/user.md'], + }, + ); + + expect(result.strategy).toBe('heuristic'); + expect(result.selectedDocs.map((doc) => doc.filePath)).toContain( + '/tmp/reference.md', + ); + expect(result.selectedDocs.map((doc) => doc.filePath)).not.toContain( + '/tmp/user.md', + ); + }); }); \ No newline at end of file diff --git a/packages/core/src/memory/recall.ts b/packages/core/src/memory/recall.ts index 2799df369d3..97eca42a020 100644 --- a/packages/core/src/memory/recall.ts +++ b/packages/core/src/memory/recall.ts @@ -5,13 +5,17 @@ */ import * as path from 'node:path'; +import type { Config } from '../config/config.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import { scanAutoMemoryTopicDocuments, type ScannedAutoMemoryDocument, } from './scan.js'; +import { selectRelevantAutoMemoryDocumentsByModel } from './relevanceSelector.js'; const MAX_RELEVANT_DOCS = 3; const MAX_DOC_BODY_CHARS = 1_200; +const debugLogger = createDebugLogger('AUTO_MEMORY_RECALL'); const TYPE_KEYWORDS: Record = { user: ['user', 'preference', 'preferences', 'background', 'role', 'terse'], @@ -117,11 +121,91 @@ export function buildRelevantAutoMemoryPrompt( ].join('\n'); } +export interface ResolveRelevantAutoMemoryPromptOptions { + config?: Config; + excludedFilePaths?: Iterable; + limit?: number; +} + +export interface RelevantAutoMemoryPromptResult { + prompt: string; + selectedDocs: ScannedAutoMemoryDocument[]; + strategy: 'none' | 'heuristic' | 'model'; +} + +function filterExcludedAutoMemoryDocuments( + docs: ScannedAutoMemoryDocument[], + excludedFilePaths?: Iterable, +): ScannedAutoMemoryDocument[] { + if (!excludedFilePaths) { + return docs; + } + + const excluded = new Set(excludedFilePaths); + if (excluded.size === 0) { + return docs; + } + + return docs.filter((doc) => !excluded.has(doc.filePath)); +} + +export async function resolveRelevantAutoMemoryPromptForQuery( + projectRoot: string, + query: string, + options: ResolveRelevantAutoMemoryPromptOptions = {}, +): Promise { + const docs = filterExcludedAutoMemoryDocuments( + await scanAutoMemoryTopicDocuments(projectRoot), + options.excludedFilePaths, + ); + const limit = options.limit ?? MAX_RELEVANT_DOCS; + + if (query.trim().length === 0 || docs.length === 0 || limit <= 0) { + return { + prompt: '', + selectedDocs: [], + strategy: 'none', + }; + } + + if (options.config) { + try { + const selectedDocs = await selectRelevantAutoMemoryDocumentsByModel( + options.config, + query, + docs, + limit, + ); + return { + prompt: buildRelevantAutoMemoryPrompt(selectedDocs), + selectedDocs, + strategy: selectedDocs.length > 0 ? 'model' : 'none', + }; + } catch (error) { + debugLogger.warn( + 'Model-driven auto-memory recall failed; falling back to heuristic selection.', + error, + ); + } + } + + const selectedDocs = selectRelevantAutoMemoryDocuments(query, docs, limit); + return { + prompt: buildRelevantAutoMemoryPrompt(selectedDocs), + selectedDocs, + strategy: selectedDocs.length > 0 ? 'heuristic' : 'none', + }; +} + export async function buildRelevantAutoMemoryPromptForQuery( projectRoot: string, query: string, + options: ResolveRelevantAutoMemoryPromptOptions = {}, ): Promise { - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - const selected = selectRelevantAutoMemoryDocuments(query, docs); - return buildRelevantAutoMemoryPrompt(selected); + const result = await resolveRelevantAutoMemoryPromptForQuery( + projectRoot, + query, + options, + ); + return result.prompt; } \ No newline at end of file diff --git a/packages/core/src/memory/relevanceSelector.test.ts b/packages/core/src/memory/relevanceSelector.test.ts new file mode 100644 index 00000000000..733e493527f --- /dev/null +++ b/packages/core/src/memory/relevanceSelector.test.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { runSideQuery } from '../auxiliary/sideQuery.js'; +import type { ScannedAutoMemoryDocument } from './scan.js'; +import { selectRelevantAutoMemoryDocumentsByModel } from './relevanceSelector.js'; + +vi.mock('../auxiliary/sideQuery.js', () => ({ + runSideQuery: vi.fn(), +})); + +const docs: ScannedAutoMemoryDocument[] = [ + { + type: 'user', + filePath: '/tmp/user.md', + title: 'User Memory', + description: 'User preferences', + body: '- User prefers terse responses.', + }, + { + type: 'reference', + filePath: '/tmp/reference.md', + title: 'Reference Memory', + description: 'Operational references', + body: '- Grafana dashboard: https://grafana.internal/d/api-latency', + }, +]; + +describe('selectRelevantAutoMemoryDocumentsByModel', () => { + const mockConfig = {} as Config; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns documents chosen by the side-query selector', async () => { + vi.mocked(runSideQuery).mockResolvedValue({ + relevantFilePaths: ['/tmp/reference.md'], + reasoning: 'The request asks for dashboard information.', + }); + + const selected = await selectRelevantAutoMemoryDocumentsByModel( + mockConfig, + 'check the latency dashboard', + docs, + 2, + ); + + expect(selected).toEqual([docs[1]]); + expect(runSideQuery).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + purpose: 'auto-memory-recall', + config: { temperature: 0 }, + }), + ); + }); + + it('returns an empty list for empty query or no docs', async () => { + await expect( + selectRelevantAutoMemoryDocumentsByModel(mockConfig, ' ', docs, 2), + ).resolves.toEqual([]); + await expect( + selectRelevantAutoMemoryDocumentsByModel(mockConfig, 'hello', [], 2), + ).resolves.toEqual([]); + expect(runSideQuery).not.toHaveBeenCalled(); + }); + + it('throws when selector returns unknown file paths', async () => { + vi.mocked(runSideQuery).mockImplementation(async (_config, options) => { + const error = options.validate?.({ + relevantFilePaths: ['/tmp/unknown.md'], + }); + if (error) { + throw new Error(error); + } + return { relevantFilePaths: [] }; + }); + + await expect( + selectRelevantAutoMemoryDocumentsByModel( + mockConfig, + 'check memory', + docs, + 2, + ), + ).rejects.toThrow('Recall selector returned unknown file path'); + }); +}); diff --git a/packages/core/src/memory/relevanceSelector.ts b/packages/core/src/memory/relevanceSelector.ts new file mode 100644 index 00000000000..18a05a8da5f --- /dev/null +++ b/packages/core/src/memory/relevanceSelector.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { runSideQuery } from '../auxiliary/sideQuery.js'; +import type { ScannedAutoMemoryDocument } from './scan.js'; + +const MAX_SELECTOR_EXCERPT_CHARS = 240; + +const RESPONSE_SCHEMA: Record = { + type: 'object', + properties: { + relevantFilePaths: { + type: 'array', + items: { type: 'string' }, + description: + 'Exact file paths of the managed memory topic documents that are directly relevant to the request.', + }, + reasoning: { + type: 'string', + description: 'Short explanation for the selection.', + }, + }, + required: ['relevantFilePaths'], +}; + +interface RecallSelectorResponse { + relevantFilePaths: string[]; + reasoning?: string; +} + +function truncateExcerpt(body: string): string { + const normalized = body.replace(/\s+/g, ' ').trim(); + if (normalized.length <= MAX_SELECTOR_EXCERPT_CHARS) { + return normalized; + } + return `${normalized.slice(0, MAX_SELECTOR_EXCERPT_CHARS).trimEnd()}…`; +} + +function buildSelectorPrompt( + query: string, + docs: ScannedAutoMemoryDocument[], + limit: number, +): string { + const candidateBlock = docs + .map( + (doc, index) => + [ + `Candidate ${index + 1}`, + `filePath: ${doc.filePath}`, + `type: ${doc.type}`, + `title: ${doc.title}`, + `description: ${doc.description || '(none)'}`, + `excerpt: ${truncateExcerpt(doc.body) || '(empty)'}`, + ].join('\n'), + ) + .join('\n\n'); + + return [ + 'Select the managed memory topic files that are directly relevant to the current user request.', + `Return at most ${limit} file paths.`, + 'If none are clearly relevant, return an empty array.', + 'Only return file paths from the provided candidates.', + '', + `User request:\n${query.trim()}`, + '', + `Candidates:\n${candidateBlock}`, + ].join('\n'); +} + +export async function selectRelevantAutoMemoryDocumentsByModel( + config: Config, + query: string, + docs: ScannedAutoMemoryDocument[], + limit: number, +): Promise { + if (docs.length === 0 || limit <= 0 || query.trim().length === 0) { + return []; + } + + const contents: Content[] = [ + { + role: 'user', + parts: [{ text: buildSelectorPrompt(query, docs, limit) }], + }, + ]; + + const allowedPaths = new Set(docs.map((doc) => doc.filePath)); + const response = await runSideQuery(config, { + purpose: 'auto-memory-recall', + contents, + schema: RESPONSE_SCHEMA, + abortSignal: AbortSignal.timeout(5_000), + config: { + temperature: 0, + }, + validate: (value) => { + if (!Array.isArray(value.relevantFilePaths)) { + return 'Recall selector must return relevantFilePaths array'; + } + if (value.relevantFilePaths.length > limit) { + return `Recall selector returned too many documents: ${value.relevantFilePaths.length}`; + } + if (value.relevantFilePaths.some((filePath) => !allowedPaths.has(filePath))) { + return 'Recall selector returned unknown file path'; + } + return null; + }, + }); + + const selectedPathSet = new Set(response.relevantFilePaths); + return docs.filter((doc) => selectedPathSet.has(doc.filePath)).slice(0, limit); +} From a178725041f837d2174205950581475fe94f4d72 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 17:37:56 +0800 Subject: [PATCH 09/56] feat(memory): add model-driven extraction planner --- docs/auto-memory-work-log.md | 50 ++++++ packages/core/src/core/client.test.ts | 1 + packages/core/src/core/client.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/memory/extract.ts | 84 ++++++++- packages/core/src/memory/extractModel.test.ts | 104 +++++++++++ .../core/src/memory/extractionPlanner.test.ts | 115 ++++++++++++ packages/core/src/memory/extractionPlanner.ts | 165 ++++++++++++++++++ 8 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/memory/extractModel.test.ts create mode 100644 packages/core/src/memory/extractionPlanner.test.ts create mode 100644 packages/core/src/memory/extractionPlanner.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 6c1d6bcf078..91aaf62c75b 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -162,6 +162,56 @@ Completed --- +## Part 8 - Side-query extraction patches + +### Start review + +- Overall plan now advances from recall selection to the next most valuable model-driven memory improvement: extraction quality. +- Part 7 already validated that side-query can safely drive memory decisions with fallback, so extraction is the next consumer. +- Scope for this part: keep host-side cursoring, patch application, and dedupe, but replace heuristic-only patch planning with a model-driven side-query planner plus heuristic fallback. + +### Goal + +- Add a side-query extraction planner that consumes transcript slices and topic summaries +- Keep host-side patch application, dedupe, and cursor updates unchanged +- Fallback safely to the existing heuristic extraction planner on model failure +- Add targeted tests for planner behavior, fallback behavior, and client integration + +### Implemented + +- Added `packages/core/src/memory/extractionPlanner.ts` +- Added `packages/core/src/memory/extractionPlanner.test.ts` +- Added `packages/core/src/memory/extractModel.test.ts` +- Updated `packages/core/src/memory/extract.ts` to use model-driven patch planning with heuristic fallback +- Updated `packages/core/src/core/client.ts` to pass `Config` into managed extraction scheduling +- Updated `packages/core/src/core/client.test.ts` with extraction config coverage +- Exported extraction planner helpers from `packages/core/src/index.ts` + +### Functional verification + +- Managed extraction now supports a side-query planner that consumes transcript slices plus current topic summaries and returns structured topic patches. +- If the planner fails or returns invalid output, extraction safely falls back to the existing heuristic patch extractor. +- Host-side cursor persistence, topic patch application, dedupe, and system messages remain unchanged. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/extractionPlanner.test.ts src/memory/extractModel.test.ts src/memory/extract.test.ts src/core/client.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extractionPlanner.test.ts src/memory/extract.test.ts src/memory/extractModel.test.ts src/memory/dream.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This part intentionally stays single-shot and structured; it does not introduce forked extractor agents or background task runtime yet. + +### Status + +Completed + +--- + ## Part 1 - Managed auto-memory storage scaffold ### Start review diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 13f092bcb0a..dbfef0df381 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -1474,6 +1474,7 @@ hello projectRoot: '/test/project/root', sessionId: 'test-session-id', history: recordedHistory, + config: mockConfig, }); expect(events).toContainEqual({ type: GeminiEventType.HookSystemMessage, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 77566d9f469..34b554d97d9 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -464,6 +464,7 @@ export class GeminiClient { projectRoot: this.config.getProjectRoot(), sessionId: this.config.getSessionId(), history: this.getHistory(), + config: this.config, }); if (result?.systemMessage) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eb2f58bfe28..3def0c39a49 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -121,6 +121,7 @@ export * from './memory/paths.js'; export * from './memory/store.js'; export * from './memory/prompt.js'; export * from './memory/state.js'; +export * from './memory/extractionPlanner.js'; export * from './memory/extract.js'; export * from './memory/dream.js'; export * from './memory/scan.js'; diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts index cfaf063027a..d1cec86846a 100644 --- a/packages/core/src/memory/extract.ts +++ b/packages/core/src/memory/extract.ts @@ -6,10 +6,13 @@ import * as fs from 'node:fs/promises'; import type { Content } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import { partToString } from '../utils/partUtils.js'; import { getAutoMemoryExtractCursorPath, getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { parseAutoMemoryTopicDocument } from './scan.js'; +import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; import { type AutoMemoryExtractCursor, type AutoMemoryMetadata, @@ -22,6 +25,7 @@ import { } from './state.js'; const MIN_CANDIDATE_LENGTH = 12; +const debugLogger = createDebugLogger('AUTO_MEMORY_EXTRACT'); export interface AutoMemoryTranscriptMessage { offset: number; @@ -175,6 +179,78 @@ export function extractMemoryPatchesFromTranscript( return patches; } +function normalizeExtractPatch( + patch: AutoMemoryExtractPatch, +): AutoMemoryExtractPatch | null { + const summary = normalizeSummary( + patch.summary.replace(/^[-*]\s+/, '').trim(), + ); + if ( + summary.length < MIN_CANDIDATE_LENGTH || + summary.endsWith('?') || + isTemporaryTask(summary) + ) { + return null; + } + + return { + topic: patch.topic, + summary, + sourceOffset: patch.sourceOffset, + }; +} + +function dedupeExtractPatches( + patches: AutoMemoryExtractPatch[], +): AutoMemoryExtractPatch[] { + const seen = new Set(); + const deduped: AutoMemoryExtractPatch[] = []; + + for (const patch of patches) { + const normalizedPatch = normalizeExtractPatch(patch); + if (!normalizedPatch) { + continue; + } + + const dedupeKey = `${normalizedPatch.topic}:${normalizedPatch.summary.toLowerCase()}`; + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + deduped.push(normalizedPatch); + } + + return deduped; +} + +async function planAutoMemoryExtractPatches(params: { + projectRoot: string; + messages: AutoMemoryTranscriptMessage[]; + config?: Config; +}): Promise { + if (params.messages.length === 0) { + return []; + } + + if (params.config) { + try { + const plannedPatches = await planAutoMemoryExtractionPatchesByModel( + params.config, + params.projectRoot, + params.messages, + ); + return dedupeExtractPatches(plannedPatches); + } catch (error) { + debugLogger.warn( + 'Model-driven auto-memory extraction failed; falling back to heuristic extraction.', + error, + ); + } + } + + return dedupeExtractPatches(extractMemoryPatchesFromTranscript(params.messages)); +} + async function readExtractCursor( projectRoot: string, ): Promise { @@ -273,6 +349,7 @@ export async function runAutoMemoryExtract(params: { sessionId: string; history: Content[]; now?: Date; + config?: Config; }): Promise { const now = params.now ?? new Date(); await ensureAutoMemoryScaffold(params.projectRoot, now); @@ -284,7 +361,11 @@ export async function runAutoMemoryExtract(params: { transcript, currentCursor, ); - const patches = extractMemoryPatchesFromTranscript(slice.messages); + const patches = await planAutoMemoryExtractPatches({ + projectRoot: params.projectRoot, + messages: slice.messages, + config: params.config, + }); const touchedTopics = await applyExtractedMemoryPatches( params.projectRoot, patches, @@ -314,6 +395,7 @@ export async function scheduleAutoMemoryExtract(params: { sessionId: string; history: Content[]; now?: Date; + config?: Config; }): Promise { if (isExtractRunning(params.projectRoot)) { return { diff --git a/packages/core/src/memory/extractModel.test.ts b/packages/core/src/memory/extractModel.test.ts new file mode 100644 index 00000000000..16318e818c6 --- /dev/null +++ b/packages/core/src/memory/extractModel.test.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; +import { runAutoMemoryExtract } from './extract.js'; +import { getAutoMemoryTopicPath } from './paths.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { resetAutoMemoryStateForTests } from './state.js'; + +vi.mock('./extractionPlanner.js', () => ({ + planAutoMemoryExtractionPatchesByModel: vi.fn(), +})); + +describe('auto-memory extraction with model planner', () => { + let tempDir: string; + let projectRoot: string; + const mockConfig = {} as Config; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-extract-model-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot); + vi.clearAllMocks(); + }); + + afterEach(async () => { + resetAutoMemoryStateForTests(); + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('applies model-planned extraction patches when config is provided', async () => { + vi.mocked(planAutoMemoryExtractionPatchesByModel).mockResolvedValue([ + { + topic: 'reference', + summary: 'Latency dashboard: https://grafana.internal/d/api-latency', + sourceOffset: 0, + }, + ]); + + const result = await runAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + config: mockConfig, + history: [ + { + role: 'user', + parts: [{ text: 'The latency dashboard is https://grafana.internal/d/api-latency' }], + }, + ], + }); + + expect(result.touchedTopics).toEqual(['reference']); + expect(planAutoMemoryExtractionPatchesByModel).toHaveBeenCalledWith( + mockConfig, + projectRoot, + expect.any(Array), + ); + + const referenceTopic = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'reference'), + 'utf-8', + ); + expect(referenceTopic).toContain('Latency dashboard: https://grafana.internal/d/api-latency'); + }); + + it('falls back to heuristic extraction when the model planner fails', async () => { + vi.mocked(planAutoMemoryExtractionPatchesByModel).mockRejectedValue( + new Error('planner failed'), + ); + + const result = await runAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + config: mockConfig, + history: [ + { + role: 'user', + parts: [{ text: 'I prefer terse responses.' }], + }, + ], + }); + + expect(result.touchedTopics).toEqual(['user']); + const userTopic = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + 'utf-8', + ); + expect(userTopic).toContain('- I prefer terse responses.'); + }); +}); diff --git a/packages/core/src/memory/extractionPlanner.test.ts b/packages/core/src/memory/extractionPlanner.test.ts new file mode 100644 index 00000000000..8dcc8383655 --- /dev/null +++ b/packages/core/src/memory/extractionPlanner.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { runSideQuery } from '../auxiliary/sideQuery.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; +import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; + +vi.mock('../auxiliary/sideQuery.js', () => ({ + runSideQuery: vi.fn(), +})); + +vi.mock('./scan.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scanAutoMemoryTopicDocuments: vi.fn(), + }; +}); + +describe('planAutoMemoryExtractionPatchesByModel', () => { + const mockConfig = {} as Config; + const messages = [ + { offset: 0, role: 'user' as const, text: 'I prefer terse responses.' }, + { offset: 1, role: 'model' as const, text: 'Understood.' }, + { + offset: 2, + role: 'user' as const, + text: 'The latency dashboard is https://grafana.internal/d/api-latency', + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([ + { + type: 'user', + filePath: '/tmp/user.md', + title: 'User Memory', + description: 'User preferences', + body: '- Existing terse preference.', + }, + ]); + }); + + it('returns model-planned extraction patches', async () => { + vi.mocked(runSideQuery).mockResolvedValue({ + patches: [ + { topic: 'user', summary: 'User prefers terse responses.', sourceOffset: 0 }, + { + topic: 'reference', + summary: 'Latency dashboard: https://grafana.internal/d/api-latency', + sourceOffset: 2, + }, + ], + }); + + const patches = await planAutoMemoryExtractionPatchesByModel( + mockConfig, + '/tmp/project', + messages, + ); + + expect(patches).toEqual([ + { topic: 'user', summary: 'User prefers terse responses.', sourceOffset: 0 }, + { + topic: 'reference', + summary: 'Latency dashboard: https://grafana.internal/d/api-latency', + sourceOffset: 2, + }, + ]); + expect(runSideQuery).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + purpose: 'auto-memory-extract', + systemInstruction: expect.stringContaining('You maintain durable managed memory'), + }), + ); + }); + + it('returns empty list when there are no user messages', async () => { + await expect( + planAutoMemoryExtractionPatchesByModel(mockConfig, '/tmp/project', [ + { offset: 0, role: 'model', text: 'hello' }, + ]), + ).resolves.toEqual([]); + expect(runSideQuery).not.toHaveBeenCalled(); + }); + + it('throws when the planner returns an invalid sourceOffset', async () => { + vi.mocked(runSideQuery).mockImplementation(async (_config, options) => { + const error = options.validate?.({ + patches: [ + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 99, + }, + ], + }); + if (error) { + throw new Error(error); + } + return { patches: [] }; + }); + + await expect( + planAutoMemoryExtractionPatchesByModel(mockConfig, '/tmp/project', messages), + ).rejects.toThrow('Extraction planner returned invalid sourceOffset'); + }); +}); diff --git a/packages/core/src/memory/extractionPlanner.ts b/packages/core/src/memory/extractionPlanner.ts new file mode 100644 index 00000000000..21da3c8a621 --- /dev/null +++ b/packages/core/src/memory/extractionPlanner.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { runSideQuery } from '../auxiliary/sideQuery.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; +import type { AutoMemoryType } from './types.js'; +import type { + AutoMemoryExtractPatch, + AutoMemoryTranscriptMessage, +} from './extract.js'; + +const MAX_TOPIC_SUMMARY_CHARS = 280; + +const SYSTEM_PROMPT = `You maintain durable managed memory for an AI coding assistant. + +Review the transcript slice and current topic summaries, then extract only durable memory worth keeping beyond the current task. + +Allowed topics: +- user: stable user preferences, habits, background, recurring requirements +- feedback: lasting instructions about how the assistant should respond or work +- project: stable project constraints, environments, releases, architecture facts +- reference: durable links, dashboards, tickets, docs, runbooks, identifiers + +Do not extract: +- temporary task steps +- one-off requests for this turn only +- speculative conclusions +- questions +- assistant-only plans not stated by the user + +Return concise summaries suitable for bullet points. Do not include leading bullet markers.`; + +const RESPONSE_SCHEMA: Record = { + type: 'object', + properties: { + patches: { + type: 'array', + items: { + type: 'object', + properties: { + topic: { + type: 'string', + enum: ['user', 'feedback', 'project', 'reference'], + }, + summary: { + type: 'string', + }, + sourceOffset: { + type: 'integer', + }, + }, + required: ['topic', 'summary', 'sourceOffset'], + }, + }, + }, + required: ['patches'], +}; + +interface ExtractionPlannerResponse { + patches: AutoMemoryExtractPatch[]; +} + +function truncate(text: string, maxChars: number): string { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxChars) { + return normalized; + } + return `${normalized.slice(0, maxChars).trimEnd()}…`; +} + +function buildTranscriptBlock(messages: AutoMemoryTranscriptMessage[]): string { + return messages + .map( + (message) => + `- offset=${message.offset} role=${message.role} text=${message.text}`, + ) + .join('\n'); +} + +function buildTopicSummaryBlock(projectRoot: string): Promise { + return scanAutoMemoryTopicDocuments(projectRoot).then((docs) => + docs + .map((doc) => { + const body = truncate(doc.body === '_No entries yet._' ? '' : doc.body, MAX_TOPIC_SUMMARY_CHARS); + return [ + `topic=${doc.type}`, + `title=${doc.title}`, + `description=${doc.description || '(none)'}`, + `current=${body || '(empty)'}`, + ].join('\n'); + }) + .join('\n\n'), + ); +} + +export async function planAutoMemoryExtractionPatchesByModel( + config: Config, + projectRoot: string, + messages: AutoMemoryTranscriptMessage[], +): Promise { + if (messages.length === 0) { + return []; + } + + const userOffsets = new Set( + messages.filter((message) => message.role === 'user').map((message) => message.offset), + ); + if (userOffsets.size === 0) { + return []; + } + + const topicSummaries = await buildTopicSummaryBlock(projectRoot); + const contents: Content[] = [ + { + role: 'user', + parts: [ + { + text: [ + 'Transcript slice:', + buildTranscriptBlock(messages), + '', + 'Current topic summaries:', + topicSummaries || '(no topics found)', + ].join('\n'), + }, + ], + }, + ]; + + const response = await runSideQuery(config, { + purpose: 'auto-memory-extract', + contents, + schema: RESPONSE_SCHEMA, + abortSignal: AbortSignal.timeout(7_500), + systemInstruction: SYSTEM_PROMPT, + config: { + temperature: 0, + }, + validate: (value) => { + if (!Array.isArray(value.patches)) { + return 'Extraction planner must return patches array'; + } + for (const patch of value.patches) { + if (!patch.summary?.trim()) { + return 'Extraction planner returned empty summary'; + } + if (!userOffsets.has(patch.sourceOffset)) { + return 'Extraction planner returned invalid sourceOffset'; + } + } + return null; + }, + }); + + return response.patches.map((patch) => ({ + topic: patch.topic as AutoMemoryType, + summary: patch.summary, + sourceOffset: patch.sourceOffset, + })); +} From ff1a5b3edd88c65c5faf62210b54875486516583 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 17:49:16 +0800 Subject: [PATCH 10/56] feat(core): add background task runtime foundation --- docs/auto-memory-work-log.md | 50 ++++++++ .../core/src/background/taskDrainer.test.ts | 33 +++++ packages/core/src/background/taskDrainer.ts | 44 +++++++ .../core/src/background/taskRegistry.test.ts | 50 ++++++++ packages/core/src/background/taskRegistry.ts | 114 ++++++++++++++++++ .../core/src/background/taskScheduler.test.ts | 74 ++++++++++++ packages/core/src/background/taskScheduler.ts | 107 ++++++++++++++++ packages/core/src/index.ts | 3 + 8 files changed, 475 insertions(+) create mode 100644 packages/core/src/background/taskDrainer.test.ts create mode 100644 packages/core/src/background/taskDrainer.ts create mode 100644 packages/core/src/background/taskRegistry.test.ts create mode 100644 packages/core/src/background/taskRegistry.ts create mode 100644 packages/core/src/background/taskScheduler.test.ts create mode 100644 packages/core/src/background/taskScheduler.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 91aaf62c75b..b5f2d5adc04 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -212,6 +212,56 @@ Completed --- +## Part 9 - Background task runtime foundation + +### Start review + +- Overall plan now shifts from side-query consumers to the next shared runtime layer needed for auto-dream and future fork-agent execution. +- Parts 6 to 8 already delivered the auxiliary inference layer plus memory recall/extraction consumers. +- Scope for this part: add a minimal background task runtime foundation with registry, scheduler, and drainer primitives, but do not yet wire automatic dream scheduling onto it. + +### Goal + +- Add a reusable background task registry with status updates and snapshots +- Add a scheduler that can run tracked tasks with simple dedupe behavior +- Add a drainer that waits for in-flight background work with timeout protection +- Add targeted tests for lifecycle updates, dedupe handling, and drain behavior + +### Implemented + +- Added `packages/core/src/background/taskRegistry.ts` +- Added `packages/core/src/background/taskDrainer.ts` +- Added `packages/core/src/background/taskScheduler.ts` +- Added `packages/core/src/background/taskRegistry.test.ts` +- Added `packages/core/src/background/taskDrainer.test.ts` +- Added `packages/core/src/background/taskScheduler.test.ts` +- Exported background runtime helpers from `packages/core/src/index.ts` + +### Functional verification + +- Background work now has a shared registry for task snapshots, updates, and subscriptions. +- Background tasks can be scheduled through a common scheduler with simple dedupe-key skipping. +- In-flight background work can be tracked and drained with timeout protection before shutdown. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/background/taskRegistry.test.ts src/background/taskDrainer.test.ts src/background/taskScheduler.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/background/taskRegistry.test.ts src/background/taskDrainer.test.ts src/background/taskScheduler.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extractionPlanner.test.ts src/memory/extract.test.ts src/memory/extractModel.test.ts src/memory/dream.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This part intentionally does not yet hook memory dream or extraction into the new background runtime. + +### Status + +Completed + +--- + ## Part 1 - Managed auto-memory storage scaffold ### Start review diff --git a/packages/core/src/background/taskDrainer.test.ts b/packages/core/src/background/taskDrainer.test.ts new file mode 100644 index 00000000000..c5ba350e7c1 --- /dev/null +++ b/packages/core/src/background/taskDrainer.test.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { BackgroundTaskDrainer } from './taskDrainer.js'; + +describe('BackgroundTaskDrainer', () => { + it('tracks tasks and drains successfully', async () => { + const drainer = new BackgroundTaskDrainer(); + drainer.track('task-1', Promise.resolve('done')); + + await Promise.resolve(); + expect(await drainer.drain()).toBe(true); + expect(drainer.getInFlightTaskIds()).toEqual([]); + }); + + it('returns false when drain times out', async () => { + const drainer = new BackgroundTaskDrainer(); + let resolveTask: (() => void) | undefined; + const blockingPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + + drainer.track('task-1', blockingPromise); + + await expect(drainer.drain({ timeoutMs: 10 })).resolves.toBe(false); + resolveTask?.(); + await expect(drainer.drain()).resolves.toBe(true); + }); +}); diff --git a/packages/core/src/background/taskDrainer.ts b/packages/core/src/background/taskDrainer.ts new file mode 100644 index 00000000000..1d1ac928832 --- /dev/null +++ b/packages/core/src/background/taskDrainer.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface DrainBackgroundTasksOptions { + timeoutMs?: number; +} + +export class BackgroundTaskDrainer { + private readonly inFlight = new Map>(); + + track(taskId: string, promise: Promise): Promise { + this.inFlight.set(taskId, promise); + promise.finally(() => { + this.inFlight.delete(taskId); + }); + return promise; + } + + getInFlightTaskIds(): string[] { + return [...this.inFlight.keys()]; + } + + async drain(options: DrainBackgroundTasksOptions = {}): Promise { + const promises = [...this.inFlight.values()]; + if (promises.length === 0) { + return true; + } + + const waitForTasks = Promise.allSettled(promises).then(() => true); + if (!options.timeoutMs || options.timeoutMs <= 0) { + return waitForTasks; + } + + return Promise.race([ + waitForTasks, + new Promise((resolve) => { + setTimeout(() => resolve(false), options.timeoutMs); + }), + ]); + } +} diff --git a/packages/core/src/background/taskRegistry.test.ts b/packages/core/src/background/taskRegistry.test.ts new file mode 100644 index 00000000000..d3e00142e2e --- /dev/null +++ b/packages/core/src/background/taskRegistry.test.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { BackgroundTaskRegistry } from './taskRegistry.js'; + +describe('BackgroundTaskRegistry', () => { + it('registers and updates background tasks', () => { + const registry = new BackgroundTaskRegistry(); + const task = registry.register({ + taskType: 'memory-extract', + title: 'Extract memory', + projectRoot: '/tmp/project', + }); + + expect(task.status).toBe('pending'); + + const updated = registry.update(task.id, { + status: 'running', + progressText: 'Planning patches', + metadata: { attempt: 1 }, + }); + + expect(updated.status).toBe('running'); + expect(updated.progressText).toBe('Planning patches'); + expect(updated.metadata).toEqual({ attempt: 1 }); + }); + + it('emits task snapshots to listeners', () => { + const registry = new BackgroundTaskRegistry(); + const events: string[] = []; + const unsubscribe = registry.subscribe((task) => { + events.push(`${task.status}:${task.title}`); + }); + + const task = registry.register({ + taskType: 'memory-dream', + title: 'Dream memory', + projectRoot: '/tmp/project', + }); + registry.update(task.id, { status: 'completed' }); + unsubscribe(); + registry.update(task.id, { progressText: 'ignored after unsubscribe' }); + + expect(events).toEqual(['pending:Dream memory', 'completed:Dream memory']); + }); +}); diff --git a/packages/core/src/background/taskRegistry.ts b/packages/core/src/background/taskRegistry.ts new file mode 100644 index 00000000000..d42407b31ca --- /dev/null +++ b/packages/core/src/background/taskRegistry.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; + +export type BackgroundTaskStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'skipped'; + +export interface BackgroundTaskState { + id: string; + taskType: string; + title: string; + projectRoot: string; + sessionId?: string; + status: BackgroundTaskStatus; + createdAt: string; + updatedAt: string; + progressText?: string; + error?: string; + dedupeKey?: string; + metadata?: Record; +} + +export interface RegisterBackgroundTaskParams { + id?: string; + taskType: string; + title: string; + projectRoot: string; + sessionId?: string; + dedupeKey?: string; + metadata?: Record; +} + +export type BackgroundTaskListener = (task: BackgroundTaskState) => void; + +export class BackgroundTaskRegistry { + private readonly tasks = new Map(); + private readonly listeners = new Set(); + + register(params: RegisterBackgroundTaskParams): BackgroundTaskState { + const now = new Date().toISOString(); + const task: BackgroundTaskState = { + id: params.id ?? randomUUID(), + taskType: params.taskType, + title: params.title, + projectRoot: params.projectRoot, + sessionId: params.sessionId, + dedupeKey: params.dedupeKey, + metadata: params.metadata, + status: 'pending', + createdAt: now, + updatedAt: now, + }; + this.tasks.set(task.id, task); + this.emit(task); + return task; + } + + get(taskId: string): BackgroundTaskState | undefined { + const task = this.tasks.get(taskId); + return task ? { ...task } : undefined; + } + + list(projectRoot?: string): BackgroundTaskState[] { + return [...this.tasks.values()] + .filter((task) => !projectRoot || task.projectRoot === projectRoot) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .map((task) => ({ ...task })); + } + + update( + taskId: string, + patch: Partial>, + ): BackgroundTaskState { + const current = this.tasks.get(taskId); + if (!current) { + throw new Error(`Unknown background task: ${taskId}`); + } + + const next: BackgroundTaskState = { + ...current, + ...patch, + updatedAt: new Date().toISOString(), + metadata: + patch.metadata === undefined + ? current.metadata + : { ...(current.metadata ?? {}), ...patch.metadata }, + }; + this.tasks.set(taskId, next); + this.emit(next); + return { ...next }; + } + + subscribe(listener: BackgroundTaskListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private emit(task: BackgroundTaskState): void { + for (const listener of this.listeners) { + listener({ ...task }); + } + } +} diff --git a/packages/core/src/background/taskScheduler.test.ts b/packages/core/src/background/taskScheduler.test.ts new file mode 100644 index 00000000000..e3044d0045d --- /dev/null +++ b/packages/core/src/background/taskScheduler.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { BackgroundTaskDrainer } from './taskDrainer.js'; +import { BackgroundTaskRegistry } from './taskRegistry.js'; +import { BackgroundTaskScheduler } from './taskScheduler.js'; + +describe('BackgroundTaskScheduler', () => { + it('runs a background task and marks it completed', async () => { + const registry = new BackgroundTaskRegistry(); + const drainer = new BackgroundTaskDrainer(); + const scheduler = new BackgroundTaskScheduler(registry, drainer); + const run = vi.fn().mockResolvedValue({ + progressText: 'Finished extraction', + metadata: { touchedTopics: ['user'] }, + }); + + const scheduled = scheduler.schedule({ + taskType: 'memory-extract', + title: 'Extract memory', + projectRoot: '/tmp/project', + run, + }); + const finalTask = await scheduled.promise; + + expect(run).toHaveBeenCalledTimes(1); + expect(finalTask.status).toBe('completed'); + expect(finalTask.progressText).toBe('Finished extraction'); + expect(finalTask.metadata).toEqual({ touchedTopics: ['user'] }); + expect(await drainer.drain()).toBe(true); + }); + + it('skips duplicate tasks that share a dedupe key while one is running', async () => { + const registry = new BackgroundTaskRegistry(); + const drainer = new BackgroundTaskDrainer(); + const scheduler = new BackgroundTaskScheduler(registry, drainer); + + let resolveFirst: (() => void) | undefined; + const first = scheduler.schedule({ + taskType: 'memory-dream', + title: 'Dream memory', + projectRoot: '/tmp/project', + dedupeKey: 'dream:/tmp/project', + run: () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + }); + + const second = scheduler.schedule({ + taskType: 'memory-dream', + title: 'Dream memory duplicate', + projectRoot: '/tmp/project', + dedupeKey: 'dream:/tmp/project', + run: vi.fn(), + }); + + const skippedTask = await second.promise; + expect(skippedTask.status).toBe('skipped'); + expect(skippedTask.metadata).toEqual( + expect.objectContaining({ + skippedBecauseOf: first.taskId, + }), + ); + + resolveFirst?.(); + const completedTask = await first.promise; + expect(completedTask.status).toBe('completed'); + }); +}); diff --git a/packages/core/src/background/taskScheduler.ts b/packages/core/src/background/taskScheduler.ts new file mode 100644 index 00000000000..6fdfe25fc16 --- /dev/null +++ b/packages/core/src/background/taskScheduler.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BackgroundTaskRegistry, + type BackgroundTaskState, +} from './taskRegistry.js'; +import { BackgroundTaskDrainer } from './taskDrainer.js'; + +export interface ScheduleBackgroundTaskParams { + taskType: string; + title: string; + projectRoot: string; + sessionId?: string; + dedupeKey?: string; + metadata?: Record; + run: (task: BackgroundTaskState) => Promise<{ + progressText?: string; + metadata?: Record; + } | void>; +} + +export interface ScheduledBackgroundTask { + taskId: string; + promise: Promise; +} + +export class BackgroundTaskScheduler { + private readonly inFlightByDedupeKey = new Map(); + + constructor( + private readonly registry: BackgroundTaskRegistry, + private readonly drainer: BackgroundTaskDrainer, + ) {} + + schedule(params: ScheduleBackgroundTaskParams): ScheduledBackgroundTask { + if (params.dedupeKey) { + const existingTaskId = this.inFlightByDedupeKey.get(params.dedupeKey); + if (existingTaskId) { + const skipped = this.registry.register({ + taskType: params.taskType, + title: params.title, + projectRoot: params.projectRoot, + sessionId: params.sessionId, + dedupeKey: params.dedupeKey, + metadata: { + ...(params.metadata ?? {}), + skippedBecauseOf: existingTaskId, + }, + }); + this.registry.update(skipped.id, { + status: 'skipped', + progressText: `Skipped duplicate background task; existing task ${existingTaskId} is still running.`, + }); + return { + taskId: skipped.id, + promise: Promise.resolve(this.registry.get(skipped.id) as BackgroundTaskState), + }; + } + } + + const task = this.registry.register({ + taskType: params.taskType, + title: params.title, + projectRoot: params.projectRoot, + sessionId: params.sessionId, + dedupeKey: params.dedupeKey, + metadata: params.metadata, + }); + this.registry.update(task.id, { status: 'running' }); + if (params.dedupeKey) { + this.inFlightByDedupeKey.set(params.dedupeKey, task.id); + } + + const promise = this.drainer.track( + task.id, + (async () => { + try { + const result = await params.run(this.registry.get(task.id) as BackgroundTaskState); + const finalTask = this.registry.update(task.id, { + status: 'completed', + progressText: result?.progressText, + metadata: result?.metadata, + }); + return finalTask; + } catch (error) { + return this.registry.update(task.id, { + status: 'failed', + error: error instanceof Error ? error.message : String(error), + }); + } finally { + if (params.dedupeKey) { + this.inFlightByDedupeKey.delete(params.dedupeKey); + } + } + })(), + ); + + return { + taskId: task.id, + promise, + }; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3def0c39a49..0e10059d2c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -115,6 +115,9 @@ export * from './services/shellExecutionService.js'; // ============================================================================ export * from './auxiliary/sideQuery.js'; +export * from './background/taskRegistry.js'; +export * from './background/taskDrainer.js'; +export * from './background/taskScheduler.js'; export * from './memory/types.js'; export * from './memory/paths.js'; From 611f7e0a8cd98d5ad5914044453f8122b380cb85 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 17:59:57 +0800 Subject: [PATCH 11/56] feat(memory): schedule auto dream in background --- docs/auto-memory-work-log.md | 50 ++++ packages/core/src/core/client.test.ts | 15 ++ packages/core/src/core/client.ts | 8 + packages/core/src/index.ts | 1 + .../core/src/memory/dreamScheduler.test.ts | 156 ++++++++++++ packages/core/src/memory/dreamScheduler.ts | 239 ++++++++++++++++++ packages/core/src/memory/types.ts | 3 + 7 files changed, 472 insertions(+) create mode 100644 packages/core/src/memory/dreamScheduler.test.ts create mode 100644 packages/core/src/memory/dreamScheduler.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index b5f2d5adc04..17c26ac78da 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -262,6 +262,56 @@ Completed --- +## Part 10 - Auto dream scheduling stage A + +### Start review + +- Overall plan now applies the new background runtime to the first real consumer: managed auto-memory dream. +- Part 9 already delivered the shared registry/scheduler/drainer layer, so this slice focuses on wiring mechanical dream into that runtime with minimal gating. +- Scope for this part: schedule mechanical dream in the background after user-query extraction, add consolidation lock and persisted dream gating metadata, but do not yet introduce model-driven dream rewriting or background agents. + +### Goal + +- Add a managed auto-memory dream scheduler built on the shared background runtime +- Add minimal persisted gating for dream cadence and same-session suppression +- Reuse the existing mechanical dream implementation as the task body +- Add targeted tests for gating, locking, scheduling, and client integration + +### Implemented + +- Added `packages/core/src/memory/dreamScheduler.ts` +- Added `packages/core/src/memory/dreamScheduler.test.ts` +- Extended `AutoMemoryMetadata` with dream scheduling fields +- Updated `packages/core/src/core/client.ts` to fire-and-forget dream scheduling after managed extraction +- Updated `packages/core/src/core/client.test.ts` to cover dream scheduling integration +- Exported dream scheduler helpers from `packages/core/src/index.ts` + +### Functional verification + +- Managed auto-memory dream can now be scheduled as a background task using the shared task registry, scheduler, and drainer. +- Dream scheduling persists minimal gating state in metadata, including `lastDreamAt`, `lastDreamSessionId`, and distinct sessions seen since the last dream. +- A consolidation lock now prevents concurrent dream execution for the same project, while the existing mechanical dream logic remains the execution core. +- User-query completion now asynchronously attempts dream scheduling without blocking the main response path. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/dreamScheduler.test.ts src/memory/dream.test.ts src/core/client.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/background/taskRegistry.test.ts src/background/taskDrainer.test.ts src/background/taskScheduler.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extractionPlanner.test.ts src/memory/extract.test.ts src/memory/extractModel.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This part intentionally keeps dream execution mechanical and background-only; task visualization and model-driven dream agents remain for later phases. + +### Status + +Completed + +--- + ## Part 1 - Managed auto-memory storage scaffold ### Start review diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index dbfef0df381..d63679b88a5 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -39,6 +39,7 @@ import { setSimulate429 } from '../utils/testUtils.js'; import { ideContextStore } from '../ide/ideContext.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { scheduleAutoMemoryExtract } from '../memory/extract.js'; +import { scheduleManagedAutoMemoryDream } from '../memory/dreamScheduler.js'; import { resolveRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; // Mock fs module to prevent actual file system operations during tests @@ -99,6 +100,12 @@ vi.mock('../memory/extract.js', () => ({ cursor: { updatedAt: new Date(0).toISOString() }, }), })); +vi.mock('../memory/dreamScheduler.js', () => ({ + scheduleManagedAutoMemoryDream: vi.fn().mockResolvedValue({ + status: 'skipped', + skippedReason: 'min_sessions', + }), +})); vi.mock('../memory/recall.js', () => ({ resolveRelevantAutoMemoryPromptForQuery: vi.fn().mockResolvedValue({ prompt: '', @@ -297,6 +304,10 @@ describe('Gemini Client (client.ts)', () => { touchedTopics: [], cursor: { updatedAt: new Date(0).toISOString() }, }); + vi.mocked(scheduleManagedAutoMemoryDream).mockResolvedValue({ + status: 'skipped', + skippedReason: 'min_sessions', + }); mockGenerateContentFn = vi.fn().mockResolvedValue({ candidates: [{ content: { parts: [{ text: '{"key": "value"}' }] } }], @@ -1476,6 +1487,10 @@ hello history: recordedHistory, config: mockConfig, }); + expect(scheduleManagedAutoMemoryDream).toHaveBeenCalledWith({ + projectRoot: '/test/project/root', + sessionId: 'test-session-id', + }); expect(events).toContainEqual({ type: GeminiEventType.HookSystemMessage, value: 'Managed auto-memory updated: user.md', diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 34b554d97d9..04067f50285 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -48,6 +48,7 @@ import { LoopDetectionService } from '../services/loopDetectionService.js'; // Tools import { AgentTool } from '../tools/agent.js'; import { scheduleAutoMemoryExtract } from '../memory/extract.js'; +import { scheduleManagedAutoMemoryDream } from '../memory/dreamScheduler.js'; import { resolveRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; // Telemetry @@ -467,6 +468,13 @@ export class GeminiClient { config: this.config, }); + void scheduleManagedAutoMemoryDream({ + projectRoot: this.config.getProjectRoot(), + sessionId: this.config.getSessionId(), + }).catch((error) => { + debugLogger.warn('Failed to schedule managed auto-memory dream.', error); + }); + if (result?.systemMessage) { yield { type: GeminiEventType.HookSystemMessage, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0e10059d2c3..7b51ebe7140 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -127,6 +127,7 @@ export * from './memory/state.js'; export * from './memory/extractionPlanner.js'; export * from './memory/extract.js'; export * from './memory/dream.js'; +export * from './memory/dreamScheduler.js'; export * from './memory/scan.js'; export * from './memory/relevanceSelector.js'; export * from './memory/recall.js'; diff --git a/packages/core/src/memory/dreamScheduler.test.ts b/packages/core/src/memory/dreamScheduler.test.ts new file mode 100644 index 00000000000..5610f0562c1 --- /dev/null +++ b/packages/core/src/memory/dreamScheduler.test.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getAutoMemoryConsolidationLockPath, getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; +import { + createManagedAutoMemoryDreamRuntimeForTests, + DEFAULT_AUTO_DREAM_MIN_HOURS, +} from './dreamScheduler.js'; +import { ensureAutoMemoryScaffold } from './store.js'; + +describe('managed auto-memory dream scheduler', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-dream-scheduler-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T00:00:00.000Z')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('waits for enough distinct sessions before scheduling dream', async () => { + const runtime = createManagedAutoMemoryDreamRuntimeForTests(); + + const first = await runtime.schedule({ + projectRoot, + sessionId: 'session-1', + now: new Date('2026-04-01T10:00:00.000Z'), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 2, + }); + expect(first).toEqual({ + status: 'skipped', + skippedReason: 'min_sessions', + }); + + const second = await runtime.schedule({ + projectRoot, + sessionId: 'session-2', + now: new Date('2026-04-01T11:00:00.000Z'), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 2, + }); + + expect(second.status).toBe('scheduled'); + await second.promise; + + const metadata = JSON.parse( + await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'), + ) as { lastDreamAt?: string; lastDreamSessionId?: string; recentSessionIdsSinceDream?: string[] }; + + expect(metadata.lastDreamSessionId).toBe('session-2'); + expect(metadata.lastDreamAt).toBe('2026-04-01T11:00:00.000Z'); + expect(metadata.recentSessionIdsSinceDream).toEqual([]); + await expect( + fs.access(getAutoMemoryConsolidationLockPath(projectRoot)), + ).rejects.toThrow(); + }); + + it('skips dream in the same session after a successful run', async () => { + const runtime = createManagedAutoMemoryDreamRuntimeForTests(); + + const scheduled = await runtime.schedule({ + projectRoot, + sessionId: 'session-1', + now: new Date('2026-04-01T10:00:00.000Z'), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 1, + }); + await scheduled.promise; + + const skipped = await runtime.schedule({ + projectRoot, + sessionId: 'session-1', + now: new Date('2026-04-01T12:00:00.000Z'), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 1, + }); + + expect(skipped).toEqual({ + status: 'skipped', + skippedReason: 'same_session', + }); + }); + + it('skips dream when consolidation lock already exists', async () => { + const runtime = createManagedAutoMemoryDreamRuntimeForTests(); + await fs.writeFile(getAutoMemoryConsolidationLockPath(projectRoot), 'locked', 'utf-8'); + + const result = await runtime.schedule({ + projectRoot, + sessionId: 'session-2', + now: new Date(`2026-04-0${DEFAULT_AUTO_DREAM_MIN_HOURS > 0 ? '2' : '1'}T12:00:00.000Z`), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 1, + }); + + expect(result).toEqual({ + status: 'skipped', + skippedReason: 'locked', + }); + }); + + it('runs the existing mechanical dream logic inside scheduled tasks', async () => { + const runtime = createManagedAutoMemoryDreamRuntimeForTests(); + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + [ + '---', + 'type: user', + 'title: User Memory', + 'description: User profile', + '---', + '', + '# User Memory', + '', + '- User prefers terse responses.', + '- User prefers terse responses.', + ].join('\n'), + 'utf-8', + ); + + const result = await runtime.schedule({ + projectRoot, + sessionId: 'session-1', + now: new Date('2026-04-01T10:00:00.000Z'), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 1, + }); + const finalTask = await result.promise; + + expect(finalTask?.status).toBe('completed'); + expect(finalTask?.metadata).toEqual( + expect.objectContaining({ + dedupedEntries: 1, + touchedTopics: ['user'], + }), + ); + }); +}); diff --git a/packages/core/src/memory/dreamScheduler.ts b/packages/core/src/memory/dreamScheduler.ts new file mode 100644 index 00000000000..2bf10bb6806 --- /dev/null +++ b/packages/core/src/memory/dreamScheduler.ts @@ -0,0 +1,239 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { + BackgroundTaskDrainer, + type DrainBackgroundTasksOptions, +} from '../background/taskDrainer.js'; +import { + BackgroundTaskRegistry, + type BackgroundTaskState, +} from '../background/taskRegistry.js'; +import { BackgroundTaskScheduler } from '../background/taskScheduler.js'; +import { + getAutoMemoryConsolidationLockPath, + getAutoMemoryMetadataPath, +} from './paths.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { runManagedAutoMemoryDream } from './dream.js'; +import type { AutoMemoryMetadata } from './types.js'; + +export const DEFAULT_AUTO_DREAM_MIN_HOURS = 24; +export const DEFAULT_AUTO_DREAM_MIN_SESSIONS = 5; + +export interface ScheduleManagedAutoMemoryDreamParams { + projectRoot: string; + sessionId: string; + now?: Date; + minHoursBetweenDreams?: number; + minSessionsBetweenDreams?: number; +} + +export interface ManagedAutoMemoryDreamScheduleResult { + status: 'scheduled' | 'skipped'; + taskId?: string; + skippedReason?: + | 'same_session' + | 'min_hours' + | 'min_sessions' + | 'locked' + | 'running'; + promise?: Promise; +} + +async function readDreamMetadata(projectRoot: string): Promise { + const content = await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'); + return JSON.parse(content) as AutoMemoryMetadata; +} + +async function writeDreamMetadata( + projectRoot: string, + metadata: AutoMemoryMetadata, +): Promise { + await fs.writeFile( + getAutoMemoryMetadataPath(projectRoot), + `${JSON.stringify(metadata, null, 2)}\n`, + 'utf-8', + ); +} + +function hoursSince(lastDreamAt: string | undefined, now: Date): number | null { + if (!lastDreamAt) { + return null; + } + const timestamp = Date.parse(lastDreamAt); + if (Number.isNaN(timestamp)) { + return null; + } + return (now.getTime() - timestamp) / (1000 * 60 * 60); +} + +async function lockExists(projectRoot: string): Promise { + try { + await fs.access(getAutoMemoryConsolidationLockPath(projectRoot)); + return true; + } catch { + return false; + } +} + +async function acquireDreamLock(projectRoot: string): Promise { + const handle = await fs.open(getAutoMemoryConsolidationLockPath(projectRoot), 'wx'); + await handle.close(); +} + +async function releaseDreamLock(projectRoot: string): Promise { + await fs.rm(getAutoMemoryConsolidationLockPath(projectRoot), { + force: true, + }); +} + +export class ManagedAutoMemoryDreamRuntime { + readonly registry = new BackgroundTaskRegistry(); + readonly drainer = new BackgroundTaskDrainer(); + readonly scheduler = new BackgroundTaskScheduler(this.registry, this.drainer); + + async schedule( + params: ScheduleManagedAutoMemoryDreamParams, + ): Promise { + const now = params.now ?? new Date(); + const minHoursBetweenDreams = + params.minHoursBetweenDreams ?? DEFAULT_AUTO_DREAM_MIN_HOURS; + const minSessionsBetweenDreams = + params.minSessionsBetweenDreams ?? DEFAULT_AUTO_DREAM_MIN_SESSIONS; + + await ensureAutoMemoryScaffold(params.projectRoot, now); + const metadata = await readDreamMetadata(params.projectRoot); + + if (metadata.lastDreamSessionId === params.sessionId) { + return { + status: 'skipped', + skippedReason: 'same_session', + }; + } + + const recentSessionIds = new Set(metadata.recentSessionIdsSinceDream ?? []); + recentSessionIds.add(params.sessionId); + metadata.recentSessionIdsSinceDream = [...recentSessionIds]; + metadata.updatedAt = now.toISOString(); + await writeDreamMetadata(params.projectRoot, metadata); + + const elapsedHours = hoursSince(metadata.lastDreamAt, now); + if (elapsedHours !== null && elapsedHours < minHoursBetweenDreams) { + return { + status: 'skipped', + skippedReason: 'min_hours', + }; + } + + if (recentSessionIds.size < minSessionsBetweenDreams) { + return { + status: 'skipped', + skippedReason: 'min_sessions', + }; + } + + if (await lockExists(params.projectRoot)) { + return { + status: 'skipped', + skippedReason: 'locked', + }; + } + + const scheduled = this.scheduler.schedule({ + taskType: 'managed-auto-memory-dream', + title: 'Managed auto-memory dream', + projectRoot: params.projectRoot, + sessionId: params.sessionId, + dedupeKey: `managed-auto-memory-dream:${params.projectRoot}`, + metadata: { + sessionCount: recentSessionIds.size, + }, + run: async () => { + try { + await acquireDreamLock(params.projectRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return { + progressText: 'Skipped managed auto-memory dream because consolidation lock already exists.', + metadata: { skippedReason: 'locked' }, + }; + } + throw error; + } + + try { + const result = await runManagedAutoMemoryDream(params.projectRoot, now); + const nextMetadata = await readDreamMetadata(params.projectRoot); + nextMetadata.lastDreamAt = now.toISOString(); + nextMetadata.lastDreamSessionId = params.sessionId; + nextMetadata.recentSessionIdsSinceDream = []; + nextMetadata.updatedAt = now.toISOString(); + await writeDreamMetadata(params.projectRoot, nextMetadata); + + return { + progressText: + result.systemMessage ?? 'Managed auto-memory dream completed.', + metadata: { + touchedTopics: result.touchedTopics, + dedupedEntries: result.dedupedEntries, + lastDreamAt: now.toISOString(), + }, + }; + } finally { + await releaseDreamLock(params.projectRoot); + } + }, + }); + + const initialTask = this.registry.get(scheduled.taskId); + if (initialTask?.status === 'skipped') { + return { + status: 'skipped', + skippedReason: 'running', + taskId: scheduled.taskId, + promise: scheduled.promise, + }; + } + + return { + status: 'scheduled', + taskId: scheduled.taskId, + promise: scheduled.promise, + }; + } + + listTasks(projectRoot?: string): BackgroundTaskState[] { + return this.registry.list(projectRoot); + } + + drain(options?: DrainBackgroundTasksOptions): Promise { + return this.drainer.drain(options); + } +} + +const defaultManagedAutoMemoryDreamRuntime = new ManagedAutoMemoryDreamRuntime(); + +export async function scheduleManagedAutoMemoryDream( + params: ScheduleManagedAutoMemoryDreamParams, +): Promise { + return defaultManagedAutoMemoryDreamRuntime.schedule(params); +} + +export function getManagedAutoMemoryDreamTaskRegistry(): BackgroundTaskRegistry { + return defaultManagedAutoMemoryDreamRuntime.registry; +} + +export async function drainManagedAutoMemoryDreamTasks( + options?: DrainBackgroundTasksOptions, +): Promise { + return defaultManagedAutoMemoryDreamRuntime.drain(options); +} + +export function createManagedAutoMemoryDreamRuntimeForTests(): ManagedAutoMemoryDreamRuntime { + return new ManagedAutoMemoryDreamRuntime(); +} diff --git a/packages/core/src/memory/types.ts b/packages/core/src/memory/types.ts index c7ab7d50ff6..918cebd1a0b 100644 --- a/packages/core/src/memory/types.ts +++ b/packages/core/src/memory/types.ts @@ -44,6 +44,9 @@ export interface AutoMemoryMetadata { version: typeof AUTO_MEMORY_SCHEMA_VERSION; createdAt: string; updatedAt: string; + lastDreamAt?: string; + lastDreamSessionId?: string; + recentSessionIdsSinceDream?: string[]; } export interface AutoMemoryExtractCursor { From a258d20058c003d51dee2e04c691fea7e877b17e Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 18:07:38 +0800 Subject: [PATCH 12/56] feat(core): add background agent runner foundation --- docs/auto-memory-work-log.md | 46 ++++ .../background/backgroundAgentRunner.test.ts | 113 ++++++++++ .../src/background/backgroundAgentRunner.ts | 201 ++++++++++++++++++ packages/core/src/index.ts | 1 + 4 files changed, 361 insertions(+) create mode 100644 packages/core/src/background/backgroundAgentRunner.test.ts create mode 100644 packages/core/src/background/backgroundAgentRunner.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 17c26ac78da..6e710a0a5c8 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -312,6 +312,52 @@ Completed --- +## Part 11 - Background agent runner foundation + +### Start review + +- Overall plan now extends the background runtime from plain tasks to reusable headless agent orchestration. +- Part 10 already proved the scheduler/drainer path with mechanical dream tasks, so the next slice can safely wrap `AgentHeadless` itself. +- Scope for this part: add a shared `BackgroundAgentRunner` that binds task registry updates to `AgentEventEmitter`, but do not yet connect any memory workflow to it. + +### Goal + +- Add a reusable background agent runner on top of `AgentHeadless` +- Map core agent events into background task registry progress and metadata +- Reuse the shared background scheduler and drainer for lifecycle tracking +- Add targeted tests for success and failure execution paths + +### Implemented + +- Added `packages/core/src/background/backgroundAgentRunner.ts` +- Added `packages/core/src/background/backgroundAgentRunner.test.ts` +- Exported background agent runner helpers from `packages/core/src/index.ts` + +### Functional verification + +- Background work can now wrap `AgentHeadless` execution inside the shared task runtime. +- Core agent streaming/tool/usage events are mapped into task registry progress and metadata updates. +- Background agent execution is tracked by the shared scheduler/drainer and returns a summarized completion result. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/background/backgroundAgentRunner.test.ts src/background/taskScheduler.test.ts src/agents/runtime/agent-headless.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/background/taskRegistry.test.ts src/background/taskDrainer.test.ts src/background/taskScheduler.test.ts src/background/backgroundAgentRunner.test.ts src/agents/runtime/agent-headless.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extractionPlanner.test.ts src/memory/extract.test.ts src/memory/extractModel.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This part intentionally stops at runner infrastructure; memory extraction/dream agents remain for later phases. + +### Status + +Completed + +--- + ## Part 1 - Managed auto-memory storage scaffold ### Start review diff --git a/packages/core/src/background/backgroundAgentRunner.test.ts b/packages/core/src/background/backgroundAgentRunner.test.ts new file mode 100644 index 00000000000..a4f4e1e1db4 --- /dev/null +++ b/packages/core/src/background/backgroundAgentRunner.test.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AgentEventEmitter, AgentEventType, AgentTerminateMode } from '../agents/index.js'; +import { BackgroundAgentRunner } from './backgroundAgentRunner.js'; + +describe('BackgroundAgentRunner', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('runs a headless agent and maps events into background task state', async () => { + const createMock = vi.fn().mockImplementation(async ( + _name, + _runtimeContext, + _promptConfig, + _modelConfig, + _runConfig, + _toolConfig, + eventEmitter?: AgentEventEmitter, + ) => ({ + execute: async () => { + eventEmitter?.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'agent-1', + round: 1, + text: 'Working on it', + thought: false, + timestamp: Date.now(), + }); + eventEmitter?.emit(AgentEventType.TOOL_CALL, { + subagentId: 'agent-1', + round: 1, + callId: 'call-1', + name: 'read_file', + args: {}, + description: 'Read a file', + timestamp: Date.now(), + }); + eventEmitter?.emit(AgentEventType.USAGE_METADATA, { + subagentId: 'agent-1', + round: 1, + usage: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, + timestamp: Date.now(), + }); + }, + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'Done', + })); + + const runner = new BackgroundAgentRunner(undefined, undefined, undefined, createMock); + const result = await runner.run({ + taskType: 'background-agent', + title: 'Review code', + description: 'Run a background code review', + projectRoot: '/tmp/project', + name: 'code-reviewer', + runtimeContext: {} as never, + taskPrompt: 'Review the recent code changes', + promptConfig: { systemPrompt: 'You are a reviewer.' }, + modelConfig: { model: 'qwen3-coder-plus' }, + runConfig: { max_turns: 3 }, + }); + + expect(result.status).toBe('completed'); + expect(result.finalText).toBe('Done'); + expect(result.usage).toEqual({ + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }); + + const tasks = runner.registry.list('/tmp/project'); + expect(tasks[0]?.progressText).toBe('Done'); + expect(tasks[0]?.metadata).toEqual( + expect.objectContaining({ + lastToolCall: 'read_file', + }), + ); + }); + + it('marks background agent as failed when terminate mode is error', async () => { + const createMock = vi.fn().mockResolvedValue({ + execute: vi.fn().mockResolvedValue(undefined), + getTerminateMode: () => AgentTerminateMode.ERROR, + getFinalText: () => '', + }); + + const runner = new BackgroundAgentRunner(undefined, undefined, undefined, createMock); + const result = await runner.run({ + taskType: 'background-agent', + title: 'Review code', + description: 'Run a background code review', + projectRoot: '/tmp/project', + name: 'code-reviewer', + runtimeContext: {} as never, + taskPrompt: 'Review the recent code changes', + promptConfig: { systemPrompt: 'You are a reviewer.' }, + modelConfig: { model: 'qwen3-coder-plus' }, + runConfig: { max_turns: 3 }, + }); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('Background agent terminated with ERROR'); + }); +}); diff --git a/packages/core/src/background/backgroundAgentRunner.ts b/packages/core/src/background/backgroundAgentRunner.ts new file mode 100644 index 00000000000..897f86c04da --- /dev/null +++ b/packages/core/src/background/backgroundAgentRunner.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { + AgentHeadless, + AgentEventEmitter, + AgentEventType, + AgentTerminateMode, + ContextState, + type ModelConfig, + type PromptConfig, + type RunConfig, + type ToolConfig, +} from '../agents/index.js'; +import { BackgroundTaskDrainer } from './taskDrainer.js'; +import { + BackgroundTaskRegistry, + type BackgroundTaskState, +} from './taskRegistry.js'; +import { BackgroundTaskScheduler } from './taskScheduler.js'; + +export interface BackgroundAgentTaskRequest { + taskType: string; + title: string; + description: string; + projectRoot: string; + sessionId?: string; + dedupeKey?: string; + name: string; + runtimeContext: Config; + taskPrompt: string; + promptConfig: PromptConfig; + modelConfig: ModelConfig; + runConfig: RunConfig; + toolConfig?: ToolConfig; + metadata?: Record; + abortSignal?: AbortSignal; +} + +export interface BackgroundAgentResult { + taskId: string; + status: 'completed' | 'failed'; + finalText?: string; + terminateReason?: string; + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + }; + filesTouched: string[]; + error?: string; +} + +type AgentHeadlessLike = Pick< + AgentHeadless, + 'execute' | 'getTerminateMode' | 'getFinalText' +>; + +type CreateAgentHeadlessFn = ( + name: string, + runtimeContext: Config, + promptConfig: PromptConfig, + modelConfig: ModelConfig, + runConfig: RunConfig, + toolConfig?: ToolConfig, + eventEmitter?: AgentEventEmitter, +) => Promise; + +export class BackgroundAgentRunner { + readonly registry: BackgroundTaskRegistry; + readonly drainer: BackgroundTaskDrainer; + readonly scheduler: BackgroundTaskScheduler; + + constructor( + registry = new BackgroundTaskRegistry(), + drainer = new BackgroundTaskDrainer(), + scheduler = new BackgroundTaskScheduler(registry, drainer), + private readonly createAgentHeadless: CreateAgentHeadlessFn = + AgentHeadless.create, + ) { + this.registry = registry; + this.drainer = drainer; + this.scheduler = scheduler; + } + + async run( + request: BackgroundAgentTaskRequest, + ): Promise { + const usage: BackgroundAgentResult['usage'] = {}; + const scheduled = this.scheduler.schedule({ + taskType: request.taskType, + title: request.title, + projectRoot: request.projectRoot, + sessionId: request.sessionId, + dedupeKey: request.dedupeKey, + metadata: request.metadata, + run: async (task) => { + const emitter = new AgentEventEmitter(); + this.bindTaskEvents(task.id, emitter, usage); + + const headless = await this.createAgentHeadless( + request.name, + request.runtimeContext, + request.promptConfig, + request.modelConfig, + request.runConfig, + request.toolConfig, + emitter, + ); + + const context = new ContextState(); + context.set('task_prompt', request.taskPrompt); + await headless.execute(context, request.abortSignal); + + const terminateReason = headless.getTerminateMode(); + if ( + terminateReason === AgentTerminateMode.ERROR || + terminateReason === AgentTerminateMode.CANCELLED || + terminateReason === AgentTerminateMode.TIMEOUT + ) { + throw new Error(`Background agent terminated with ${terminateReason}`); + } + + return { + progressText: headless.getFinalText() || request.description, + metadata: { + finalText: headless.getFinalText(), + terminateReason, + usage, + filesTouched: [], + }, + }; + }, + }); + + const finalTask = await scheduled.promise; + return this.buildResult(scheduled.taskId, finalTask); + } + + private bindTaskEvents( + taskId: string, + emitter: AgentEventEmitter, + usage: NonNullable, + ): void { + emitter.on(AgentEventType.STREAM_TEXT, (event) => { + if (!event.thought && event.text.trim().length > 0) { + this.registry.update(taskId, { + progressText: event.text, + }); + } + }); + + emitter.on(AgentEventType.TOOL_CALL, (event) => { + this.registry.update(taskId, { + metadata: { + lastToolCall: event.name, + }, + }); + }); + + emitter.on(AgentEventType.USAGE_METADATA, (event) => { + usage.inputTokens = event.usage.promptTokenCount; + usage.outputTokens = event.usage.candidatesTokenCount; + usage.totalTokens = event.usage.totalTokenCount; + this.registry.update(taskId, { + metadata: { + usage, + }, + }); + }); + } + + private buildResult( + taskId: string, + finalTask: BackgroundTaskState, + ): BackgroundAgentResult { + const metadata = finalTask.metadata ?? {}; + const finalText = metadata['finalText']; + const terminateReason = metadata['terminateReason']; + const usage = metadata['usage']; + const filesTouched = metadata['filesTouched']; + + return { + taskId, + status: finalTask.status === 'completed' ? 'completed' : 'failed', + finalText: typeof finalText === 'string' ? finalText : undefined, + terminateReason: + typeof terminateReason === 'string' ? terminateReason : undefined, + usage: + usage && typeof usage === 'object' + ? (usage as BackgroundAgentResult['usage']) + : undefined, + filesTouched: Array.isArray(filesTouched) ? (filesTouched as string[]) : [], + error: finalTask.error, + }; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7b51ebe7140..f633acc3fe1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,6 +118,7 @@ export * from './auxiliary/sideQuery.js'; export * from './background/taskRegistry.js'; export * from './background/taskDrainer.js'; export * from './background/taskScheduler.js'; +export * from './background/backgroundAgentRunner.js'; export * from './memory/types.js'; export * from './memory/paths.js'; From c91e840139557126e5bc931455e63017f732dd98 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 18:15:56 +0800 Subject: [PATCH 13/56] feat(memory): add extraction agent planner --- docs/auto-memory-work-log.md | 49 +++++ packages/core/src/index.ts | 1 + packages/core/src/memory/extract.ts | 15 ++ packages/core/src/memory/extractAgent.test.ts | 79 +++++++ packages/core/src/memory/extractModel.test.ts | 11 + .../src/memory/extractionAgentPlanner.test.ts | 119 ++++++++++ .../core/src/memory/extractionAgentPlanner.ts | 208 ++++++++++++++++++ 7 files changed, 482 insertions(+) create mode 100644 packages/core/src/memory/extractAgent.test.ts create mode 100644 packages/core/src/memory/extractionAgentPlanner.test.ts create mode 100644 packages/core/src/memory/extractionAgentPlanner.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 6e710a0a5c8..414410e528c 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -358,6 +358,55 @@ Completed --- +## Part 12 - Extraction agent consumer stage A + +### Start review + +- Overall plan now moves from shared background agent infrastructure to the first memory consumer that actually uses it. +- Part 11 already delivered `BackgroundAgentRunner`, so the next slice should connect managed extraction to it while preserving all existing fallbacks. +- Scope for this part: add an extraction agent planner that emits structured patches via `BackgroundAgentRunner`, then fall back to side-query planner and finally heuristic extraction when needed. + +### Goal + +- Add a managed extraction agent planner on top of `BackgroundAgentRunner` +- Parse and validate structured extraction patches from agent output +- Integrate the agent planner into the extraction fallback chain ahead of side-query/heuristic planning +- Add targeted tests for planner behavior and extraction integration + +### Implemented + +- Added `packages/core/src/memory/extractionAgentPlanner.ts` +- Added `packages/core/src/memory/extractionAgentPlanner.test.ts` +- Added `packages/core/src/memory/extractAgent.test.ts` +- Updated `packages/core/src/memory/extract.ts` to try extraction-agent planning before side-query and heuristic fallback +- Updated `packages/core/src/memory/extractModel.test.ts` to cover the new fallback order +- Exported extraction agent planner helpers from `packages/core/src/index.ts` + +### Functional verification + +- Managed extraction can now first invoke a tool-free background extraction agent through `BackgroundAgentRunner` and parse structured JSON patch output. +- If agent output is invalid or the agent fails, extraction safely falls back to the existing side-query planner and then to heuristic extraction. +- Host-side cursoring, patch application, and dedupe remain unchanged. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/extractionAgentPlanner.test.ts src/memory/extractAgent.test.ts src/memory/extractModel.test.ts src/memory/extract.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/background/taskRegistry.test.ts src/background/taskDrainer.test.ts src/background/taskScheduler.test.ts src/background/backgroundAgentRunner.test.ts src/agents/runtime/agent-headless.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extractionAgentPlanner.test.ts src/memory/extractionPlanner.test.ts src/memory/extract.test.ts src/memory/extractAgent.test.ts src/memory/extractModel.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This part intentionally keeps the extraction agent tool-free and JSON-only; constrained tool policies come later. + +### Status + +Completed + +--- + ## Part 1 - Managed auto-memory storage scaffold ### Start review diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f633acc3fe1..96c9fbab43c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -125,6 +125,7 @@ export * from './memory/paths.js'; export * from './memory/store.js'; export * from './memory/prompt.js'; export * from './memory/state.js'; +export * from './memory/extractionAgentPlanner.js'; export * from './memory/extractionPlanner.js'; export * from './memory/extract.js'; export * from './memory/dream.js'; diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts index d1cec86846a..e2715e95733 100644 --- a/packages/core/src/memory/extract.ts +++ b/packages/core/src/memory/extract.ts @@ -12,6 +12,7 @@ import { partToString } from '../utils/partUtils.js'; import { getAutoMemoryExtractCursorPath, getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { parseAutoMemoryTopicDocument } from './scan.js'; +import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; import { type AutoMemoryExtractCursor, @@ -234,6 +235,20 @@ async function planAutoMemoryExtractPatches(params: { if (params.config) { try { + const plannedPatches = await planAutoMemoryExtractionPatchesByAgent( + params.config, + params.projectRoot, + params.messages, + ); + return dedupeExtractPatches(plannedPatches); + } catch (error) { + debugLogger.warn( + 'Agent-driven auto-memory extraction failed; falling back to side-query extraction.', + error, + ); + } + + try { const plannedPatches = await planAutoMemoryExtractionPatchesByModel( params.config, params.projectRoot, diff --git a/packages/core/src/memory/extractAgent.test.ts b/packages/core/src/memory/extractAgent.test.ts new file mode 100644 index 00000000000..819c8b1eed5 --- /dev/null +++ b/packages/core/src/memory/extractAgent.test.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; +import { runAutoMemoryExtract } from './extract.js'; +import { getAutoMemoryTopicPath } from './paths.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { resetAutoMemoryStateForTests } from './state.js'; + +vi.mock('./extractionAgentPlanner.js', () => ({ + planAutoMemoryExtractionPatchesByAgent: vi.fn(), +})); + +describe('auto-memory extraction with agent planner', () => { + let tempDir: string; + let projectRoot: string; + const mockConfig = {} as Config; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-extract-agent-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot); + vi.clearAllMocks(); + }); + + afterEach(async () => { + resetAutoMemoryStateForTests(); + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('applies agent-planned extraction patches when config is provided', async () => { + vi.mocked(planAutoMemoryExtractionPatchesByAgent).mockResolvedValue([ + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 0, + }, + ]); + + const result = await runAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + config: mockConfig, + history: [ + { + role: 'user', + parts: [{ text: 'I prefer terse responses.' }], + }, + ], + }); + + expect(result.touchedTopics).toEqual(['user']); + expect(planAutoMemoryExtractionPatchesByAgent).toHaveBeenCalledWith( + mockConfig, + projectRoot, + expect.any(Array), + ); + + const userTopic = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + 'utf-8', + ); + expect(userTopic).toContain('- User prefers terse responses.'); + }); +}); diff --git a/packages/core/src/memory/extractModel.test.ts b/packages/core/src/memory/extractModel.test.ts index 16318e818c6..af8160ad689 100644 --- a/packages/core/src/memory/extractModel.test.ts +++ b/packages/core/src/memory/extractModel.test.ts @@ -9,12 +9,17 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; +import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; import { runAutoMemoryExtract } from './extract.js'; import { getAutoMemoryTopicPath } from './paths.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { resetAutoMemoryStateForTests } from './state.js'; +vi.mock('./extractionAgentPlanner.js', () => ({ + planAutoMemoryExtractionPatchesByAgent: vi.fn(), +})); + vi.mock('./extractionPlanner.js', () => ({ planAutoMemoryExtractionPatchesByModel: vi.fn(), })); @@ -43,6 +48,9 @@ describe('auto-memory extraction with model planner', () => { }); it('applies model-planned extraction patches when config is provided', async () => { + vi.mocked(planAutoMemoryExtractionPatchesByAgent).mockRejectedValue( + new Error('agent planner failed'), + ); vi.mocked(planAutoMemoryExtractionPatchesByModel).mockResolvedValue([ { topic: 'reference', @@ -78,6 +86,9 @@ describe('auto-memory extraction with model planner', () => { }); it('falls back to heuristic extraction when the model planner fails', async () => { + vi.mocked(planAutoMemoryExtractionPatchesByAgent).mockRejectedValue( + new Error('agent planner failed'), + ); vi.mocked(planAutoMemoryExtractionPatchesByModel).mockRejectedValue( new Error('planner failed'), ); diff --git a/packages/core/src/memory/extractionAgentPlanner.test.ts b/packages/core/src/memory/extractionAgentPlanner.test.ts new file mode 100644 index 00000000000..aaca25bdb43 --- /dev/null +++ b/packages/core/src/memory/extractionAgentPlanner.test.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; + +vi.mock('./scan.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scanAutoMemoryTopicDocuments: vi.fn(), + }; +}); + +describe('planAutoMemoryExtractionPatchesByAgent', () => { + const mockConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + getModel: vi.fn().mockReturnValue('qwen3-coder-plus'), + } as unknown as Config; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([ + { + type: 'user', + filePath: '/tmp/user.md', + title: 'User Memory', + description: 'User preferences', + body: '- Existing terse preference.', + }, + ]); + }); + + it('returns parsed patches from BackgroundAgentRunner output', async () => { + const runner = { + run: vi.fn().mockResolvedValue({ + taskId: 'task-1', + status: 'completed', + finalText: JSON.stringify({ + patches: [ + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 0, + }, + ], + }), + filesTouched: [], + }), + }; + + const patches = await planAutoMemoryExtractionPatchesByAgent( + mockConfig, + '/tmp/project', + [{ offset: 0, role: 'user', text: 'I prefer terse responses.' }], + runner, + ); + + expect(patches).toEqual([ + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 0, + }, + ]); + expect(runner.run).toHaveBeenCalledWith( + expect.objectContaining({ + taskType: 'managed-auto-memory-extraction-agent', + sessionId: 'session-1', + }), + ); + }); + + it('returns empty list when there are no user messages', async () => { + const runner = { run: vi.fn() }; + await expect( + planAutoMemoryExtractionPatchesByAgent( + mockConfig, + '/tmp/project', + [{ offset: 0, role: 'model', text: 'hello' }], + runner, + ), + ).resolves.toEqual([]); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('throws when the agent returns invalid source offsets', async () => { + const runner = { + run: vi.fn().mockResolvedValue({ + taskId: 'task-1', + status: 'completed', + finalText: JSON.stringify({ + patches: [ + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 99, + }, + ], + }), + filesTouched: [], + }), + }; + + await expect( + planAutoMemoryExtractionPatchesByAgent( + mockConfig, + '/tmp/project', + [{ offset: 0, role: 'user', text: 'I prefer terse responses.' }], + runner, + ), + ).rejects.toThrow('Invalid extraction agent response: invalid sourceOffset'); + }); +}); diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts new file mode 100644 index 00000000000..399a6bda2df --- /dev/null +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { + BackgroundAgentRunner, + type BackgroundAgentResult, +} from '../background/backgroundAgentRunner.js'; +import { SchemaValidator } from '../utils/schemaValidator.js'; +import { safeJsonParse } from '../utils/safeJsonParse.js'; +import type { AutoMemoryType } from './types.js'; +import type { + AutoMemoryExtractPatch, + AutoMemoryTranscriptMessage, +} from './extract.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; + +const MAX_TOPIC_SUMMARY_CHARS = 280; + +const EXTRACTION_AGENT_SYSTEM_PROMPT = `You are a background memory extraction agent for an AI coding assistant. + +Your job is to read the provided transcript slice and current managed memory topic summaries, then return only durable memory patches worth saving long-term. + +Rules: +- Output JSON only. +- Follow the schema exactly. +- Extract only durable facts stated by the user. +- Ignore temporary, session-specific, speculative, or question content. +- Use one of the allowed topics: user, feedback, project, reference. +- Keep summaries concise and suitable for bullet points. +- Do not include leading bullet markers.`; + +const EXTRACTION_AGENT_RESPONSE_SCHEMA: Record = { + type: 'object', + properties: { + patches: { + type: 'array', + items: { + type: 'object', + properties: { + topic: { + type: 'string', + enum: ['user', 'feedback', 'project', 'reference'], + }, + summary: { + type: 'string', + }, + sourceOffset: { + type: 'integer', + }, + }, + required: ['topic', 'summary', 'sourceOffset'], + }, + }, + }, + required: ['patches'], +}; + +interface ExtractionAgentResponse { + patches: AutoMemoryExtractPatch[]; +} + +interface BackgroundAgentRunnerLike { + run(request: Parameters[0]): Promise; +} + +function truncate(text: string, maxChars: number): string { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxChars) { + return normalized; + } + return `${normalized.slice(0, maxChars).trimEnd()}…`; +} + +function buildTranscriptBlock(messages: AutoMemoryTranscriptMessage[]): string { + return messages + .map( + (message) => + `- offset=${message.offset} role=${message.role} text=${message.text}`, + ) + .join('\n'); +} + +async function buildTopicSummaryBlock(projectRoot: string): Promise { + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + return docs + .map((doc) => { + const body = truncate( + doc.body === '_No entries yet._' ? '' : doc.body, + MAX_TOPIC_SUMMARY_CHARS, + ); + return [ + `topic=${doc.type}`, + `title=${doc.title}`, + `description=${doc.description || '(none)'}`, + `current=${body || '(empty)'}`, + ].join('\n'); + }) + .join('\n\n'); +} + +function buildTaskPrompt( + messages: AutoMemoryTranscriptMessage[], + topicSummaries: string, +): string { + return [ + 'Return a JSON object that matches this schema:', + JSON.stringify(EXTRACTION_AGENT_RESPONSE_SCHEMA, null, 2), + '', + 'Transcript slice:', + buildTranscriptBlock(messages), + '', + 'Current topic summaries:', + topicSummaries || '(no topics found)', + ].join('\n'); +} + +function validateExtractionAgentResponse( + parsed: ExtractionAgentResponse, + userOffsets: Set, +): AutoMemoryExtractPatch[] { + const schemaError = SchemaValidator.validate( + EXTRACTION_AGENT_RESPONSE_SCHEMA, + parsed, + ); + if (schemaError) { + throw new Error(`Invalid extraction agent response: ${schemaError}`); + } + + for (const patch of parsed.patches) { + if (!patch.summary?.trim()) { + throw new Error('Invalid extraction agent response: empty summary'); + } + if (!userOffsets.has(patch.sourceOffset)) { + throw new Error( + 'Invalid extraction agent response: invalid sourceOffset', + ); + } + } + + return parsed.patches.map((patch) => ({ + topic: patch.topic as AutoMemoryType, + summary: patch.summary.trim(), + sourceOffset: patch.sourceOffset, + })); +} + +export async function planAutoMemoryExtractionPatchesByAgent( + config: Config, + projectRoot: string, + messages: AutoMemoryTranscriptMessage[], + runner: BackgroundAgentRunnerLike = new BackgroundAgentRunner(), +): Promise { + if (messages.length === 0) { + return []; + } + + const userOffsets = new Set( + messages + .filter((message) => message.role === 'user') + .map((message) => message.offset), + ); + if (userOffsets.size === 0) { + return []; + } + + const topicSummaries = await buildTopicSummaryBlock(projectRoot); + const result = await runner.run({ + taskType: 'managed-auto-memory-extraction-agent', + title: 'Managed auto-memory extraction agent', + description: 'Extract durable managed memory patches from transcript history.', + projectRoot, + sessionId: config.getSessionId(), + dedupeKey: `managed-auto-memory-extraction-agent:${projectRoot}`, + name: 'managed-auto-memory-extractor', + runtimeContext: config, + taskPrompt: buildTaskPrompt(messages, topicSummaries), + promptConfig: { + systemPrompt: EXTRACTION_AGENT_SYSTEM_PROMPT, + }, + modelConfig: { + model: config.getModel(), + temp: 0, + }, + runConfig: { + max_turns: 2, + max_time_minutes: 1, + }, + toolConfig: { + tools: [], + }, + metadata: { + planner: 'extraction-agent', + }, + }); + + if (result.status !== 'completed' || !result.finalText) { + throw new Error(result.error || 'Extraction agent did not complete successfully'); + } + + const parsed = safeJsonParse(result.finalText, { + patches: [], + }); + return validateExtractionAgentResponse(parsed, userOffsets); +} From 1ab3b0a99c7904d868223acc536f408f5544df6b Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 18:50:46 +0800 Subject: [PATCH 14/56] feat(core): add dream agent planner --- docs/auto-memory-work-log.md | 52 ++++++ packages/core/src/core/client.test.ts | 1 + packages/core/src/core/client.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/memory/dream.test.ts | 88 ++++++++- packages/core/src/memory/dream.ts | 74 ++++++++ .../core/src/memory/dreamAgentPlanner.test.ts | 117 ++++++++++++ packages/core/src/memory/dreamAgentPlanner.ts | 175 ++++++++++++++++++ packages/core/src/memory/dreamScheduler.ts | 8 +- 9 files changed, 515 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/memory/dreamAgentPlanner.test.ts create mode 100644 packages/core/src/memory/dreamAgentPlanner.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 414410e528c..98a4349c18a 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -62,6 +62,58 @@ Completed --- +## Part 13 - Dream agent consumer stage A + +### Start review + +- Overall plan continues from the shared background runtime and extraction-agent work toward the next real consumer: dream/consolidation. +- Parts 10 to 12 already delivered auto dream scheduling, a reusable `BackgroundAgentRunner`, and extraction agent planning. +- Scope for this part: let dream attempt a tool-free background-agent rewrite plan first, while preserving the existing mechanical dream path as a safe fallback. + +### Goal + +- Add a tool-free dream agent planner that returns structured topic rewrites +- Wire managed dream to prefer agent rewrites when `Config` is available +- Preserve the existing mechanical dream path as fallback on planner failure or absence of config +- Add targeted tests for planner validation, agent-first dream behavior, fallback behavior, and client wiring + +### Implemented + +- Added `packages/core/src/memory/dreamAgentPlanner.ts` +- Added `packages/core/src/memory/dreamAgentPlanner.test.ts` +- Updated `packages/core/src/memory/dream.ts` to prefer agent-produced topic rewrites and fall back to mechanical dream +- Updated `packages/core/src/memory/dreamScheduler.ts` to accept optional `Config` and pass it into dream execution +- Updated `packages/core/src/core/client.ts` to pass `Config` into auto-dream scheduling +- Updated `packages/core/src/memory/dream.test.ts` with agent-first and fallback coverage +- Updated `packages/core/src/core/client.test.ts` with dream scheduler config coverage +- Exported dream agent planner helpers from `packages/core/src/index.ts` + +### Functional verification + +- Managed dream can now use `BackgroundAgentRunner` as a tool-free consolidation planner that rewrites full topic bodies in JSON form. +- If the dream agent planner fails, returns invalid output, or no `Config` is available, dream safely falls back to the existing mechanical dedupe implementation. +- Auto dream scheduling now passes runtime config through from the main client so background dream tasks can use the new agent path. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/dreamAgentPlanner.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts src/core/client.test.ts` +- Passed regression tests: + - `npm exec --workspace=packages/core -- vitest run src/background/backgroundAgentRunner.test.ts src/memory/extractionAgentPlanner.test.ts src/memory/extractAgent.test.ts src/memory/extractModel.test.ts src/memory/extract.test.ts src/memory/dreamAgentPlanner.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts src/core/client.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This stage intentionally keeps the dream agent tool-free and JSON-only, matching the low-risk rollout shape used for extraction agent stage A. +- The existing mechanical dream remains the safety net and still supports manual `/dream` use without requiring runtime config. + +### Status + +Completed + +--- + ## Part 6 - Auxiliary side-query foundation ### Start review diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index d63679b88a5..ab5100a8099 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -1490,6 +1490,7 @@ hello expect(scheduleManagedAutoMemoryDream).toHaveBeenCalledWith({ projectRoot: '/test/project/root', sessionId: 'test-session-id', + config: mockConfig, }); expect(events).toContainEqual({ type: GeminiEventType.HookSystemMessage, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 04067f50285..cdb01b2056e 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -471,6 +471,7 @@ export class GeminiClient { void scheduleManagedAutoMemoryDream({ projectRoot: this.config.getProjectRoot(), sessionId: this.config.getSessionId(), + config: this.config, }).catch((error) => { debugLogger.warn('Failed to schedule managed auto-memory dream.', error); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 96c9fbab43c..23bca02a9fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -128,6 +128,7 @@ export * from './memory/state.js'; export * from './memory/extractionAgentPlanner.js'; export * from './memory/extractionPlanner.js'; export * from './memory/extract.js'; +export * from './memory/dreamAgentPlanner.js'; export * from './memory/dream.js'; export * from './memory/dreamScheduler.js'; export * from './memory/scan.js'; diff --git a/packages/core/src/memory/dream.test.ts b/packages/core/src/memory/dream.test.ts index a3fd905f313..ed83acec074 100644 --- a/packages/core/src/memory/dream.test.ts +++ b/packages/core/src/memory/dream.test.ts @@ -7,11 +7,18 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; import { getAutoMemoryTopicPath } from './paths.js'; import { runManagedAutoMemoryDream } from './dream.js'; import { ensureAutoMemoryScaffold } from './store.js'; +vi.mock('./dreamAgentPlanner.js', () => ({ + planManagedAutoMemoryDreamByAgent: vi.fn(), +})); + +import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; + describe('managed auto-memory dream', () => { let tempDir: string; let projectRoot: string; @@ -21,6 +28,7 @@ describe('managed auto-memory dream', () => { projectRoot = path.join(tempDir, 'project'); await fs.mkdir(projectRoot, { recursive: true }); await ensureAutoMemoryScaffold(projectRoot); + vi.mocked(planManagedAutoMemoryDreamByAgent).mockReset(); }); afterEach(async () => { @@ -86,4 +94,82 @@ describe('managed auto-memory dream', () => { expect(content).toContain('_No entries yet._'); }); + + it('prefers agent rewrites when config is provided', async () => { + vi.mocked(planManagedAutoMemoryDreamByAgent).mockResolvedValue([ + { + topic: 'user', + body: '# User Memory\n\n- User prefers terse responses.', + }, + ]); + + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + [ + '---', + 'type: user', + 'title: User Memory', + 'description: User profile', + '---', + '', + '# User Memory', + '', + '- User prefers terse responses.', + '- User prefers terse responses.', + ].join('\n'), + 'utf-8', + ); + + const result = await runManagedAutoMemoryDream( + projectRoot, + new Date('2026-04-02T00:00:00.000Z'), + { + getSessionId: vi.fn(), + getModel: vi.fn(), + } as unknown as Config, + ); + const content = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + 'utf-8', + ); + + expect(result.touchedTopics).toEqual(['user']); + expect(result.dedupedEntries).toBe(1); + expect(content.match(/User prefers terse responses\./g)).toHaveLength(1); + }); + + it('falls back to mechanical dream when the agent planner fails', async () => { + vi.mocked(planManagedAutoMemoryDreamByAgent).mockRejectedValue( + new Error('agent failed'), + ); + + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + [ + '---', + 'type: user', + 'title: User Memory', + 'description: User profile', + '---', + '', + '# User Memory', + '', + '- User prefers terse responses.', + '- User prefers terse responses.', + ].join('\n'), + 'utf-8', + ); + + const result = await runManagedAutoMemoryDream( + projectRoot, + new Date('2026-04-02T00:00:00.000Z'), + { + getSessionId: vi.fn(), + getModel: vi.fn(), + } as unknown as Config, + ); + + expect(result.touchedTopics).toContain('user'); + expect(result.dedupedEntries).toBe(1); + }); }); \ No newline at end of file diff --git a/packages/core/src/memory/dream.ts b/packages/core/src/memory/dream.ts index e797103e8aa..07b3969106e 100644 --- a/packages/core/src/memory/dream.ts +++ b/packages/core/src/memory/dream.ts @@ -5,7 +5,9 @@ */ import * as fs from 'node:fs/promises'; +import type { Config } from '../config/config.js'; import { getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; +import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; import { parseAutoMemoryTopicDocument } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { @@ -24,6 +26,19 @@ function normalizeBullet(line: string): string { return line.replace(/^[-*]\s+/, '').replace(/\s+/g, ' ').trim(); } +function countDuplicateBullets(body: string): number { + const bullets = body + .split('\n') + .filter((line) => /^[-*]\s+/.test(line.trim())) + .map(normalizeBullet) + .filter((line) => line.length > 0); + + return Math.max( + 0, + bullets.length - new Set(bullets.map((line) => line.toLowerCase())).size, + ); +} + function buildDreamedBody(body: string): { body: string; dedupedEntries: number } { const lines = body .split('\n') @@ -61,12 +76,71 @@ async function bumpMetadata(projectRoot: string, now: Date): Promise { } } +async function runDreamByAgent( + projectRoot: string, + config: Config, +): Promise { + const rewrites = await planManagedAutoMemoryDreamByAgent(config, projectRoot); + if (rewrites.length === 0) { + return null; + } + + const touchedTopics = new Set(); + let dedupedEntries = 0; + + for (const rewrite of rewrites) { + const topicPath = getAutoMemoryTopicPath(projectRoot, rewrite.topic); + const current = await fs.readFile(topicPath, 'utf-8'); + const parsed = parseAutoMemoryTopicDocument(topicPath, current); + if (!parsed) { + continue; + } + + const nextBody = rewrite.body.trim(); + dedupedEntries += Math.max( + 0, + countDuplicateBullets(parsed.body) - countDuplicateBullets(nextBody), + ); + if (nextBody === parsed.body.trim()) { + continue; + } + + const next = current.replace(parsed.body, nextBody); + await fs.writeFile(topicPath, next, 'utf-8'); + touchedTopics.add(rewrite.topic); + } + + return { + touchedTopics: [...touchedTopics], + dedupedEntries, + systemMessage: + touchedTopics.size > 0 + ? `Managed auto-memory dream updated: ${[...touchedTopics].map((topic) => `${topic}.md`).join(', ')}` + : undefined, + }; +} + export async function runManagedAutoMemoryDream( projectRoot: string, now = new Date(), + config?: Config, ): Promise { await ensureAutoMemoryScaffold(projectRoot, now); + if (config) { + try { + const agentResult = await runDreamByAgent(projectRoot, config); + if (agentResult) { + if (agentResult.touchedTopics.length > 0) { + await bumpMetadata(projectRoot, now); + } + return agentResult; + } + } catch { + // Fall back to the existing mechanical dream implementation. + } + } + const touchedTopics = new Set(); let dedupedEntries = 0; diff --git a/packages/core/src/memory/dreamAgentPlanner.test.ts b/packages/core/src/memory/dreamAgentPlanner.test.ts new file mode 100644 index 00000000000..2b56891345c --- /dev/null +++ b/packages/core/src/memory/dreamAgentPlanner.test.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { getAutoMemoryTopicPath } from './paths.js'; +import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; +import { ensureAutoMemoryScaffold } from './store.js'; + +describe('dreamAgentPlanner', () => { + let tempDir: string; + let projectRoot: string; + let config: Config; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-dream-agent-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot); + config = { + getSessionId: vi.fn().mockReturnValue('session-1'), + getModel: vi.fn().mockReturnValue('qwen-test'), + } as unknown as Config; + }); + + afterEach(async () => { + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('returns validated rewrites from the background agent', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + [ + '---', + 'type: user', + 'title: User Memory', + 'description: User profile', + '---', + '', + '# User Memory', + '', + '- User prefers terse responses.', + '- User prefers terse responses.', + ].join('\n'), + 'utf-8', + ); + + const runner = { + run: vi.fn().mockResolvedValue({ + taskId: 'task-1', + status: 'completed', + finalText: JSON.stringify({ + rewrites: [ + { + topic: 'user', + body: '# User Memory\n\n- User prefers terse responses.', + }, + ], + }), + filesTouched: [], + }), + }; + + const rewrites = await planManagedAutoMemoryDreamByAgent( + config, + projectRoot, + runner, + ); + + expect(rewrites).toEqual([ + { + topic: 'user', + body: '# User Memory\n\n- User prefers terse responses.', + }, + ]); + expect(runner.run).toHaveBeenCalledWith( + expect.objectContaining({ + projectRoot, + sessionId: 'session-1', + toolConfig: { tools: [] }, + }), + ); + }); + + it('rejects invalid agent output', async () => { + const runner = { + run: vi.fn().mockResolvedValue({ + taskId: 'task-2', + status: 'completed', + finalText: JSON.stringify({ + rewrites: [ + { + topic: 'user', + body: ' ', + }, + ], + }), + filesTouched: [], + }), + }; + + await expect( + planManagedAutoMemoryDreamByAgent(config, projectRoot, runner), + ).rejects.toThrow('Invalid dream agent response: empty body'); + }); +}); diff --git a/packages/core/src/memory/dreamAgentPlanner.ts b/packages/core/src/memory/dreamAgentPlanner.ts new file mode 100644 index 00000000000..13d228f963d --- /dev/null +++ b/packages/core/src/memory/dreamAgentPlanner.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { + BackgroundAgentRunner, + type BackgroundAgentResult, +} from '../background/backgroundAgentRunner.js'; +import { SchemaValidator } from '../utils/schemaValidator.js'; +import { safeJsonParse } from '../utils/safeJsonParse.js'; +import { + AUTO_MEMORY_TYPES, + type AutoMemoryType, +} from './types.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; + +const MAX_TOPIC_BODY_CHARS = 2_000; + +const DREAM_AGENT_SYSTEM_PROMPT = `You are a background memory consolidation agent for an AI coding assistant. + +Your job is to consolidate managed memory topic documents into cleaner, deduplicated topic bodies. + +Rules: +- Output JSON only. +- Follow the schema exactly. +- Only rewrite topics that benefit from consolidation. +- Preserve durable information. +- Remove duplicates and obvious clutter. +- Keep topic headings. +- If a topic has no durable entries, use the standard placeholder: _No entries yet._`; + +const DREAM_AGENT_RESPONSE_SCHEMA: Record = { + type: 'object', + properties: { + rewrites: { + type: 'array', + items: { + type: 'object', + properties: { + topic: { + type: 'string', + enum: [...AUTO_MEMORY_TYPES], + }, + body: { + type: 'string', + }, + }, + required: ['topic', 'body'], + }, + }, + }, + required: ['rewrites'], +}; + +export interface AutoMemoryDreamRewrite { + topic: AutoMemoryType; + body: string; +} + +interface DreamAgentResponse { + rewrites: AutoMemoryDreamRewrite[]; +} + +interface BackgroundAgentRunnerLike { + run(request: Parameters[0]): Promise; +} + +function truncate(text: string, maxChars: number): string { + const normalized = text.trim(); + if (normalized.length <= maxChars) { + return normalized; + } + return `${normalized.slice(0, maxChars).trimEnd()}…`; +} + +async function buildTopicDocumentBlock(projectRoot: string): Promise { + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + return docs + .map((doc) => + [ + `topic=${doc.type}`, + `title=${doc.title}`, + `description=${doc.description || '(none)'}`, + 'body:', + truncate(doc.body, MAX_TOPIC_BODY_CHARS), + ].join('\n'), + ) + .join('\n\n'); +} + +function buildTaskPrompt(topicBlock: string): string { + return [ + 'Return a JSON object matching this schema:', + JSON.stringify(DREAM_AGENT_RESPONSE_SCHEMA, null, 2), + '', + 'Managed memory topic documents:', + topicBlock || '(no topics found)', + ].join('\n'); +} + +function validateDreamAgentResponse( + parsed: DreamAgentResponse, +): AutoMemoryDreamRewrite[] { + const schemaError = SchemaValidator.validate( + DREAM_AGENT_RESPONSE_SCHEMA, + parsed, + ); + if (schemaError) { + throw new Error(`Invalid dream agent response: ${schemaError}`); + } + + const seen = new Set(); + for (const rewrite of parsed.rewrites) { + if (!rewrite.body.trim()) { + throw new Error('Invalid dream agent response: empty body'); + } + if (seen.has(rewrite.topic)) { + throw new Error('Invalid dream agent response: duplicate topic rewrite'); + } + seen.add(rewrite.topic); + } + + return parsed.rewrites.map((rewrite) => ({ + topic: rewrite.topic, + body: rewrite.body.trim(), + })); +} + +export async function planManagedAutoMemoryDreamByAgent( + config: Config, + projectRoot: string, + runner: BackgroundAgentRunnerLike = new BackgroundAgentRunner(), +): Promise { + const topicBlock = await buildTopicDocumentBlock(projectRoot); + const result = await runner.run({ + taskType: 'managed-auto-memory-dream-agent', + title: 'Managed auto-memory dream agent', + description: 'Consolidate managed memory topic files into cleaner summaries.', + projectRoot, + sessionId: config.getSessionId(), + dedupeKey: `managed-auto-memory-dream-agent:${projectRoot}`, + name: 'managed-auto-memory-dreamer', + runtimeContext: config, + taskPrompt: buildTaskPrompt(topicBlock), + promptConfig: { + systemPrompt: DREAM_AGENT_SYSTEM_PROMPT, + }, + modelConfig: { + model: config.getModel(), + temp: 0, + }, + runConfig: { + max_turns: 2, + max_time_minutes: 1, + }, + toolConfig: { + tools: [], + }, + metadata: { + planner: 'dream-agent', + }, + }); + + if (result.status !== 'completed' || !result.finalText) { + throw new Error(result.error || 'Dream agent did not complete successfully'); + } + + const parsed = safeJsonParse(result.finalText, { + rewrites: [], + }); + return validateDreamAgentResponse(parsed); +} diff --git a/packages/core/src/memory/dreamScheduler.ts b/packages/core/src/memory/dreamScheduler.ts index 2bf10bb6806..0fb8e12bf7e 100644 --- a/packages/core/src/memory/dreamScheduler.ts +++ b/packages/core/src/memory/dreamScheduler.ts @@ -5,6 +5,7 @@ */ import * as fs from 'node:fs/promises'; +import type { Config } from '../config/config.js'; import { BackgroundTaskDrainer, type DrainBackgroundTasksOptions, @@ -28,6 +29,7 @@ export const DEFAULT_AUTO_DREAM_MIN_SESSIONS = 5; export interface ScheduleManagedAutoMemoryDreamParams { projectRoot: string; sessionId: string; + config?: Config; now?: Date; minHoursBetweenDreams?: number; minSessionsBetweenDreams?: number; @@ -167,7 +169,11 @@ export class ManagedAutoMemoryDreamRuntime { } try { - const result = await runManagedAutoMemoryDream(params.projectRoot, now); + const result = await runManagedAutoMemoryDream( + params.projectRoot, + now, + params.config, + ); const nextMetadata = await readDreamMetadata(params.projectRoot); nextMetadata.lastDreamAt = now.toISOString(); nextMetadata.lastDreamSessionId = params.sessionId; From f6b80c625591e587e24d36fadd2301c8d43e6231 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 19:38:50 +0800 Subject: [PATCH 15/56] feat(core): rebuild managed memory index --- docs/auto-memory-work-log.md | 49 +++++++++++ packages/core/src/index.ts | 1 + packages/core/src/memory/dream.test.ts | 4 +- packages/core/src/memory/dream.ts | 3 + packages/core/src/memory/extract.test.ts | 5 +- packages/core/src/memory/extract.ts | 2 + packages/core/src/memory/indexer.test.ts | 98 ++++++++++++++++++++++ packages/core/src/memory/indexer.ts | 100 +++++++++++++++++++++++ packages/core/src/memory/store.ts | 22 +++-- 9 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/memory/indexer.test.ts create mode 100644 packages/core/src/memory/indexer.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 98a4349c18a..ea98c4d8313 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -114,6 +114,55 @@ Completed --- +## Part 14 - Dynamic MEMORY index rewrite + +### Start review + +- Overall plan now shifts from initial dream-agent adoption to the next high-value consistency gap: keeping `MEMORY.md` aligned with real topic-file state. +- Part 13 already delivered agent-first dream planning, but the managed index was still largely scaffold-shaped and not rebuilt after extraction or dream. +- Scope for this part: add a mechanical dynamic index builder, regenerate `MEMORY.md` after topic mutations, and keep the index compact for both prompt loading and manual review. + +### Goal + +- Add a reusable managed index builder that summarizes topic documents into short hooks +- Rebuild `MEMORY.md` automatically after extraction and dream touch topic files +- Keep the default scaffold index in the same compact dynamic format +- Add targeted tests for hook extraction, index formatting, and extract/dream integration + +### Implemented + +- Added `packages/core/src/memory/indexer.ts` +- Added `packages/core/src/memory/indexer.test.ts` +- Updated `packages/core/src/memory/store.ts` so the default scaffold index uses the dynamic index format +- Updated `packages/core/src/memory/extract.ts` to rebuild `MEMORY.md` after successful topic patch application +- Updated `packages/core/src/memory/dream.ts` to rebuild `MEMORY.md` after agent or mechanical consolidation +- Updated `packages/core/src/memory/extract.test.ts` and `packages/core/src/memory/dream.test.ts` with index rewrite coverage +- Exported index helpers from `packages/core/src/index.ts` + +### Functional verification + +- `MEMORY.md` is now a compact, dynamic topic index that lists durable entry counts and short hooks derived from topic bullets. +- Extraction and dream now keep the managed index synchronized with topic-file mutations instead of leaving the scaffold stale. +- Manual review and prompt loading now see a more representative managed memory landing page without needing a model-generated summary step. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/indexer.test.ts src/memory/store.test.ts src/memory/extract.test.ts src/memory/dream.test.ts src/memory/prompt.test.ts` +- Passed typecheck: + - `npm run typecheck --workspace=packages/core` + +### Notes + +- This stage intentionally keeps index generation mechanical by using the first few unique topic bullets as hooks. +- A later stage can upgrade the index from mechanical hooks to model-generated summaries if needed. + +### Status + +Completed + +--- + ## Part 6 - Auxiliary side-query foundation ### Start review diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 23bca02a9fa..1d7fc477585 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -123,6 +123,7 @@ export * from './background/backgroundAgentRunner.js'; export * from './memory/types.js'; export * from './memory/paths.js'; export * from './memory/store.js'; +export * from './memory/indexer.js'; export * from './memory/prompt.js'; export * from './memory/state.js'; export * from './memory/extractionAgentPlanner.js'; diff --git a/packages/core/src/memory/dream.test.ts b/packages/core/src/memory/dream.test.ts index ed83acec074..1fd75e51e8f 100644 --- a/packages/core/src/memory/dream.test.ts +++ b/packages/core/src/memory/dream.test.ts @@ -9,7 +9,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { getAutoMemoryTopicPath } from './paths.js'; +import { getAutoMemoryIndexPath, getAutoMemoryTopicPath } from './paths.js'; import { runManagedAutoMemoryDream } from './dream.js'; import { ensureAutoMemoryScaffold } from './store.js'; @@ -64,10 +64,12 @@ describe('managed auto-memory dream', () => { getAutoMemoryTopicPath(projectRoot, 'user'), 'utf-8', ); + const index = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); expect(result.touchedTopics).toContain('user'); expect(result.dedupedEntries).toBe(1); expect(content.match(/User prefers terse responses\./g)).toHaveLength(1); + expect(index.match(/User prefers terse responses\./g)).toHaveLength(1); }); it('restores the empty placeholder when no bullet entries remain', async () => { diff --git a/packages/core/src/memory/dream.ts b/packages/core/src/memory/dream.ts index 07b3969106e..194ab35af21 100644 --- a/packages/core/src/memory/dream.ts +++ b/packages/core/src/memory/dream.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs/promises'; import type { Config } from '../config/config.js'; import { getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; +import { rebuildManagedAutoMemoryIndex } from './indexer.js'; import { parseAutoMemoryTopicDocument } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { @@ -133,6 +134,7 @@ export async function runManagedAutoMemoryDream( if (agentResult) { if (agentResult.touchedTopics.length > 0) { await bumpMetadata(projectRoot, now); + await rebuildManagedAutoMemoryIndex(projectRoot); } return agentResult; } @@ -165,6 +167,7 @@ export async function runManagedAutoMemoryDream( if (touchedTopics.size > 0) { await bumpMetadata(projectRoot, now); + await rebuildManagedAutoMemoryIndex(projectRoot); } return { diff --git a/packages/core/src/memory/extract.test.ts b/packages/core/src/memory/extract.test.ts index 03db2990be1..07d9c146a20 100644 --- a/packages/core/src/memory/extract.test.ts +++ b/packages/core/src/memory/extract.test.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getAutoMemoryExtractCursorPath, getAutoMemoryTopicPath } from './paths.js'; +import { getAutoMemoryExtractCursorPath, getAutoMemoryIndexPath, getAutoMemoryTopicPath } from './paths.js'; import { applyExtractedMemoryPatches, buildTranscriptMessages, @@ -80,9 +80,12 @@ describe('auto-memory extraction', () => { const userTopic = await fs.readFile(getAutoMemoryTopicPath(projectRoot, 'user'), 'utf-8'); const referenceTopic = await fs.readFile(getAutoMemoryTopicPath(projectRoot, 'reference'), 'utf-8'); + const index = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); expect(userTopic).toContain('- I prefer terse responses.'); expect(referenceTopic).toContain('grafana.internal/d/api-latency'); + expect(index).toContain('I prefer terse responses.'); + expect(index).toContain('grafana.internal/d/api-latency'); }); it('updates cursor and avoids duplicate writes for repeated extraction', async () => { diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts index e2715e95733..4ca8b0f3f10 100644 --- a/packages/core/src/memory/extract.ts +++ b/packages/core/src/memory/extract.ts @@ -14,6 +14,7 @@ import { ensureAutoMemoryScaffold } from './store.js'; import { parseAutoMemoryTopicDocument } from './scan.js'; import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; +import { rebuildManagedAutoMemoryIndex } from './indexer.js'; import { type AutoMemoryExtractCursor, type AutoMemoryMetadata, @@ -354,6 +355,7 @@ export async function applyExtractedMemoryPatches( if (touchedTopics.size > 0) { await bumpMetadata(projectRoot, now); + await rebuildManagedAutoMemoryIndex(projectRoot); } return [...touchedTopics]; diff --git a/packages/core/src/memory/indexer.test.ts b/packages/core/src/memory/indexer.test.ts new file mode 100644 index 00000000000..fc432e2b03f --- /dev/null +++ b/packages/core/src/memory/indexer.test.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getAutoMemoryIndexPath, getAutoMemoryTopicPath } from './paths.js'; +import { + buildAutoMemoryTopicHooks, + buildManagedAutoMemoryIndex, + rebuildManagedAutoMemoryIndex, +} from './indexer.js'; +import { ensureAutoMemoryScaffold } from './store.js'; + +describe('managed auto-memory indexer', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-indexer-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T00:00:00.000Z')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('builds short hooks from unique topic bullets', () => { + expect( + buildAutoMemoryTopicHooks([ + '# User Memory', + '', + '- User prefers terse responses.', + '- User prefers terse responses.', + '- User likes dark mode.', + '- User uses pnpm.', + '- User writes tests first.', + ].join('\n')), + ).toEqual([ + 'User prefers terse responses.', + 'User likes dark mode.', + 'User uses pnpm.', + ]); + }); + + it('formats a compact managed index view', () => { + const content = buildManagedAutoMemoryIndex([ + { + type: 'user', + filePath: 'user.md', + title: 'User Memory', + description: 'User profile', + body: '# User Memory\n\n- User prefers terse responses.', + }, + ]); + + expect(content).toContain('Durable entries: 1'); + expect(content).toContain('[User Memory](user.md)'); + expect(content).toContain('User prefers terse responses.'); + }); + + it('rewrites MEMORY.md from topic file contents', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'project'), + [ + '---', + 'type: project', + 'title: Project Memory', + 'description: Project facts', + '---', + '', + '# Project Memory', + '', + '- The repo uses pnpm workspaces.', + '- CI runs vitest and typecheck.', + ].join('\n'), + 'utf-8', + ); + + await rebuildManagedAutoMemoryIndex(projectRoot); + + const index = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); + expect(index).toContain('[Project Memory](project.md)'); + expect(index).toContain('The repo uses pnpm workspaces.'); + expect(index).toContain('CI runs vitest and typecheck.'); + }); +}); diff --git a/packages/core/src/memory/indexer.ts b/packages/core/src/memory/indexer.ts new file mode 100644 index 00000000000..d895d5a63c8 --- /dev/null +++ b/packages/core/src/memory/indexer.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { getAutoMemoryIndexPath, getAutoMemoryMetadataPath } from './paths.js'; +import { scanAutoMemoryTopicDocuments, type ScannedAutoMemoryDocument } from './scan.js'; +import type { AutoMemoryMetadata } from './types.js'; + +const MAX_TOPIC_HOOKS = 3; + +function getBodyBulletLines(body: string): string[] { + return body + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^[-*]\s+/.test(line)) + .map((line) => line.replace(/^[-*]\s+/, '').trim()) + .filter((line) => line.length > 0); +} + +export function countAutoMemoryTopicEntries(body: string): number { + return getBodyBulletLines(body).length; +} + +export function buildAutoMemoryTopicHooks(body: string): string[] { + const hooks = getBodyBulletLines(body); + return Array.from( + new Map(hooks.map((hook) => [hook.toLowerCase(), hook])).values(), + ).slice(0, MAX_TOPIC_HOOKS); +} + +export function buildManagedAutoMemoryIndex( + docs: ScannedAutoMemoryDocument[], + metadata?: Pick, +): string { + const totalEntries = docs.reduce( + (sum, doc) => sum + countAutoMemoryTopicEntries(doc.body), + 0, + ); + + const lines = [ + '# Managed Auto-Memory Index', + '', + 'This index is maintained by Qwen Code. It summarizes durable topic files and short hooks for recall and manual review.', + '', + `Topics: ${docs.length} | Durable entries: ${totalEntries}`, + ]; + + if (metadata?.updatedAt) { + lines.push(`Updated: ${metadata.updatedAt}`); + } + if (metadata?.lastDreamAt) { + lines.push(`Last dream: ${metadata.lastDreamAt}${metadata.lastDreamSessionId ? ` (session ${metadata.lastDreamSessionId})` : ''}`); + } + + lines.push('', '## Topics', ''); + + for (const doc of docs) { + const entryCount = countAutoMemoryTopicEntries(doc.body); + const hooks = buildAutoMemoryTopicHooks(doc.body); + lines.push( + `- [${doc.title}](${doc.type}.md) — ${doc.description} (${entryCount} durable ${entryCount === 1 ? 'entry' : 'entries'})`, + ); + if (hooks.length === 0) { + lines.push(' - Hook: empty'); + continue; + } + for (const hook of hooks) { + lines.push(` - ${hook}`); + } + } + + lines.push(''); + return lines.join('\n'); +} + +async function readAutoMemoryMetadata( + projectRoot: string, +): Promise { + try { + const content = await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'); + return JSON.parse(content) as AutoMemoryMetadata; + } catch { + return undefined; + } +} + +export async function rebuildManagedAutoMemoryIndex( + projectRoot: string, +): Promise { + const [docs, metadata] = await Promise.all([ + scanAutoMemoryTopicDocuments(projectRoot), + readAutoMemoryMetadata(projectRoot), + ]); + const content = buildManagedAutoMemoryIndex(docs, metadata); + await fs.writeFile(getAutoMemoryIndexPath(projectRoot), content, 'utf-8'); + return content; +} diff --git a/packages/core/src/memory/store.ts b/packages/core/src/memory/store.ts index e5be62a3621..f6b4a52822a 100644 --- a/packages/core/src/memory/store.ts +++ b/packages/core/src/memory/store.ts @@ -20,6 +20,7 @@ import { type AutoMemoryMetadata, type AutoMemoryType, } from './types.js'; +import { buildManagedAutoMemoryIndex } from './indexer.js'; const TOPIC_DESCRIPTIONS: Record = { user: 'User profile, preferences, background, and stable collaboration context.', @@ -64,18 +65,15 @@ export function createDefaultAutoMemoryExtractCursor( } export function createDefaultAutoMemoryIndex(): string { - const lines = [ - '# Managed Auto-Memory Index', - '', - 'This index is maintained by Qwen Code. Keep entries concise and store durable details in topic files.', - '', - ...AUTO_MEMORY_TYPES.map( - (type) => - `- [${buildTopicTitle(type)}](${type}.md) — ${TOPIC_DESCRIPTIONS[type]}`, - ), - '', - ]; - return lines.join('\n'); + return buildManagedAutoMemoryIndex( + AUTO_MEMORY_TYPES.map((type) => ({ + type, + filePath: `${type}.md`, + title: buildTopicTitle(type), + description: TOPIC_DESCRIPTIONS[type], + body: '_No entries yet._', + })), + ); } export function createDefaultAutoMemoryTopic(type: AutoMemoryType): string { From 6309b21d3833f2768bbcb2e41acf8a14ad07a02c Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 19:52:23 +0800 Subject: [PATCH 16/56] feat(memory): add governance status commands --- docs/auto-memory-work-log.md | 50 +++++ .../cli/src/ui/commands/memoryCommand.test.ts | 169 +++++++++++++---- packages/cli/src/ui/commands/memoryCommand.ts | 175 ++++++++++++++---- packages/core/src/index.ts | 1 + packages/core/src/memory/dream.ts | 23 +++ packages/core/src/memory/extract.ts | 20 +- packages/core/src/memory/status.test.ts | 74 ++++++++ packages/core/src/memory/status.ts | 110 +++++++++++ packages/core/src/memory/types.ts | 6 + 9 files changed, 555 insertions(+), 73 deletions(-) create mode 100644 packages/core/src/memory/status.test.ts create mode 100644 packages/core/src/memory/status.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index ea98c4d8313..af2f19d6e86 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -163,6 +163,56 @@ Completed --- +## Part 15 - Memory governance entrypoints + +### Start review + +- Overall plan now moves from keeping `MEMORY.md` synchronized to making managed memory easier to inspect and govern from the CLI. +- Part 14 already gave the system a dynamic index, but `/memory` still exposed only a minimal status view and no direct task/index inspection path. +- Scope for this part: add reusable managed-memory status aggregation, enrich metadata with recent extraction/dream outcomes, and expand `/memory` with governance-oriented status, tasks, and inspect commands. + +### Goal + +- Add a reusable status reader for managed auto-memory state +- Record recent extraction/dream outcomes in metadata for governance views +- Expand `/memory` with richer `status`, plus `tasks` and `inspect` subcommands +- Add targeted tests for status aggregation and CLI governance flows + +### Implemented + +- Added `packages/core/src/memory/status.ts` +- Added `packages/core/src/memory/status.test.ts` +- Extended `packages/core/src/memory/types.ts` metadata with recent extraction/dream result fields +- Updated `packages/core/src/memory/extract.ts` to persist recent extraction status into metadata +- Updated `packages/core/src/memory/dream.ts` to persist recent dream status into metadata +- Exported status helpers from `packages/core/src/index.ts` +- Enhanced `packages/cli/src/ui/commands/memoryCommand.ts` to use structured status aggregation +- Added `/memory tasks` and `/memory inspect` governance subcommands +- Updated `packages/cli/src/ui/commands/memoryCommand.test.ts` with governance coverage + +### Functional verification + +- Managed memory now has a reusable status surface covering cursor state, dynamic index, topic hooks, extraction running-state, recent dream/extraction results, and tracked dream tasks. +- `/memory status` now surfaces more operational context instead of only file counts. +- `/memory tasks` and `/memory inspect` provide direct governance entrypoints for recent task activity and current managed memory contents. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/status.test.ts src/memory/extract.test.ts src/memory/dreamScheduler.test.ts src/memory/dream.test.ts` + - `npm exec --workspace=packages/cli -- vitest run src/ui/commands/memoryCommand.test.ts` + +### Notes + +- Extraction still runs in-process rather than in the shared background task runtime, so governance currently reports extraction running-state separately from tracked dream tasks. +- This part focuses on observability and inspectability, not on adding an interactive review UI. + +### Status + +Completed + +--- + ## Part 6 - Auxiliary side-query foundation ### Start review diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 2c92d161b0f..0c051628ac3 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -16,9 +16,8 @@ import os from 'node:os'; import path from 'node:path'; import { AUTO_MEMORY_TYPES, - getAutoMemoryExtractCursorPath, - getAutoMemoryTopicPath, getErrorMessage, + getManagedAutoMemoryStatus, loadServerHierarchicalMemory, QWEN_DIR, scheduleAutoMemoryExtract, @@ -36,6 +35,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { if (error instanceof Error) return error.message; return String(error); }), + getManagedAutoMemoryStatus: vi.fn(), loadServerHierarchicalMemory: vi.fn(), scheduleAutoMemoryExtract: vi.fn(), }; @@ -53,12 +53,13 @@ vi.mock('node:fs/promises', () => { const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock; const mockScheduleAutoMemoryExtract = scheduleAutoMemoryExtract as Mock; +const mockGetManagedAutoMemoryStatus = getManagedAutoMemoryStatus as Mock; const mockReadFile = readFile as unknown as Mock; describe('memoryCommand', () => { let mockContext: CommandContext; - const getSubCommand = (name: 'show' | 'add' | 'refresh'): SlashCommand => { + const getSubCommand = (name: 'show' | 'add' | 'refresh' | 'status' | 'tasks' | 'inspect'): SlashCommand => { const subCommand = memoryCommand.subCommands?.find( (cmd) => cmd.name === name, ); @@ -293,7 +294,7 @@ describe('memoryCommand', () => { statusCommand = memoryCommand.subCommands?.find( (cmd) => cmd.name === 'status', ) as SlashCommand; - mockReadFile.mockReset(); + mockGetManagedAutoMemoryStatus.mockReset(); mockContext = createMockCommandContext({ services: { config: { @@ -304,33 +305,35 @@ describe('memoryCommand', () => { }); it('shows managed auto-memory root, cursor and topic counts', async () => { - mockReadFile.mockImplementation(async (filePath: string) => { - if (filePath === getAutoMemoryExtractCursorPath('/test/project')) { - return JSON.stringify({ - sessionId: 'session-1', - processedOffset: 3, - updatedAt: '2026-04-01T00:00:00.000Z', - }); - } - - for (const topic of AUTO_MEMORY_TYPES) { - if (filePath === getAutoMemoryTopicPath('/test/project', topic)) { - return [ - '---', - `type: ${topic}`, - `title: ${topic}`, - 'description: topic', - '---', - '', - `# ${topic}`, - '', - '- one', - '- two', - ].join('\n'); - } - } - - throw new Error('ENOENT'); + mockGetManagedAutoMemoryStatus.mockResolvedValue({ + root: '/test/project/.qwen/memory', + indexPath: '/test/project/.qwen/memory/MEMORY.md', + indexContent: '# Managed Auto-Memory Index', + cursor: { + sessionId: 'session-1', + processedOffset: 3, + updatedAt: '2026-04-01T00:00:00.000Z', + }, + metadata: { + version: 1, + createdAt: '2026-04-01T00:00:00.000Z', + updatedAt: '2026-04-01T00:00:00.000Z', + lastExtractionAt: '2026-04-01T00:00:00.000Z', + lastExtractionStatus: 'updated', + lastExtractionTouchedTopics: ['user'], + lastDreamAt: '2026-04-01T01:00:00.000Z', + lastDreamStatus: 'noop', + lastDreamTouchedTopics: [], + }, + extractionRunning: false, + dreamTasks: [], + topics: AUTO_MEMORY_TYPES.map((topic) => ({ + topic, + title: topic, + entryCount: 2, + hooks: ['one', 'two'], + filePath: `/test/project/.qwen/memory/${topic}.md`, + })), }); await statusCommand.action?.(mockContext, ''); @@ -345,6 +348,110 @@ describe('memoryCommand', () => { const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; expect(text).toContain('Cursor: session=session-1, offset=3'); expect(text).toContain('- user.md: 2 entries'); + expect(text).toContain('Extraction: running=no'); + expect(text).toContain('Dream: last=2026-04-01T01:00:00.000Z'); + }); + }); + + describe('/memory tasks', () => { + let tasksCommand: SlashCommand; + + beforeEach(() => { + tasksCommand = getSubCommand('tasks'); + mockGetManagedAutoMemoryStatus.mockReset(); + mockContext = createMockCommandContext({ + services: { + config: { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }); + }); + + it('shows extraction and dream task state', async () => { + mockGetManagedAutoMemoryStatus.mockResolvedValue({ + root: '/test/project/.qwen/memory', + indexPath: '/test/project/.qwen/memory/MEMORY.md', + indexContent: '', + extractionRunning: true, + topics: [], + dreamTasks: [ + { + id: 'dream-1', + taskType: 'managed-auto-memory-dream', + title: 'Managed auto-memory dream', + projectRoot: '/test/project', + status: 'running', + createdAt: '2026-04-01T00:00:00.000Z', + updatedAt: '2026-04-01T00:01:00.000Z', + progressText: 'Consolidating topics', + }, + ], + }); + + await tasksCommand.action?.(mockContext, ''); + + const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; + expect(text).toContain('extraction: running'); + expect(text).toContain('dream dream-1: running'); + }); + }); + + describe('/memory inspect', () => { + let inspectCommand: SlashCommand; + + beforeEach(() => { + inspectCommand = getSubCommand('inspect'); + mockGetManagedAutoMemoryStatus.mockReset(); + mockReadFile.mockReset(); + mockContext = createMockCommandContext({ + services: { + config: { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }); + }); + + it('shows the managed index by default', async () => { + mockGetManagedAutoMemoryStatus.mockResolvedValue({ + root: '/test/project/.qwen/memory', + indexPath: '/test/project/.qwen/memory/MEMORY.md', + indexContent: '# Managed Auto-Memory Index\n\n- hook', + extractionRunning: false, + topics: [], + dreamTasks: [], + }); + + await inspectCommand.action?.(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + text: '# Managed Auto-Memory Index\n\n- hook', + }), + expect.any(Number), + ); + }); + + it('shows a topic file when a valid topic is requested', async () => { + mockGetManagedAutoMemoryStatus.mockResolvedValue({ + root: '/test/project/.qwen/memory', + indexPath: '/test/project/.qwen/memory/MEMORY.md', + indexContent: '# Managed Auto-Memory Index', + extractionRunning: false, + topics: [], + dreamTasks: [], + }); + mockReadFile.mockResolvedValue('# User Memory\n\n- User prefers terse responses.'); + + await inspectCommand.action?.(mockContext, 'user'); + + expect(mockReadFile).toHaveBeenCalledWith( + '/test/project/.qwen/memory/user.md', + 'utf-8', + ); + const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; + expect(text).toContain('User prefers terse responses.'); }); }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 99ba97dfea3..9216c7ea2c1 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -7,12 +7,10 @@ import { AUTO_MEMORY_TYPES, getErrorMessage, - getAutoMemoryExtractCursorPath, - getAutoMemoryRoot, + getManagedAutoMemoryStatus, getAutoMemoryTopicPath, getAllGeminiMdFilenames, loadServerHierarchicalMemory, - parseAutoMemoryTopicDocument, QWEN_DIR, scheduleAutoMemoryExtract, } from '@qwen-code/qwen-code-core'; @@ -25,54 +23,101 @@ import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; async function buildManagedMemoryStatus(projectRoot: string): Promise { - const root = getAutoMemoryRoot(projectRoot); + const status = await getManagedAutoMemoryStatus(projectRoot); - let cursorSummary = t('No extraction cursor found yet.'); - try { - const cursor = JSON.parse( - await fs.readFile(getAutoMemoryExtractCursorPath(projectRoot), 'utf-8'), - ) as { sessionId?: string; processedOffset?: number; updatedAt?: string }; - cursorSummary = t( - 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}', - { - sessionId: cursor.sessionId || 'n/a', - offset: String(cursor.processedOffset ?? 0), - updatedAt: cursor.updatedAt || 'n/a', - }, - ); - } catch { - // Keep default summary. - } + const cursorSummary = status.cursor + ? t( + 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}', + { + sessionId: status.cursor.sessionId || 'n/a', + offset: String(status.cursor.processedOffset ?? 0), + updatedAt: status.cursor.updatedAt || 'n/a', + }, + ) + : t('No extraction cursor found yet.'); - const topicSummaries = await Promise.all( - AUTO_MEMORY_TYPES.map(async (topic) => { - try { - const content = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, topic), - 'utf-8', - ); - const parsed = parseAutoMemoryTopicDocument( - getAutoMemoryTopicPath(projectRoot, topic), - content, - ); - const entryCount = parsed?.body - .split('\n') - .filter((line) => /^[-*]\s+/.test(line.trim())).length; - return `- ${topic}.md: ${entryCount ?? 0} entries`; - } catch { - return `- ${topic}.md: 0 entries`; - } - }), + const extractionSummary = t( + 'Extraction: running={{running}}, last={{last}}, status={{status}}, touched={{touched}}', + { + running: status.extractionRunning ? 'yes' : 'no', + last: status.metadata?.lastExtractionAt || 'n/a', + status: status.metadata?.lastExtractionStatus || 'n/a', + touched: + status.metadata?.lastExtractionTouchedTopics?.join(', ') || 'none', + }, + ); + + const dreamSummary = t( + 'Dream: last={{last}}, status={{status}}, touched={{touched}}, activeTasks={{activeTasks}}', + { + last: status.metadata?.lastDreamAt || 'n/a', + status: status.metadata?.lastDreamStatus || 'n/a', + touched: status.metadata?.lastDreamTouchedTopics?.join(', ') || 'none', + activeTasks: String( + status.dreamTasks.filter( + (task) => task.status === 'pending' || task.status === 'running', + ).length, + ), + }, + ); + + const topicSummaries = status.topics.map( + (topic) => + `- ${topic.topic}.md: ${topic.entryCount} entries${topic.hooks.length > 0 ? ` | hooks: ${topic.hooks.join(' ; ')}` : ''}`, ); return [ - t('Managed auto-memory root: {{root}}', { root }), + t('Managed auto-memory root: {{root}}', { root: status.root }), cursorSummary, + extractionSummary, + dreamSummary, t('Managed auto-memory topics:'), ...topicSummaries, ].join('\n'); } +async function buildManagedMemoryTasks(projectRoot: string): Promise { + const status = await getManagedAutoMemoryStatus(projectRoot); + const lines = [ + t('Managed auto-memory background tasks:'), + `- extraction: ${status.extractionRunning ? 'running' : 'idle'}`, + ]; + + if (status.dreamTasks.length === 0) { + lines.push('- dream: no tracked tasks'); + return lines.join('\n'); + } + + for (const task of status.dreamTasks) { + lines.push( + `- dream ${task.id}: ${task.status} | updated=${task.updatedAt}${task.progressText ? ` | ${task.progressText}` : ''}`, + ); + } + return lines.join('\n'); +} + +async function buildManagedMemoryInspect( + projectRoot: string, + target?: string, +): Promise { + const normalizedTarget = target?.trim().toLowerCase(); + const status = await getManagedAutoMemoryStatus(projectRoot); + if (!normalizedTarget || normalizedTarget === 'index' || normalizedTarget === 'memory') { + return status.indexContent || t('Managed memory index is empty.'); + } + + if (!AUTO_MEMORY_TYPES.includes(normalizedTarget as (typeof AUTO_MEMORY_TYPES)[number])) { + return t('Unknown managed memory target: {{target}}', { target: target ?? '' }); + } + + const topicPath = getAutoMemoryTopicPath(projectRoot, normalizedTarget as (typeof AUTO_MEMORY_TYPES)[number]); + try { + return await fs.readFile(topicPath, 'utf-8'); + } catch { + return t('Unknown managed memory target: {{target}}', { target: target ?? '' }); + } +} + /** * Read all existing memory files from the configured filenames in a directory. * Returns an array of found files with their paths and contents. @@ -234,6 +279,56 @@ export const memoryCommand: SlashCommand = { return; }, }, + { + name: 'tasks', + get description() { + return t('Show managed auto-memory background task status.'); + }, + kind: CommandKind.BUILT_IN, + action: async (context) => { + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: await buildManagedMemoryTasks(config.getProjectRoot()), + }, + Date.now(), + ); + }, + }, + { + name: 'inspect', + get description() { + return t('Inspect managed auto-memory index or a topic file.'); + }, + kind: CommandKind.BUILT_IN, + action: async (context, args) => { + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: await buildManagedMemoryInspect(config.getProjectRoot(), args), + }, + Date.now(), + ); + }, + }, { name: 'extract-now', get description() { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d7fc477585..8e1e4131a4b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,7 @@ export * from './memory/store.js'; export * from './memory/indexer.js'; export * from './memory/prompt.js'; export * from './memory/state.js'; +export * from './memory/status.js'; export * from './memory/extractionAgentPlanner.js'; export * from './memory/extractionPlanner.js'; export * from './memory/extract.js'; diff --git a/packages/core/src/memory/dream.ts b/packages/core/src/memory/dream.ts index 194ab35af21..ddc908dc915 100644 --- a/packages/core/src/memory/dream.ts +++ b/packages/core/src/memory/dream.ts @@ -71,6 +71,7 @@ async function bumpMetadata(projectRoot: string, now: Date): Promise { const content = await fs.readFile(metadataPath, 'utf-8'); const metadata = JSON.parse(content) as AutoMemoryMetadata; metadata.updatedAt = now.toISOString(); + metadata.lastDreamAt = now.toISOString(); await fs.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf-8'); } catch { // Best-effort metadata bump. @@ -136,6 +137,7 @@ export async function runManagedAutoMemoryDream( await bumpMetadata(projectRoot, now); await rebuildManagedAutoMemoryIndex(projectRoot); } + await updateDreamMetadataResult(projectRoot, now, agentResult.touchedTopics); return agentResult; } } catch { @@ -170,6 +172,8 @@ export async function runManagedAutoMemoryDream( await rebuildManagedAutoMemoryIndex(projectRoot); } + await updateDreamMetadataResult(projectRoot, now, [...touchedTopics]); + return { touchedTopics: [...touchedTopics], dedupedEntries, @@ -178,4 +182,23 @@ export async function runManagedAutoMemoryDream( ? `Managed auto-memory dream updated: ${[...touchedTopics].map((topic) => `${topic}.md`).join(', ')}` : undefined, }; +} + +async function updateDreamMetadataResult( + projectRoot: string, + now: Date, + touchedTopics: AutoMemoryType[], +): Promise { + const metadataPath = getAutoMemoryMetadataPath(projectRoot); + try { + const content = await fs.readFile(metadataPath, 'utf-8'); + const metadata = JSON.parse(content) as AutoMemoryMetadata; + metadata.updatedAt = now.toISOString(); + metadata.lastDreamAt = now.toISOString(); + metadata.lastDreamTouchedTopics = touchedTopics; + metadata.lastDreamStatus = touchedTopics.length > 0 ? 'updated' : 'noop'; + await fs.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf-8'); + } catch { + // Best-effort metadata bump. + } } \ No newline at end of file diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts index 4ca8b0f3f10..47ebd70bbae 100644 --- a/packages/core/src/memory/extract.ts +++ b/packages/core/src/memory/extract.ts @@ -296,11 +296,20 @@ async function writeExtractCursor( ); } -async function bumpMetadata(projectRoot: string, now: Date): Promise { +async function bumpMetadata( + projectRoot: string, + now: Date, + sessionId: string, + touchedTopics: AutoMemoryType[], +): Promise { try { const content = await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'); const metadata = JSON.parse(content) as AutoMemoryMetadata; metadata.updatedAt = now.toISOString(); + metadata.lastExtractionAt = now.toISOString(); + metadata.lastExtractionSessionId = sessionId; + metadata.lastExtractionTouchedTopics = touchedTopics; + metadata.lastExtractionStatus = touchedTopics.length > 0 ? 'updated' : 'noop'; await fs.writeFile( getAutoMemoryMetadataPath(projectRoot), `${JSON.stringify(metadata, null, 2)}\n`, @@ -338,6 +347,7 @@ export async function applyExtractedMemoryPatches( projectRoot: string, patches: AutoMemoryExtractPatch[], now = new Date(), + sessionId?: string, ): Promise { const touchedTopics = new Set(); @@ -353,8 +363,13 @@ export async function applyExtractedMemoryPatches( touchedTopics.add(patch.topic); } + if (sessionId) { + await bumpMetadata(projectRoot, now, sessionId, [...touchedTopics]); + } else if (touchedTopics.size > 0) { + await bumpMetadata(projectRoot, now, 'unknown', [...touchedTopics]); + } + if (touchedTopics.size > 0) { - await bumpMetadata(projectRoot, now); await rebuildManagedAutoMemoryIndex(projectRoot); } @@ -387,6 +402,7 @@ export async function runAutoMemoryExtract(params: { params.projectRoot, patches, now, + params.sessionId, ); const cursor: AutoMemoryExtractCursor = { diff --git a/packages/core/src/memory/status.test.ts b/packages/core/src/memory/status.test.ts new file mode 100644 index 00000000000..393fe67ce39 --- /dev/null +++ b/packages/core/src/memory/status.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getManagedAutoMemoryDreamTaskRegistry } from './dreamScheduler.js'; +import { getManagedAutoMemoryStatus } from './status.js'; +import { getAutoMemoryTopicPath } from './paths.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { markExtractRunning, resetAutoMemoryStateForTests } from './state.js'; + +describe('managed auto-memory status', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-status-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T00:00:00.000Z')); + }); + + afterEach(async () => { + resetAutoMemoryStateForTests(); + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('aggregates cursor, topics, extraction state, and dream tasks', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + [ + '---', + 'type: user', + 'title: User Memory', + 'description: User profile', + '---', + '', + '# User Memory', + '', + '- User prefers terse responses.', + ].join('\n'), + 'utf-8', + ); + + markExtractRunning(projectRoot); + getManagedAutoMemoryDreamTaskRegistry().register({ + taskType: 'managed-auto-memory-dream', + title: 'Managed auto-memory dream', + projectRoot, + }); + + const status = await getManagedAutoMemoryStatus(projectRoot); + + expect(status.extractionRunning).toBe(true); + expect(status.topics.find((topic) => topic.topic === 'user')).toEqual( + expect.objectContaining({ + entryCount: 1, + hooks: ['User prefers terse responses.'], + }), + ); + expect(status.dreamTasks).toHaveLength(1); + expect(status.indexContent).toContain('# Managed Auto-Memory Index'); + }); +}); diff --git a/packages/core/src/memory/status.ts b/packages/core/src/memory/status.ts new file mode 100644 index 00000000000..345bdcd85b4 --- /dev/null +++ b/packages/core/src/memory/status.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { getManagedAutoMemoryDreamTaskRegistry } from './dreamScheduler.js'; +import { buildAutoMemoryTopicHooks, countAutoMemoryTopicEntries } from './indexer.js'; +import { + getAutoMemoryExtractCursorPath, + getAutoMemoryIndexPath, + getAutoMemoryMetadataPath, + getAutoMemoryRoot, + getAutoMemoryTopicPath, +} from './paths.js'; +import { parseAutoMemoryTopicDocument } from './scan.js'; +import { isExtractRunning } from './state.js'; +import type { + AutoMemoryExtractCursor, + AutoMemoryMetadata, + AutoMemoryType, +} from './types.js'; +import { AUTO_MEMORY_TYPES } from './types.js'; +import type { BackgroundTaskState } from '../background/taskRegistry.js'; + +export interface ManagedAutoMemoryTopicStatus { + topic: AutoMemoryType; + title: string; + entryCount: number; + hooks: string[]; + filePath: string; +} + +export interface ManagedAutoMemoryStatus { + root: string; + indexPath: string; + indexContent: string; + cursor?: AutoMemoryExtractCursor; + metadata?: AutoMemoryMetadata; + extractionRunning: boolean; + topics: ManagedAutoMemoryTopicStatus[]; + dreamTasks: BackgroundTaskState[]; +} + +async function readJsonFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(content) as T; + } catch { + return undefined; + } +} + +export async function getManagedAutoMemoryStatus( + projectRoot: string, +): Promise { + const root = getAutoMemoryRoot(projectRoot); + const indexPath = getAutoMemoryIndexPath(projectRoot); + const [indexContent, cursor, metadata, topics] = await Promise.all([ + fs.readFile(indexPath, 'utf-8').catch(() => ''), + readJsonFile(getAutoMemoryExtractCursorPath(projectRoot)), + readJsonFile(getAutoMemoryMetadataPath(projectRoot)), + Promise.all( + AUTO_MEMORY_TYPES.map(async (topic) => { + const filePath = getAutoMemoryTopicPath(projectRoot, topic); + try { + const content = await fs.readFile(filePath, 'utf-8'); + const parsed = parseAutoMemoryTopicDocument(filePath, content); + if (!parsed) { + return { + topic, + title: topic, + entryCount: 0, + hooks: [], + filePath, + }; + } + + return { + topic, + title: parsed.title, + entryCount: countAutoMemoryTopicEntries(parsed.body), + hooks: buildAutoMemoryTopicHooks(parsed.body), + filePath, + } satisfies ManagedAutoMemoryTopicStatus; + } catch { + return { + topic, + title: topic, + entryCount: 0, + hooks: [], + filePath, + } satisfies ManagedAutoMemoryTopicStatus; + } + }), + ), + ]); + + return { + root, + indexPath, + indexContent, + cursor, + metadata, + extractionRunning: isExtractRunning(projectRoot), + topics, + dreamTasks: getManagedAutoMemoryDreamTaskRegistry().list(projectRoot).slice(0, 5), + }; +} diff --git a/packages/core/src/memory/types.ts b/packages/core/src/memory/types.ts index 918cebd1a0b..0f821db2b7d 100644 --- a/packages/core/src/memory/types.ts +++ b/packages/core/src/memory/types.ts @@ -44,8 +44,14 @@ export interface AutoMemoryMetadata { version: typeof AUTO_MEMORY_SCHEMA_VERSION; createdAt: string; updatedAt: string; + lastExtractionAt?: string; + lastExtractionSessionId?: string; + lastExtractionTouchedTopics?: AutoMemoryType[]; + lastExtractionStatus?: 'updated' | 'noop'; lastDreamAt?: string; lastDreamSessionId?: string; + lastDreamTouchedTopics?: AutoMemoryType[]; + lastDreamStatus?: 'updated' | 'noop'; recentSessionIdsSinceDream?: string[]; } From dadcb8530a93b39bd5bb4da84c586b1691f6d178 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 20:00:29 +0800 Subject: [PATCH 17/56] feat(memory): add managed forget flow --- docs/auto-memory-work-log.md | 50 +++++ .../src/services/BuiltinCommandLoader.test.ts | 10 + .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/ui/commands/forgetCommand.test.ts | 52 ++++++ packages/cli/src/ui/commands/forgetCommand.ts | 49 +++++ .../cli/src/ui/commands/memoryCommand.test.ts | 53 +++++- packages/cli/src/ui/commands/memoryCommand.ts | 44 +++++ packages/core/src/index.ts | 1 + packages/core/src/memory/forget.test.ts | 117 ++++++++++++ packages/core/src/memory/forget.ts | 172 ++++++++++++++++++ 10 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/commands/forgetCommand.test.ts create mode 100644 packages/cli/src/ui/commands/forgetCommand.ts create mode 100644 packages/core/src/memory/forget.test.ts create mode 100644 packages/core/src/memory/forget.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index af2f19d6e86..d0d9b8e4d9a 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -213,6 +213,56 @@ Completed --- +## Part 16 - Forget loop closure + +### Start review + +- Overall plan now advances from observing managed memory to actively governing it with both add and remove flows. +- Part 15 already exposed richer status and inspect commands, but the system still had no explicit way to remove stale or unwanted managed entries. +- Scope for this part: add a safe managed-memory forget path, keep topic/index state synchronized after deletion, and expose that capability through built-in CLI commands. + +### Goal + +- Add managed auto-memory search/delete helpers for explicit forget requests +- Regenerate `MEMORY.md` after removals and restore placeholders when a topic becomes empty +- Add `/forget` plus `/memory forget` command entrypoints with user-visible feedback +- Add targeted tests for forget matching, topic/index updates, and command registration + +### Implemented + +- Added `packages/core/src/memory/forget.ts` +- Added `packages/core/src/memory/forget.test.ts` +- Exported forget helpers from `packages/core/src/index.ts` +- Added `packages/cli/src/ui/commands/forgetCommand.ts` +- Added `packages/cli/src/ui/commands/forgetCommand.test.ts` +- Updated `packages/cli/src/ui/commands/memoryCommand.ts` with `/memory forget` +- Updated `packages/cli/src/ui/commands/memoryCommand.test.ts` with forget coverage +- Updated `packages/cli/src/services/BuiltinCommandLoader.ts` and `packages/cli/src/services/BuiltinCommandLoader.test.ts` to register `/forget` + +### Functional verification + +- Managed memory now supports explicit forget operations by searching topic bullets and removing matching entries. +- Topic files restore `_No entries yet._` when all durable entries for a topic are removed. +- `MEMORY.md` stays synchronized after forget operations, so governance and prompt-loading still see the current state. +- Users can now invoke forget through either `/forget` or `/memory forget` and receive direct feedback. + +### Test verification + +- Passed targeted tests: + - `npm exec --workspace=packages/core -- vitest run src/memory/forget.test.ts src/memory/indexer.test.ts src/memory/status.test.ts` + - `npm exec --workspace=packages/cli -- vitest run src/ui/commands/forgetCommand.test.ts src/ui/commands/memoryCommand.test.ts src/services/BuiltinCommandLoader.test.ts` + +### Notes + +- This stage keeps forget matching mechanical by using case-insensitive substring matching over durable bullet entries. +- A later stage can layer model-assisted candidate selection or interactive confirmation on top of the same host-side deletion path. + +### Status + +Completed + +--- + ## Part 6 - Auxiliary side-query foundation ### Start review diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index e2b8c874fcf..06c944cb731 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -93,6 +93,13 @@ vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} })); vi.mock('../ui/commands/extensionsCommand.js', () => ({ extensionsCommand: {}, })); +vi.mock('../ui/commands/forgetCommand.js', () => ({ + forgetCommand: { + name: 'forget', + description: 'Forget command', + kind: 'built-in', + }, +})); vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} })); vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} })); vi.mock('../ui/commands/insightCommand.js', () => ({ insightCommand: {} })); @@ -202,6 +209,9 @@ describe('BuiltinCommandLoader', () => { const dreamCmd = commands.find((c) => c.name === 'dream'); expect(dreamCmd).toBeDefined(); + const forgetCmd = commands.find((c) => c.name === 'forget'); + expect(forgetCmd).toBeDefined(); + const rememberCmd = commands.find((c) => c.name === 'remember'); expect(rememberCmd).toBeDefined(); }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 3ce7a784c32..7aadf039625 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -24,6 +24,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { exportCommand } from '../ui/commands/exportCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; +import { forgetCommand } from '../ui/commands/forgetCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; @@ -82,6 +83,7 @@ export class BuiltinCommandLoader implements ICommandLoader { editorCommand, exportCommand, extensionsCommand, + forgetCommand, helpCommand, ...(this.config?.getEnableHooks() ? [hooksCommand] : []), await ideCommand(), diff --git a/packages/cli/src/ui/commands/forgetCommand.test.ts b/packages/cli/src/ui/commands/forgetCommand.test.ts new file mode 100644 index 00000000000..0b9d85795d3 --- /dev/null +++ b/packages/cli/src/ui/commands/forgetCommand.test.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { forgetManagedAutoMemoryEntries } from '@qwen-code/qwen-code-core'; +import { forgetCommand } from './forgetCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +vi.mock('@qwen-code/qwen-code-core', () => ({ + forgetManagedAutoMemoryEntries: vi.fn(), +})); + +describe('forgetCommand', () => { + it('returns usage error when no args are provided', async () => { + const result = await forgetCommand.action?.(createMockCommandContext(), ' '); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /forget ', + }); + }); + + it('removes matching managed auto-memory entries', async () => { + vi.mocked(forgetManagedAutoMemoryEntries).mockResolvedValue({ + query: 'terse', + removedEntries: [{ topic: 'user', summary: 'User prefers terse responses.' }], + touchedTopics: ['user'], + systemMessage: 'Managed auto-memory forgot 1 entry from user.md', + }); + + const result = await forgetCommand.action?.( + createMockCommandContext({ + services: { + config: { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }), + 'terse', + ); + + expect(forgetManagedAutoMemoryEntries).toHaveBeenCalledWith('/test/project', 'terse'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Managed auto-memory forgot 1 entry from user.md', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/forgetCommand.ts b/packages/cli/src/ui/commands/forgetCommand.ts new file mode 100644 index 00000000000..0fa9d936397 --- /dev/null +++ b/packages/cli/src/ui/commands/forgetCommand.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { forgetManagedAutoMemoryEntries } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; +import type { SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; + +export const forgetCommand: SlashCommand = { + name: 'forget', + get description() { + return t('Remove matching entries from managed auto-memory.'); + }, + kind: CommandKind.BUILT_IN, + action: async (context, args) => { + const query = args.trim(); + if (!query) { + return { + type: 'message', + messageType: 'error', + content: t('Usage: /forget '), + }; + } + + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const result = await forgetManagedAutoMemoryEntries( + config.getProjectRoot(), + query, + ); + return { + type: 'message', + messageType: 'info', + content: + result.systemMessage ?? + t('No managed auto-memory entries matched: {{query}}', { query }), + }; + }, +}; diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 0c051628ac3..22fed96ced0 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -18,6 +18,7 @@ import { AUTO_MEMORY_TYPES, getErrorMessage, getManagedAutoMemoryStatus, + forgetManagedAutoMemoryEntries, loadServerHierarchicalMemory, QWEN_DIR, scheduleAutoMemoryExtract, @@ -36,6 +37,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { return String(error); }), getManagedAutoMemoryStatus: vi.fn(), + forgetManagedAutoMemoryEntries: vi.fn(), loadServerHierarchicalMemory: vi.fn(), scheduleAutoMemoryExtract: vi.fn(), }; @@ -54,12 +56,13 @@ vi.mock('node:fs/promises', () => { const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock; const mockScheduleAutoMemoryExtract = scheduleAutoMemoryExtract as Mock; const mockGetManagedAutoMemoryStatus = getManagedAutoMemoryStatus as Mock; +const mockForgetManagedAutoMemoryEntries = forgetManagedAutoMemoryEntries as Mock; const mockReadFile = readFile as unknown as Mock; describe('memoryCommand', () => { let mockContext: CommandContext; - const getSubCommand = (name: 'show' | 'add' | 'refresh' | 'status' | 'tasks' | 'inspect'): SlashCommand => { + const getSubCommand = (name: 'show' | 'add' | 'refresh' | 'status' | 'tasks' | 'inspect' | 'forget'): SlashCommand => { const subCommand = memoryCommand.subCommands?.find( (cmd) => cmd.name === name, ); @@ -501,6 +504,54 @@ describe('memoryCommand', () => { }); }); + describe('/memory forget', () => { + let forgetCommand: SlashCommand; + + beforeEach(() => { + forgetCommand = getSubCommand('forget'); + mockForgetManagedAutoMemoryEntries.mockReset(); + mockContext = createMockCommandContext({ + services: { + config: { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }); + }); + + it('returns usage error when no args are provided', async () => { + const result = await forgetCommand.action?.(mockContext, ' '); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /memory forget ', + }); + }); + + it('forgets matching managed memory entries', async () => { + mockForgetManagedAutoMemoryEntries.mockResolvedValue({ + query: 'terse', + removedEntries: [{ topic: 'user', summary: 'User prefers terse responses.' }], + touchedTopics: ['user'], + systemMessage: 'Managed auto-memory forgot 1 entry from user.md', + }); + + await forgetCommand.action?.(mockContext, 'terse'); + + expect(mockForgetManagedAutoMemoryEntries).toHaveBeenCalledWith( + '/test/project', + 'terse', + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Managed auto-memory forgot 1 entry from user.md', + }, + expect.any(Number), + ); + }); + }); + describe('/memory add', () => { let addCommand: SlashCommand; diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 9216c7ea2c1..1ffe81a3aac 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -8,6 +8,7 @@ import { AUTO_MEMORY_TYPES, getErrorMessage, getManagedAutoMemoryStatus, + forgetManagedAutoMemoryEntries, getAutoMemoryTopicPath, getAllGeminiMdFilenames, loadServerHierarchicalMemory, @@ -375,6 +376,49 @@ export const memoryCommand: SlashCommand = { return; }, }, + { + name: 'forget', + get description() { + return t('Remove matching entries from managed auto-memory.'); + }, + kind: CommandKind.BUILT_IN, + action: async (context, args) => { + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const query = args.trim(); + if (!query) { + return { + type: 'message', + messageType: 'error', + content: t('Usage: /memory forget '), + }; + } + + const result = await forgetManagedAutoMemoryEntries( + config.getProjectRoot(), + query, + ); + + context.ui.addItem( + { + type: MessageType.INFO, + text: + result.systemMessage ?? + t('No managed auto-memory entries matched: {{query}}', { + query, + }), + }, + Date.now(), + ); + }, + }, { name: 'add', get description() { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8e1e4131a4b..7df991d8306 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -127,6 +127,7 @@ export * from './memory/indexer.js'; export * from './memory/prompt.js'; export * from './memory/state.js'; export * from './memory/status.js'; +export * from './memory/forget.js'; export * from './memory/extractionAgentPlanner.js'; export * from './memory/extractionPlanner.js'; export * from './memory/extract.js'; diff --git a/packages/core/src/memory/forget.test.ts b/packages/core/src/memory/forget.test.ts new file mode 100644 index 00000000000..46fc028681e --- /dev/null +++ b/packages/core/src/memory/forget.test.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { forgetManagedAutoMemoryEntries, findManagedAutoMemoryForgetCandidates } from './forget.js'; +import { getAutoMemoryIndexPath, getAutoMemoryTopicPath } from './paths.js'; +import { ensureAutoMemoryScaffold } from './store.js'; + +describe('managed auto-memory forget', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-forget-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot); + }); + + afterEach(async () => { + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('finds matching forget candidates across topics', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + [ + '---', + 'type: user', + 'title: User Memory', + 'description: User profile', + '---', + '', + '# User Memory', + '', + '- User prefers terse responses.', + ].join('\n'), + 'utf-8', + ); + + const matches = await findManagedAutoMemoryForgetCandidates(projectRoot, 'terse'); + expect(matches).toEqual([ + { + topic: 'user', + summary: 'User prefers terse responses.', + }, + ]); + }); + + it('removes matching topic entries and rewrites the index', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + [ + '---', + 'type: user', + 'title: User Memory', + 'description: User profile', + '---', + '', + '# User Memory', + '', + '- User prefers terse responses.', + '- User likes dark mode.', + ].join('\n'), + 'utf-8', + ); + + const result = await forgetManagedAutoMemoryEntries(projectRoot, 'terse'); + const topicContent = await fs.readFile(getAutoMemoryTopicPath(projectRoot, 'user'), 'utf-8'); + const indexContent = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); + + expect(result.removedEntries).toEqual([ + { + topic: 'user', + summary: 'User prefers terse responses.', + }, + ]); + expect(topicContent).not.toContain('terse responses'); + expect(topicContent).toContain('User likes dark mode.'); + expect(indexContent).not.toContain('terse responses'); + expect(indexContent).toContain('User likes dark mode.'); + }); + + it('restores the empty placeholder when all matching entries are removed', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'feedback'), + [ + '---', + 'type: feedback', + 'title: Feedback Memory', + 'description: Guidance', + '---', + '', + '# Feedback Memory', + '', + '- Always answer tersely.', + ].join('\n'), + 'utf-8', + ); + + await forgetManagedAutoMemoryEntries(projectRoot, 'tersely'); + const content = await fs.readFile(getAutoMemoryTopicPath(projectRoot, 'feedback'), 'utf-8'); + + expect(content).toContain('_No entries yet._'); + }); +}); diff --git a/packages/core/src/memory/forget.ts b/packages/core/src/memory/forget.ts new file mode 100644 index 00000000000..f254132175d --- /dev/null +++ b/packages/core/src/memory/forget.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { rebuildManagedAutoMemoryIndex } from './indexer.js'; +import { getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; +import { parseAutoMemoryTopicDocument } from './scan.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import type { AutoMemoryMetadata, AutoMemoryType } from './types.js'; +import { AUTO_MEMORY_TYPES } from './types.js'; + +export interface AutoMemoryForgetMatch { + topic: AutoMemoryType; + summary: string; +} + +export interface AutoMemoryForgetResult { + query: string; + removedEntries: AutoMemoryForgetMatch[]; + touchedTopics: AutoMemoryType[]; + systemMessage?: string; +} + +function normalizeBullet(line: string): string { + return line.replace(/^[-*]\s+/, '').replace(/\s+/g, ' ').trim(); +} + +function buildUpdatedBody( + body: string, + query: string, +): { body: string; removedEntries: string[] } { + const queryLower = query.trim().toLowerCase(); + const lines = body.split('\n').map((line) => line.trimEnd()); + const removedEntries: string[] = []; + + const nextLines = lines.filter((line) => { + if (!/^[-*]\s+/.test(line.trim())) { + return true; + } + const normalized = normalizeBullet(line); + const shouldRemove = normalized.toLowerCase().includes(queryLower); + if (shouldRemove) { + removedEntries.push(normalized); + return false; + } + return true; + }); + + const hasBullets = nextLines.some((line) => /^[-*]\s+/.test(line.trim())); + if (!hasBullets) { + const headingIndex = nextLines.findIndex((line) => line.startsWith('# ')); + if (headingIndex >= 0) { + return { + body: [...nextLines.slice(0, headingIndex + 1), '', '_No entries yet._'].join('\n'), + removedEntries, + }; + } + } + + return { + body: nextLines.join('\n').trim(), + removedEntries, + }; +} + +async function bumpMetadata(projectRoot: string, now: Date): Promise { + try { + const content = await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'); + const metadata = JSON.parse(content) as AutoMemoryMetadata; + metadata.updatedAt = now.toISOString(); + await fs.writeFile( + getAutoMemoryMetadataPath(projectRoot), + `${JSON.stringify(metadata, null, 2)}\n`, + 'utf-8', + ); + } catch { + // Best-effort metadata update. + } +} + +export async function findManagedAutoMemoryForgetCandidates( + projectRoot: string, + query: string, +): Promise { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return []; + } + + const matches: AutoMemoryForgetMatch[] = []; + for (const topic of AUTO_MEMORY_TYPES) { + const topicPath = getAutoMemoryTopicPath(projectRoot, topic); + try { + const current = await fs.readFile(topicPath, 'utf-8'); + const parsed = parseAutoMemoryTopicDocument(topicPath, current); + if (!parsed) { + continue; + } + + for (const line of parsed.body.split('\n')) { + if (!/^[-*]\s+/.test(line.trim())) { + continue; + } + const summary = normalizeBullet(line); + if (summary.toLowerCase().includes(normalizedQuery)) { + matches.push({ topic, summary }); + } + } + } catch { + // Ignore missing or invalid topic files. + } + } + + return matches; +} + +export async function forgetManagedAutoMemoryEntries( + projectRoot: string, + query: string, + now = new Date(), +): Promise { + const trimmedQuery = query.trim(); + await ensureAutoMemoryScaffold(projectRoot, now); + if (!trimmedQuery) { + return { + query: trimmedQuery, + removedEntries: [], + touchedTopics: [], + }; + } + + const removedEntries: AutoMemoryForgetMatch[] = []; + const touchedTopics = new Set(); + + for (const topic of AUTO_MEMORY_TYPES) { + const topicPath = getAutoMemoryTopicPath(projectRoot, topic); + const current = await fs.readFile(topicPath, 'utf-8'); + const parsed = parseAutoMemoryTopicDocument(topicPath, current); + if (!parsed) { + continue; + } + + const updated = buildUpdatedBody(parsed.body, trimmedQuery); + if (updated.removedEntries.length === 0 || updated.body === parsed.body.trim()) { + continue; + } + + for (const summary of updated.removedEntries) { + removedEntries.push({ topic, summary }); + } + await fs.writeFile(topicPath, current.replace(parsed.body, updated.body), 'utf-8'); + touchedTopics.add(topic); + } + + if (touchedTopics.size > 0) { + await bumpMetadata(projectRoot, now); + await rebuildManagedAutoMemoryIndex(projectRoot); + } + + return { + query: trimmedQuery, + removedEntries, + touchedTopics: [...touchedTopics], + systemMessage: + removedEntries.length > 0 + ? `Managed auto-memory forgot ${removedEntries.length} entr${removedEntries.length === 1 ? 'y' : 'ies'} from ${[...touchedTopics].map((topic) => `${topic}.md`).join(', ')}` + : undefined, + }; +} From 85ed860e9d783331a70f4968125ece27c64a7105 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 1 Apr 2026 20:11:08 +0800 Subject: [PATCH 18/56] feat(core): harden background agent planning --- docs/auto-memory-work-log.md | 51 ++++++++++ packages/cli/src/ui/commands/memoryCommand.ts | 11 ++- .../background/backgroundAgentRunner.test.ts | 38 ++++++- .../src/background/backgroundAgentRunner.ts | 99 +++++++++++++++++-- packages/core/src/background/taskScheduler.ts | 6 +- .../core/src/memory/dreamAgentPlanner.test.ts | 6 +- packages/core/src/memory/dreamAgentPlanner.ts | 11 ++- .../src/memory/extractionAgentPlanner.test.ts | 5 + .../core/src/memory/extractionAgentPlanner.ts | 11 ++- 9 files changed, 219 insertions(+), 19 deletions(-) diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index d0d9b8e4d9a..cb901c6adf0 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -263,6 +263,57 @@ Completed --- +## Part 17 - Agent stage B observability and constrained tools + +### Start review + +- Overall plan now advances from basic tool-free agent planners to a more realistic constrained-agent runtime shape. +- Parts 11 to 13 already established `BackgroundAgentRunner` and initial dream/extraction agent consumers, but they still ran in a mostly JSON-only, tool-free configuration with limited task observability. +- Scope for this part: make the shared runner expose richer runtime metadata, support cancelled task results, and upgrade extraction/dream planners to a constrained read-only tool mode with larger budgets. + +### Goal + +- Improve `BackgroundAgentRunner` observability with budget, round, and touched-file tracking +- Support cancelled background agent results distinctly from generic failures +- Upgrade extraction and dream planners from tool-free stage A into constrained read-only multi-turn agents +- Add targeted tests for runner metadata, cancelled behavior, and planner configuration + +### Implemented + +- Updated `packages/core/src/background/taskScheduler.ts` to allow explicit final task status from background callbacks +- Updated `packages/core/src/background/backgroundAgentRunner.ts` to track budget metadata, current round, `filesTouched`, and cancelled outcomes +- Updated `packages/core/src/background/backgroundAgentRunner.test.ts` with filesTouched/round/cancelled coverage +- Updated `packages/core/src/memory/extractionAgentPlanner.ts` to expose topic file paths, increase agent budget, and allow constrained `read_file` use +- Updated `packages/core/src/memory/extractionAgentPlanner.test.ts` for the new stage-B planner configuration +- Updated `packages/core/src/memory/dreamAgentPlanner.ts` to expose topic file paths, increase agent budget, and allow constrained `read_file` use +- Updated `packages/core/src/memory/dreamAgentPlanner.test.ts` for the new stage-B planner configuration + +### Functional verification + +- Background agent tasks now expose more useful runtime metadata for governance and future UI work, including budget, rounds, and touched file paths. +- Cancelled headless-agent runs are now represented as cancelled outcomes instead of being collapsed into generic failures. +- Dream and extraction agent planners can now perform constrained read-only inspection of topic files when the provided summaries are insufficient, while still keeping host-side write application. + +### Test verification + +- Passed targeted tests: + - `npm run build --workspace=packages/core` + - `npm run typecheck --workspace=packages/core` + - `npm exec --workspace=packages/core -- vitest run src/background/backgroundAgentRunner.test.ts src/background/taskScheduler.test.ts src/memory/extractionAgentPlanner.test.ts src/memory/dreamAgentPlanner.test.ts src/memory/extractAgent.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts src/memory/extract.test.ts` + - `npm exec --workspace=packages/cli -- vitest run src/ui/commands/forgetCommand.test.ts src/ui/commands/memoryCommand.test.ts src/services/BuiltinCommandLoader.test.ts` + - `npm run generate && npm run build --workspace=packages/web-templates && npm run typecheck --workspace=packages/cli` + +### Notes + +- This stage still keeps host-side application of extraction patches and dream rewrites; agents only inspect and propose. +- The allowed tool surface is intentionally narrow (`read_file`) to keep the rollout low-risk while still exercising the shared background-agent runtime more realistically. + +### Status + +Completed + +--- + ## Part 6 - Auxiliary side-query foundation ### Start review diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 1ffe81a3aac..8a076047b4d 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -56,14 +56,15 @@ async function buildManagedMemoryStatus(projectRoot: string): Promise { touched: status.metadata?.lastDreamTouchedTopics?.join(', ') || 'none', activeTasks: String( status.dreamTasks.filter( - (task) => task.status === 'pending' || task.status === 'running', + (task: { status: string }) => + task.status === 'pending' || task.status === 'running', ).length, ), }, ); const topicSummaries = status.topics.map( - (topic) => + (topic: { topic: string; entryCount: number; hooks: string[] }) => `- ${topic.topic}.md: ${topic.entryCount} entries${topic.hooks.length > 0 ? ` | hooks: ${topic.hooks.join(' ; ')}` : ''}`, ); @@ -303,6 +304,8 @@ export const memoryCommand: SlashCommand = { }, Date.now(), ); + + return; }, }, { @@ -328,6 +331,8 @@ export const memoryCommand: SlashCommand = { }, Date.now(), ); + + return; }, }, { @@ -417,6 +422,8 @@ export const memoryCommand: SlashCommand = { }, Date.now(), ); + + return; }, }, { diff --git a/packages/core/src/background/backgroundAgentRunner.test.ts b/packages/core/src/background/backgroundAgentRunner.test.ts index a4f4e1e1db4..8791bba6924 100644 --- a/packages/core/src/background/backgroundAgentRunner.test.ts +++ b/packages/core/src/background/backgroundAgentRunner.test.ts @@ -24,6 +24,12 @@ describe('BackgroundAgentRunner', () => { eventEmitter?: AgentEventEmitter, ) => ({ execute: async () => { + eventEmitter?.emit(AgentEventType.ROUND_START, { + subagentId: 'agent-1', + round: 1, + promptId: 'prompt-1', + timestamp: Date.now(), + }); eventEmitter?.emit(AgentEventType.STREAM_TEXT, { subagentId: 'agent-1', round: 1, @@ -36,7 +42,7 @@ describe('BackgroundAgentRunner', () => { round: 1, callId: 'call-1', name: 'read_file', - args: {}, + args: { filePath: '/tmp/project/user.md' }, description: 'Read a file', timestamp: Date.now(), }); @@ -76,11 +82,16 @@ describe('BackgroundAgentRunner', () => { outputTokens: 5, totalTokens: 15, }); + expect(result.roundCount).toBe(1); + expect(result.filesTouched).toEqual(['/tmp/project/user.md']); const tasks = runner.registry.list('/tmp/project'); expect(tasks[0]?.progressText).toBe('Done'); expect(tasks[0]?.metadata).toEqual( expect.objectContaining({ + allowedTools: ['*'], + currentRound: 1, + filesTouched: ['/tmp/project/user.md'], lastToolCall: 'read_file', }), ); @@ -110,4 +121,29 @@ describe('BackgroundAgentRunner', () => { expect(result.status).toBe('failed'); expect(result.error).toContain('Background agent terminated with ERROR'); }); + + it('returns cancelled when the headless agent is aborted', async () => { + const createMock = vi.fn().mockResolvedValue({ + execute: vi.fn().mockResolvedValue(undefined), + getTerminateMode: () => AgentTerminateMode.CANCELLED, + getFinalText: () => '', + }); + + const runner = new BackgroundAgentRunner(undefined, undefined, undefined, createMock); + const result = await runner.run({ + taskType: 'background-agent', + title: 'Review code', + description: 'Run a background code review', + projectRoot: '/tmp/project', + name: 'code-reviewer', + runtimeContext: {} as never, + taskPrompt: 'Review the recent code changes', + promptConfig: { systemPrompt: 'You are a reviewer.' }, + modelConfig: { model: 'qwen3-coder-plus' }, + runConfig: { max_turns: 3 }, + }); + + expect(result.status).toBe('cancelled'); + expect(result.error).toContain('CANCELLED'); + }); }); diff --git a/packages/core/src/background/backgroundAgentRunner.ts b/packages/core/src/background/backgroundAgentRunner.ts index 897f86c04da..c1378d07fae 100644 --- a/packages/core/src/background/backgroundAgentRunner.ts +++ b/packages/core/src/background/backgroundAgentRunner.ts @@ -43,7 +43,7 @@ export interface BackgroundAgentTaskRequest { export interface BackgroundAgentResult { taskId: string; - status: 'completed' | 'failed'; + status: 'completed' | 'failed' | 'cancelled'; finalText?: string; terminateReason?: string; usage?: { @@ -51,6 +51,7 @@ export interface BackgroundAgentResult { outputTokens?: number; totalTokens?: number; }; + roundCount?: number; filesTouched: string[]; error?: string; } @@ -91,16 +92,29 @@ export class BackgroundAgentRunner { request: BackgroundAgentTaskRequest, ): Promise { const usage: BackgroundAgentResult['usage'] = {}; + const filesTouched = new Set(); + let roundCount = 0; const scheduled = this.scheduler.schedule({ taskType: request.taskType, title: request.title, projectRoot: request.projectRoot, sessionId: request.sessionId, dedupeKey: request.dedupeKey, - metadata: request.metadata, + metadata: { + ...(request.metadata ?? {}), + budget: { + maxTurns: request.runConfig.max_turns, + maxTimeMinutes: request.runConfig.max_time_minutes, + }, + allowedTools: request.toolConfig?.tools?.map((tool) => + typeof tool === 'string' ? tool : tool.name, + ) ?? ['*'], + }, run: async (task) => { const emitter = new AgentEventEmitter(); - this.bindTaskEvents(task.id, emitter, usage); + this.bindTaskEvents(task.id, emitter, usage, filesTouched, (nextRound) => { + roundCount = Math.max(roundCount, nextRound); + }); const headless = await this.createAgentHeadless( request.name, @@ -119,19 +133,34 @@ export class BackgroundAgentRunner { const terminateReason = headless.getTerminateMode(); if ( terminateReason === AgentTerminateMode.ERROR || - terminateReason === AgentTerminateMode.CANCELLED || terminateReason === AgentTerminateMode.TIMEOUT ) { throw new Error(`Background agent terminated with ${terminateReason}`); } + if (terminateReason === AgentTerminateMode.CANCELLED) { + return { + status: 'cancelled', + progressText: 'Background agent cancelled.', + error: 'Background agent terminated with CANCELLED', + metadata: { + finalText: headless.getFinalText(), + terminateReason, + usage, + roundCount, + filesTouched: [...filesTouched], + }, + }; + } + return { progressText: headless.getFinalText() || request.description, metadata: { finalText: headless.getFinalText(), terminateReason, usage, - filesTouched: [], + roundCount, + filesTouched: [...filesTouched], }, }; }, @@ -145,7 +174,18 @@ export class BackgroundAgentRunner { taskId: string, emitter: AgentEventEmitter, usage: NonNullable, + filesTouched: Set, + onRound: (round: number) => void, ): void { + emitter.on(AgentEventType.ROUND_START, (event) => { + onRound(event.round); + this.registry.update(taskId, { + metadata: { + currentRound: event.round, + }, + }); + }); + emitter.on(AgentEventType.STREAM_TEXT, (event) => { if (!event.thought && event.text.trim().length > 0) { this.registry.update(taskId, { @@ -155,9 +195,15 @@ export class BackgroundAgentRunner { }); emitter.on(AgentEventType.TOOL_CALL, (event) => { + onRound(event.round); + for (const filePath of extractFilePathsFromArgs(event.args)) { + filesTouched.add(filePath); + } this.registry.update(taskId, { metadata: { + currentRound: event.round, lastToolCall: event.name, + filesTouched: [...filesTouched], }, }); }); @@ -183,10 +229,16 @@ export class BackgroundAgentRunner { const terminateReason = metadata['terminateReason']; const usage = metadata['usage']; const filesTouched = metadata['filesTouched']; + const roundCount = metadata['roundCount']; return { taskId, - status: finalTask.status === 'completed' ? 'completed' : 'failed', + status: + finalTask.status === 'completed' + ? 'completed' + : finalTask.status === 'cancelled' + ? 'cancelled' + : 'failed', finalText: typeof finalText === 'string' ? finalText : undefined, terminateReason: typeof terminateReason === 'string' ? terminateReason : undefined, @@ -194,8 +246,43 @@ export class BackgroundAgentRunner { usage && typeof usage === 'object' ? (usage as BackgroundAgentResult['usage']) : undefined, + roundCount: typeof roundCount === 'number' ? roundCount : undefined, filesTouched: Array.isArray(filesTouched) ? (filesTouched as string[]) : [], error: finalTask.error, }; } } + +function extractFilePathsFromArgs(args: Record): string[] { + const matches = new Set(); + + const visit = (value: unknown, key?: string): void => { + if (typeof value === 'string') { + const normalizedKey = key?.toLowerCase() ?? ''; + if ( + normalizedKey.includes('path') || + normalizedKey.includes('file') || + normalizedKey.includes('target') + ) { + matches.add(value); + } + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + visit(item, key); + } + return; + } + + if (value && typeof value === 'object') { + for (const [nextKey, nextValue] of Object.entries(value as Record)) { + visit(nextValue, nextKey); + } + } + }; + + visit(args); + return [...matches]; +} diff --git a/packages/core/src/background/taskScheduler.ts b/packages/core/src/background/taskScheduler.ts index 6fdfe25fc16..60fe485226c 100644 --- a/packages/core/src/background/taskScheduler.ts +++ b/packages/core/src/background/taskScheduler.ts @@ -6,6 +6,7 @@ import { BackgroundTaskRegistry, + type BackgroundTaskStatus, type BackgroundTaskState, } from './taskRegistry.js'; import { BackgroundTaskDrainer } from './taskDrainer.js'; @@ -18,7 +19,9 @@ export interface ScheduleBackgroundTaskParams { dedupeKey?: string; metadata?: Record; run: (task: BackgroundTaskState) => Promise<{ + status?: BackgroundTaskStatus; progressText?: string; + error?: string; metadata?: Record; } | void>; } @@ -81,8 +84,9 @@ export class BackgroundTaskScheduler { try { const result = await params.run(this.registry.get(task.id) as BackgroundTaskState); const finalTask = this.registry.update(task.id, { - status: 'completed', + status: result?.status ?? 'completed', progressText: result?.progressText, + error: result?.error, metadata: result?.metadata, }); return finalTask; diff --git a/packages/core/src/memory/dreamAgentPlanner.test.ts b/packages/core/src/memory/dreamAgentPlanner.test.ts index 2b56891345c..7894812432b 100644 --- a/packages/core/src/memory/dreamAgentPlanner.test.ts +++ b/packages/core/src/memory/dreamAgentPlanner.test.ts @@ -88,7 +88,11 @@ describe('dreamAgentPlanner', () => { expect.objectContaining({ projectRoot, sessionId: 'session-1', - toolConfig: { tools: [] }, + runConfig: expect.objectContaining({ + max_turns: 4, + max_time_minutes: 2, + }), + toolConfig: { tools: ['read_file'] }, }), ); }); diff --git a/packages/core/src/memory/dreamAgentPlanner.ts b/packages/core/src/memory/dreamAgentPlanner.ts index 13d228f963d..f2043e29890 100644 --- a/packages/core/src/memory/dreamAgentPlanner.ts +++ b/packages/core/src/memory/dreamAgentPlanner.ts @@ -30,7 +30,8 @@ Rules: - Preserve durable information. - Remove duplicates and obvious clutter. - Keep topic headings. -- If a topic has no durable entries, use the standard placeholder: _No entries yet._`; +- If a topic has no durable entries, use the standard placeholder: _No entries yet._ +- You may use read-only tools to inspect topic files when the provided excerpts are insufficient.`; const DREAM_AGENT_RESPONSE_SCHEMA: Record = { type: 'object', @@ -82,6 +83,7 @@ async function buildTopicDocumentBlock(projectRoot: string): Promise { .map((doc) => [ `topic=${doc.type}`, + `path=${doc.filePath}`, `title=${doc.title}`, `description=${doc.description || '(none)'}`, 'body:', @@ -153,14 +155,15 @@ export async function planManagedAutoMemoryDreamByAgent( temp: 0, }, runConfig: { - max_turns: 2, - max_time_minutes: 1, + max_turns: 4, + max_time_minutes: 2, }, toolConfig: { - tools: [], + tools: ['read_file'], }, metadata: { planner: 'dream-agent', + stage: 'agent-b', }, }); diff --git a/packages/core/src/memory/extractionAgentPlanner.test.ts b/packages/core/src/memory/extractionAgentPlanner.test.ts index aaca25bdb43..8e9b39f22fb 100644 --- a/packages/core/src/memory/extractionAgentPlanner.test.ts +++ b/packages/core/src/memory/extractionAgentPlanner.test.ts @@ -72,6 +72,11 @@ describe('planAutoMemoryExtractionPatchesByAgent', () => { expect.objectContaining({ taskType: 'managed-auto-memory-extraction-agent', sessionId: 'session-1', + runConfig: expect.objectContaining({ + max_turns: 4, + max_time_minutes: 2, + }), + toolConfig: { tools: ['read_file'] }, }), ); }); diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index 399a6bda2df..bf283de37c8 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -31,7 +31,8 @@ Rules: - Ignore temporary, session-specific, speculative, or question content. - Use one of the allowed topics: user, feedback, project, reference. - Keep summaries concise and suitable for bullet points. -- Do not include leading bullet markers.`; +- Do not include leading bullet markers. +- You may use read-only tools to inspect topic files when the summaries seem insufficient.`; const EXTRACTION_AGENT_RESPONSE_SCHEMA: Record = { type: 'object', @@ -94,6 +95,7 @@ async function buildTopicSummaryBlock(projectRoot: string): Promise { ); return [ `topic=${doc.type}`, + `path=${doc.filePath}`, `title=${doc.title}`, `description=${doc.description || '(none)'}`, `current=${body || '(empty)'}`, @@ -186,14 +188,15 @@ export async function planAutoMemoryExtractionPatchesByAgent( temp: 0, }, runConfig: { - max_turns: 2, - max_time_minutes: 1, + max_turns: 4, + max_time_minutes: 2, }, toolConfig: { - tools: [], + tools: ['read_file'], }, metadata: { planner: 'extraction-agent', + stage: 'agent-b', }, }); From 63f5392afbbba1c1a2260010f04ba288f0f5e9fa Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 3 Apr 2026 11:42:43 +0800 Subject: [PATCH 19/56] feat(memory): complete managed parity closure --- docs/auto-memory-implementation-report.md | 457 ++++++++++++++++++ docs/auto-memory-work-log.md | 101 ++++ .../cli/src/ui/commands/forgetCommand.test.ts | 60 ++- packages/cli/src/ui/commands/forgetCommand.ts | 49 +- .../cli/src/ui/commands/memoryCommand.test.ts | 115 ++++- packages/cli/src/ui/commands/memoryCommand.ts | 224 ++++++++- packages/core/src/core/client.ts | 17 +- packages/core/src/index.ts | 3 + packages/core/src/memory/dream.test.ts | 38 ++ packages/core/src/memory/dream.ts | 49 +- packages/core/src/memory/entries.test.ts | 38 ++ packages/core/src/memory/entries.ts | 136 ++++++ packages/core/src/memory/extract.test.ts | 39 +- packages/core/src/memory/extract.ts | 95 ++-- .../core/src/memory/extractScheduler.test.ts | 110 +++++ packages/core/src/memory/extractScheduler.ts | 253 ++++++++++ .../core/src/memory/extractionAgentPlanner.ts | 13 + packages/core/src/memory/extractionPlanner.ts | 13 + packages/core/src/memory/forget.test.ts | 52 +- packages/core/src/memory/forget.ts | 273 +++++++++-- packages/core/src/memory/governance.test.ts | 61 +++ packages/core/src/memory/governance.ts | 321 ++++++++++++ packages/core/src/memory/indexer.ts | 10 +- packages/core/src/memory/status.ts | 5 + 24 files changed, 2345 insertions(+), 187 deletions(-) create mode 100644 docs/auto-memory-implementation-report.md create mode 100644 packages/core/src/memory/entries.test.ts create mode 100644 packages/core/src/memory/entries.ts create mode 100644 packages/core/src/memory/extractScheduler.test.ts create mode 100644 packages/core/src/memory/extractScheduler.ts create mode 100644 packages/core/src/memory/governance.test.ts create mode 100644 packages/core/src/memory/governance.ts diff --git a/docs/auto-memory-implementation-report.md b/docs/auto-memory-implementation-report.md new file mode 100644 index 00000000000..44fdada6fd1 --- /dev/null +++ b/docs/auto-memory-implementation-report.md @@ -0,0 +1,457 @@ +# Auto-Memory 功能实现报告 + +## 1. 结论摘要 + +当前实现已经完成设计文档中的 **Part 1–5 全部既定 MVP 范围**,形成了一套可运行、可测试、可观察的 managed auto-memory 子系统,覆盖: + +- 独立存储层:`.qwen/memory/` +- 主提示词集成:`MEMORY.md` 索引并入 `userMemory` +- 查询时相关记忆召回:按 query 注入 relevant memory block +- turn-end 自动提炼:从当前 session transcript 增量抽取 durable memory +- dream/consolidation:手动触发的去重整理 +- CLI 入口:`/memory status`、`/memory extract-now`、`/dream`、`/remember` + +如果以“**设计文档的第一阶段目标**”衡量,当前实现是 **已完成**。 + +如果以“**Claude Code 当前完整 memory system 的实际能力**”衡量,当前实现是 **中高完成度的 MVP**: + +- **已对齐**:taxonomy、独立 memory 目录、显式保存入口、基础 recall、基础 extract、基础 dream、基本命令入口。 +- **未完全对齐**:Claude 的模型驱动 recall、forked extractor、后台 auto-dream 调度、task 可视化、forget/governance 深水区、team/private 双层 memory。 + +## 2. 对比基线 + +### 2.1 设计文档基线 + +设计目标来自 [auto-memory-doc/02-technical-design.md](../auto-memory-doc/02-technical-design.md),核心要求是: + +1. 独立 managed memory 存储层 +2. relevant memory recall +3. turn-end 自动提炼 +4. 周期性 consolidation / dream +5. 保持对 `QWEN.md` / `AGENTS.md` / `save_memory` 的兼容 + +### 2.2 Claude Code 对标基线 + +Claude 当前 memory system 的关键能力,可从以下实现侧看到: + +- taxonomy 与 memory prompt 规则: [src/memdir/memoryTypes.ts](../src/memdir/memoryTypes.ts) +- query 相关记忆选择: [src/memdir/findRelevantMemories.ts](../src/memdir/findRelevantMemories.ts) +- turn-end extractor: [src/services/extractMemories/extractMemories.ts](../src/services/extractMemories/extractMemories.ts) +- background auto-dream: [src/services/autoDream/autoDream.ts](../src/services/autoDream/autoDream.ts) + +## 3. 当前实现概览 + +当前 Qwen 实现位于 memory-worktree,主要落点如下: + +- storage/scaffold: [packages/core/src/memory/store.ts](packages/core/src/memory/store.ts) +- managed index prompt: [packages/core/src/memory/prompt.ts](packages/core/src/memory/prompt.ts) +- topic 扫描: [packages/core/src/memory/scan.ts](packages/core/src/memory/scan.ts) +- recall: [packages/core/src/memory/recall.ts](packages/core/src/memory/recall.ts) +- extraction: [packages/core/src/memory/extract.ts](packages/core/src/memory/extract.ts) +- dream: [packages/core/src/memory/dream.ts](packages/core/src/memory/dream.ts) +- 配置集成: [packages/core/src/config/config.ts](packages/core/src/config/config.ts) +- client 注入/触发: [packages/core/src/core/client.ts](packages/core/src/core/client.ts) +- CLI 命令: [packages/cli/src/ui/commands/memoryCommand.ts](packages/cli/src/ui/commands/memoryCommand.ts)、[packages/cli/src/ui/commands/dreamCommand.ts](packages/cli/src/ui/commands/dreamCommand.ts)、[packages/cli/src/ui/commands/rememberCommand.ts](packages/cli/src/ui/commands/rememberCommand.ts) +- 命令注册: [packages/cli/src/services/BuiltinCommandLoader.ts](packages/cli/src/services/BuiltinCommandLoader.ts) +- 交付记录: [docs/auto-memory-work-log.md](docs/auto-memory-work-log.md) + +## 4. 与设计文档的逐项对比 + +### 4.1 Storage Layer + +**设计要求** + +- 独立 `.qwen/memory/` 目录 +- `MEMORY.md` + topic files + `meta.json` + `extract-cursor.json` +- 与 `QWEN.md` 分离 + +**当前实现** + +- 已实现 `MEMORY.md`、`meta.json`、`extract-cursor.json` +- 已实现 4 个 topic files:`user.md`、`feedback.md`、`project.md`、`reference.md` +- 已保持机器维护内容不写回 `QWEN.md` + +**结论** + +- **已完成,且与文档一致。** + +**说明** + +- taxonomy 已严格对齐 Claude 的 4 类,而没有引入文档中被明确降级为后续扩展的 `workflow` / `debugging` 等分类。 + +### 4.2 Prompt / Compatibility Layer + +**设计要求** + +- 与 `QWEN.md` / `AGENTS.md` / `save_memory` 兼容 +- managed memory 以低侵入方式并入现有 prompt 体系 + +**当前实现** + +- `refreshHierarchicalMemory()` 会在原有 hierarchical memory 基础上追加 managed index +- 现有 `save_memory` 行为未被破坏 +- 原有 `QWEN.md` / `AGENTS.md` 发现逻辑未修改 + +**结论** + +- **已完成。** + +### 4.3 Recall Layer + +**设计要求** + +- 扫描 memory 元数据 +- 基于 query 做相关性筛选 +- 构造注入 prompt +- 控制 token 成本 + +**当前实现** + +- 已实现 topic 文件扫描与 frontmatter 解析 +- 已实现 query-token + type-keyword 的启发式评分选择 +- 已在 `UserQuery` 请求路径下注入 `Relevant Managed Auto-Memory` block +- 已对单文档注入体做截断 + +**结论** + +- **MVP 已完成。** + +**与文档差异** + +- 文档没有强制要求必须模型驱动;当前实现采用启发式检索,风险更低,但召回质量上限低于 Claude。 + +### 4.4 Extraction Layer + +**设计要求** + +- turn-end 异步触发 +- 基于 transcript 增量提炼 +- 幂等与 cursor 控制 +- 可独立关闭/回滚 + +**当前实现** + +- 已在完成 `UserQuery` 后触发 extraction +- 已用 `extract-cursor.json` 维护 session-aware 增量游标 +- 已实现同进程并发保护 +- 已做 topic file 幂等追加与 metadata 更新时间维护 + +**结论** + +- **MVP 已完成。** + +**与文档差异** + +- 文档中更理想的方案是 headless extractor agent + structured memory patch。 +- 当前实现是 **host 侧启发式抽取**,不是独立 extractor agent。 +- 因此当前版本在复杂总结、跨消息归纳、why/how 提炼方面弱于文档理想态。 + +### 4.5 Dream Layer + +**设计要求** + +- 周期性 consolidation +- 去重、重组、更新索引 +- 并发锁与状态管理 + +**当前实现** + +- 已实现手动触发的 dream primitive +- 已实现 topic file bullet 去重、排序、占位恢复、metadata bump + +**结论** + +- **MVP 部分完成。** + +**未达成点** + +- 尚未实现自动调度 +- 尚未实现 consolidation lock 文件 +- 尚未实现模型驱动重写/提纯/重组 +- 尚未重写 `MEMORY.md` 索引摘要 + +### 4.6 Control / Observability Layer + +**设计要求** + +- lock +- cursor +- debug 日志 +- CLI 命令 +- task / governance 可观测性 +- richer durable schema + +**当前实现** + +- 已实现 cursor +- 已实现 consolidation lock、dream/extraction task registry 与 CLI 可视化 +- 已实现 `/memory status`、`/memory tasks`、`/memory inspect`、`/memory review`、`/memory forget`、`/forget` +- 已通过 system message、task timeline 与 governance review 暴露 memory 更新结果 +- 已引入 `why` / `howToApply` / `stability` richer schema + +**结论** + +- **大部完成。** + +**缺口** + +- task UI 仍可继续向更完整的交互式观察体验打磨 +- team/private 双层 memory 仍未纳入当前范围 + +## 5. 与 Claude Code memory system 的对比 + +### 5.1 已基本对齐的能力 + +#### 1) Memory taxonomy + +Qwen 当前实现使用 `user` / `feedback` / `project` / `reference` 四类,已与 Claude 的 taxonomy 对齐,符合 [src/memdir/memoryTypes.ts](../src/memdir/memoryTypes.ts) 的实际定义。 + +#### 2) 独立 managed memory 存储 + +Claude 将 auto-memory 独立于人工维护 memory 文件;Qwen 当前也已做到这一点,避免把机器维护内容继续塞入 `QWEN.md`。 + +#### 3) 显式记忆写入入口 + +Claude 支持在主对话里直接 remember;Qwen 当前通过 `/remember` 和既有 `save_memory` 工具实现了同类显式入口。 + +#### 4) 查询时 relevant recall + +两边都在主请求前追加相关记忆,而不是把所有 topic 文件完整灌入 prompt。 + +### 5.2 部分对齐、但实现深度不同的能力 + +#### 1) Recall 选择机制 + +Claude 的 recall 选择是 **side-query + 模型判定**,见 [src/memdir/findRelevantMemories.ts](../src/memdir/findRelevantMemories.ts)。 + +Qwen 当前是 **启发式 token/keyword 评分**。 + +影响: + +- 优点:更稳定、成本更低、实现风险更小 +- 缺点:语义召回能力弱,难处理隐式关联和复杂描述 + +#### 2) Extraction 实现形态 + +Claude 的 extractor 是 **forked agent**,具备: + +- 只读探索 + memory 目录限域写入 +- 依据 prompt 进行结构化提炼 +- 主 agent 已经写 memory 时可跳过 extractor +- 正在运行时支持 trailing run / stash 行为 + +对应实现见 [src/services/extractMemories/extractMemories.ts](../src/services/extractMemories/extractMemories.ts)。 + +Qwen 当前 extraction 是 **本地启发式规则抽取**。 + +影响: + +- 优点:轻量、可预测、容易验证 +- 缺点:对复杂对话、跨 turn 归纳、why/how 结构化沉淀明显不如 Claude + +#### 3) Dream 实现形态 + +Claude 的 dream 是 **后台 forked consolidation agent**,并带有: + +- 时间门限 +- session 数门限 +- consolidation lock +- background dream task +- progress watcher +- 完成/失败/中止状态流转 + +对应实现见 [src/services/autoDream/autoDream.ts](../src/services/autoDream/autoDream.ts)。 + +Qwen 当前 dream 仅为 **手动 dedupe/normalize**。 + +影响: + +- 当前只能算 dream 的低风险占位版 +- 还不具备 Claude 的后台整理能力和任务可视化能力 + +### 5.3 尚未对齐的能力 + +#### 1) 自动后台 dream 调度 + +Claude 有 stop-hook/background housekeeping 驱动的 auto-dream;Qwen 当前无自动调度。 + +#### 2) Dream / Extract 任务可视化 + +Claude 有 `DreamTask` 等任务态展示;Qwen 当前只有简要 system message / CLI 结果文本。 + +#### 3) Forget / memory 治理闭环 + +Claude 的“完整记忆系统”不仅有 remember,也强调治理、审查、整理。 + +Qwen 当前: + +- 有 `/memory show`、`/memory status` +- 有 `/remember` +- 有 `/dream` +- **但没有显式 `/forget`** +- **也没有更深入的 memory 审查/提升/迁移工作流** + +#### 4) Team/private 双层 memory + +Claude 代码里已经考虑 private/team 语义;Qwen 当前第一阶段未覆盖,这与设计文档非目标一致,不算偏航,但属于 Claude parity 未完成项。 + +## 6. 功能完成度判断 + +### 6.1 按设计文档第一阶段判断 + +| 领域 | 结论 | +| --- | --- | +| 存储 scaffold | 完成 | +| 索引 prompt 集成 | 完成 | +| relevant recall | 完成(MVP) | +| turn-end extraction | 完成(MVP) | +| dream primitive | 部分完成 | +| CLI 入口 | 完成(MVP) | +| 兼容 `QWEN.md` / `AGENTS.md` / `save_memory` | 完成 | + +总体判断:**文档第一阶段目标已完成。** + +### 6.2 按 Claude Code parity 判断 + +| 领域 | 完成度 | +| --- | --- | +| taxonomy / storage 形态 | 高 | +| prompt 集成与兼容性 | 高 | +| explicit remember | 高 | +| recall 能力深度 | 中 | +| extraction 能力深度 | 中 | +| dream 能力深度 | 低到中 | +| memory 治理与审查 | 中偏低 | +| 后台任务与状态可视化 | 低 | + +总体判断:**已达到 Claude 风格 memory system 的 MVP 骨架,但还未达到 Claude 当前实现深度。** + +## 7. 已完成验证 + +根据 [docs/auto-memory-work-log.md](docs/auto-memory-work-log.md) 的记录,当前实现已完成: + +- core 定向测试 +- core 回归测试 +- cli 定向测试 +- core / cli typecheck +- 工作日志记录 +- 分阶段提交 + +关键提交: + +- Part 4:`a5b6683f8` — `feat(core): add managed auto-memory extraction` +- Part 5:`eefd3e9d0` — `feat(cli): add managed auto-memory dream commands` + +## 8. 主要缺口与后续建议 + +### 优先级 P1 + +1. 将 extraction 从启发式规则升级为模型驱动 extractor +2. 为 dream 增加自动调度、锁和最小状态记录 +3. 重写 `MEMORY.md`,让它真正反映 topic 文件摘要,而不只是 scaffold index + +### 优先级 P2 + +1. 增加 `/forget` 或等效治理入口 +2. 为 `/memory` 增加更完整的 topic 审查/编辑/迁移视图 +3. 为 durable memory 引入更丰富的结构化 schema 与治理辅助信息 + +### 优先级 P3 + +1. 引入 task 级可视化或后台状态面板 +2. 评估 team/private 双层 memory +3. 继续打磨治理建议的交互体验与执行闭环 + +## 9. 最终判断 + +当前实现不是“半成品”,而是一个 **已经闭环的第一阶段交付**: + +- 有存储 +- 有注入 +- 有召回 +- 有自动提炼 +- 有手动整理 +- 有命令入口 +- 有测试验证 + +但如果目标是 **严格追平 Claude Code 当前 memory system 的行为深度和运营能力**,那么当前仍属于: + +**结构完成、能力可用、深度未完全追平。** + +更准确地说: + +- **对设计文档:已完成第一阶段目标** +- **对 Claude Code:已完成 MVP 级别对标,未完成 full parity** + +## 10. 当前版本相对 Claude Code 的对齐情况 + +> 本章基于当前 `feat/auto-memory` 分支的最新实现状态补充。若与前文较早阶段的判断存在差异,以本章为准。 + +### 10.1 当前已经完成或基本对齐的内容 + +1. **memory taxonomy 与 managed 存储形态** + - 已采用 `user` / `feedback` / `project` / `reference` 四类 taxonomy。 + - 已维护独立 `.qwen/memory/` 目录,包含 `MEMORY.md`、topic files、`meta.json`、`extract-cursor.json` 与 `consolidation.lock`。 + +2. **recall 主链路** + - 已实现模型驱动 relevance selector。 + - 已保留 heuristic fallback。 + - 已实现 surfaced memory 的会话级去重。 + - 在“主请求前注入 relevant memories”这一能力形态上,已与 Claude Code 基本对齐。 + +3. **extraction 主链路** + - 已从单纯规则抽取升级为:agent planner → side-query planner → heuristic fallback。 + - agent stage B 已支持受限 `read_file`、多轮预算、`filesTouched`、`roundCount` 与 cancelled 状态。 + - 说明 Qwen 已进入模型驱动 extraction 阶段。 + +4. **dream / consolidation 主链路** + - 已具备 auto-dream 调度、时间/会话门限、`consolidation.lock`、background task registry、agent-first planner 与 mechanical fallback。 + - 这意味着 Qwen 已不再只是手动 `/dream` 的占位实现。 + +5. **动态 `MEMORY.md` 索引** + - `MEMORY.md` 已会随 extraction、dream、forget 自动重写。 + - 已与 Claude 把 `MEMORY.md` 作为“索引入口”而非正文存储位的设计基本一致。 + +6. **governance 基础入口** + - 当前已提供 `/memory status`、`/memory tasks`、`/memory inspect`、`/memory forget`、`/forget`、`/dream`、`/remember`。 + - 已经具备基础治理入口,而不只是写入入口。 + +7. **通用 runtime 基础设施** + - 已具备 shared side-query、background task runtime、`BackgroundAgentRunner`。 + - 从结构分层看,Qwen 已补齐 Claude memory system 很大一部分公共底座。 + +8. **extraction 完整后台生命周期** + - 已补齐 extraction runtime、trailing queue、pending drain、save_memory 同轮 skip 与 extraction task tracking。 + - 在 turn-end 提炼的后台语义上,已明显接近 Claude 的运行形态。 + +9. **task UI 深度可视化** + - `/memory status` 与 `/memory tasks` 已展示 extraction / dream 双 lane、timeline、progressText 与关键 metadata。 + - 任务可观测性已不再停留在简单 system message 层面。 + +10. **治理建议流与 richer schema** + - 已新增 governance review,支持 duplicate / conflict / outdated / promote / migrate / forget suggestion。 + - 已补齐 durable entry richer schema:`why`、`howToApply`、`stability`,并打通 extract / dream / index / forget 链路。 + +11. **模型辅助 forget candidate 选择** + - `/forget` 与 `/memory forget` 已升级为 preview-first + `--apply` 确认流。 + - forget 候选选择已支持 side-query / heuristic 双路径,而不再只是直接 substring 删除。 + +### 10.2 当前仍等待与 Claude Code 进一步对齐的部分 + +1. **team/private 双层 memory** + - 当前仍以单层 managed memory 为主,未完成更复杂的 scope 语义。 + - 这一项仍属于 Claude 更复杂的 memory scope 语义,不在当前 Qwen Code 目标范围内。 + +### 10.3 现阶段总体判断 + +如果只看“第一阶段 MVP 是否完成”,答案已经是 **完成**。 + +如果看“当前版本相对 Claude Code 到底完成了什么”,更准确的判断是: + +- **主干结构:已基本对齐** +- **主要链路:除 team/private scope 外已基本具备对应能力形态** +- **执行深度与治理成熟度:已进入可用且可治理阶段** + +可以把当前状态概括为:结构对齐高,主能力链路对齐高,治理成熟度中高,任务可视化/UI 深度中高;若排除 team/private scope,已接近 Claude 当前 memory system 的主要能力面。 + +因此,Qwen 当前已经不再只是“memory MVP 原型”,而是进入了“**从结构对齐走向深度对齐**”的阶段。 diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index cb901c6adf0..9f138c3f6f7 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -62,6 +62,107 @@ Completed --- +## Part 18 - Extraction runtime lifecycle and task timelines + +### Start review + +- Overall parity work now focuses on the remaining Claude-aligned execution details rather than the basic memory backbone. +- Previous parts already delivered model-driven recall/extract/dream and governance entrypoints, but extraction still lacked a fuller background lifecycle and the CLI only exposed limited task visibility. +- Scope for this part: move extraction onto a tracked runtime with trailing semantics and surface richer extraction/dream task state through `/memory status` and `/memory tasks`. + +### Goal + +- Add a managed extraction runtime with queued trailing execution and drain support +- Skip extraction when the same turn already used `save_memory` +- Track extraction tasks alongside dream tasks in managed-memory status +- Expand CLI task views with extraction/dream timelines and metadata summaries + +### Implemented + +- Added `packages/core/src/memory/extractScheduler.ts` +- Added `packages/core/src/memory/extractScheduler.test.ts` +- Updated `packages/core/src/memory/extract.ts` to delegate scheduling to the extraction runtime +- Updated `packages/core/src/memory/status.ts` to expose `extractionTasks` +- Updated `packages/cli/src/ui/commands/memoryCommand.ts` with dual-lane task summaries and timelines +- Updated `packages/cli/src/ui/commands/memoryCommand.test.ts` with extraction timeline coverage + +### Functional verification + +- Managed extraction now supports trailing queued execution instead of only returning a coarse already-running skip. +- The runtime detects `save_memory` activity in the same slice and skips redundant extraction safely. +- `/memory status` and `/memory tasks` now show extraction and dream task counts, timelines, progress text, and key metadata such as trailing state and touched topics. + +### Test verification + +- Passed targeted tests: + - `cd packages/core && npx vitest run src/memory/extractScheduler.test.ts src/memory/extract.test.ts src/memory/status.test.ts src/core/client.test.ts` + - `cd packages/cli && npx vitest run src/ui/commands/memoryCommand.test.ts` + +### Notes + +- This part keeps extraction writes host-side; the new runtime improves lifecycle semantics and observability without changing the underlying patch-application ownership. + +### Status + +Completed + +--- + +## Part 19 - Governance review, preview-first forget, and richer schema + +### Start review + +- After closing extraction lifecycle and task visibility, the remaining non-team/private parity work concentrated on governance maturity. +- The system still needed structured durable entries, deeper governance suggestions, and a safer preview-first forget workflow. +- Scope for this part: add richer durable-entry schema, model-assisted forget candidate selection, and governance review suggestions end to end. + +### Goal + +- Add a structured durable-entry representation with `why`, `howToApply`, and `stability` +- Preserve that schema across extraction, dream, indexing, and forget flows +- Add governance review suggestions with heuristic/model paths +- Upgrade `/forget` and `/memory forget` to preview-first candidate selection with `--apply` + +### Implemented + +- Added `packages/core/src/memory/entries.ts` +- Added `packages/core/src/memory/entries.test.ts` +- Added `packages/core/src/memory/governance.ts` +- Added `packages/core/src/memory/governance.test.ts` +- Updated `packages/core/src/memory/extract.ts`, `dream.ts`, `indexer.ts`, `forget.ts`, `extractionPlanner.ts`, and `extractionAgentPlanner.ts` to use structured durable entries +- Updated `packages/cli/src/ui/commands/memoryCommand.ts` with `/memory review` and preview-first `/memory forget` +- Updated `packages/cli/src/ui/commands/forgetCommand.ts` with preview-first `/forget` +- Updated CLI/core tests for richer schema, governance review, and preview/apply forget flows +- Removed the previously started auto-memory feature-flag wiring after confirming that feature flags are not part of Qwen Code's target scope + +### Functional verification + +- Durable managed-memory entries can now carry `why`, `howToApply`, and `stability`, and those fields survive extraction, dedupe, indexing, inspection, and deletion. +- Governance review can now surface duplicate, conflict, outdated, promote, migrate, and forget suggestions for manual inspection. +- `/forget` and `/memory forget` now preview selected removal candidates first, then require `--apply` for the actual mutation path. + +### Test verification + +- Passed targeted tests: + - `cd packages/core && npx vitest run src/memory/entries.test.ts src/memory/extractScheduler.test.ts src/memory/extract.test.ts src/memory/dream.test.ts src/memory/forget.test.ts src/memory/governance.test.ts src/memory/status.test.ts src/core/client.test.ts` + - `cd packages/core && npx vitest run src/memory/extractionPlanner.test.ts src/memory/extractionAgentPlanner.test.ts src/memory/extractAgent.test.ts src/memory/extractModel.test.ts src/memory/indexer.test.ts src/memory/dreamScheduler.test.ts` + - `cd packages/cli && npx vitest run src/ui/commands/forgetCommand.test.ts src/ui/commands/memoryCommand.test.ts src/services/BuiltinCommandLoader.test.ts` +- Passed build / typecheck verification: + - `npm run typecheck --workspace=packages/core` + - `npm run build --workspace=packages/core` + - `npm run typecheck --workspace=packages/cli` + +### Notes + +- Feature flags were explicitly removed from scope during this stage and are therefore not part of the delivered parity set. +- The only major Claude-side item still intentionally out of scope is team/private multi-scope memory. + +### Status + +Completed + +--- + ## Part 13 - Dream agent consumer stage A ### Start review diff --git a/packages/cli/src/ui/commands/forgetCommand.test.ts b/packages/cli/src/ui/commands/forgetCommand.test.ts index 0b9d85795d3..fae46f96a0b 100644 --- a/packages/cli/src/ui/commands/forgetCommand.test.ts +++ b/packages/cli/src/ui/commands/forgetCommand.test.ts @@ -5,12 +5,16 @@ */ import { describe, expect, it, vi } from 'vitest'; -import { forgetManagedAutoMemoryEntries } from '@qwen-code/qwen-code-core'; +import { + forgetManagedAutoMemoryMatches, + selectManagedAutoMemoryForgetCandidates, +} from '@qwen-code/qwen-code-core'; import { forgetCommand } from './forgetCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; vi.mock('@qwen-code/qwen-code-core', () => ({ - forgetManagedAutoMemoryEntries: vi.fn(), + forgetManagedAutoMemoryMatches: vi.fn(), + selectManagedAutoMemoryForgetCandidates: vi.fn(), })); describe('forgetCommand', () => { @@ -19,13 +23,42 @@ describe('forgetCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Usage: /forget ', + content: 'Usage: /forget [--apply] ', }); }); - it('removes matching managed auto-memory entries', async () => { - vi.mocked(forgetManagedAutoMemoryEntries).mockResolvedValue({ - query: 'terse', + it('previews matching managed auto-memory entries by default', async () => { + vi.mocked(selectManagedAutoMemoryForgetCandidates).mockResolvedValue({ + strategy: 'model', + reasoning: 'Best semantic match.', + matches: [{ topic: 'user', summary: 'User prefers terse responses.' }], + }); + + const result = await forgetCommand.action?.( + createMockCommandContext({ + services: { + config: { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }), + 'terse', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: ['Forget preview (strategy=model):', 'Best semantic match.', '1. user: User prefers terse responses.', '', 'Run /forget --apply terse to apply these removals.'].join('\n'), + }); + }); + + it('removes matching managed auto-memory entries after --apply confirmation', async () => { + vi.mocked(selectManagedAutoMemoryForgetCandidates).mockResolvedValue({ + strategy: 'heuristic', + matches: [{ topic: 'user', summary: 'User prefers terse responses.' }], + }); + vi.mocked(forgetManagedAutoMemoryMatches).mockResolvedValue({ + query: '', removedEntries: [{ topic: 'user', summary: 'User prefers terse responses.' }], touchedTopics: ['user'], systemMessage: 'Managed auto-memory forgot 1 entry from user.md', @@ -39,10 +72,21 @@ describe('forgetCommand', () => { }, }, }), - 'terse', + '--apply terse', ); - expect(forgetManagedAutoMemoryEntries).toHaveBeenCalledWith('/test/project', 'terse'); + expect(selectManagedAutoMemoryForgetCandidates).toHaveBeenCalledWith( + '/test/project', + 'terse', + { + config: expect.objectContaining({ + getProjectRoot: expect.any(Function), + }), + }, + ); + expect(forgetManagedAutoMemoryMatches).toHaveBeenCalledWith('/test/project', [ + { topic: 'user', summary: 'User prefers terse responses.' }, + ]); expect(result).toEqual({ type: 'message', messageType: 'info', diff --git a/packages/cli/src/ui/commands/forgetCommand.ts b/packages/cli/src/ui/commands/forgetCommand.ts index 0fa9d936397..dfeb765052a 100644 --- a/packages/cli/src/ui/commands/forgetCommand.ts +++ b/packages/cli/src/ui/commands/forgetCommand.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { forgetManagedAutoMemoryEntries } from '@qwen-code/qwen-code-core'; +import { + forgetManagedAutoMemoryMatches, + selectManagedAutoMemoryForgetCandidates, +} from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; @@ -16,12 +19,16 @@ export const forgetCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, action: async (context, args) => { - const query = args.trim(); + const trimmedArgs = args.trim(); + const apply = trimmedArgs.startsWith('--apply '); + const query = apply + ? trimmedArgs.slice('--apply '.length).trim() + : trimmedArgs; if (!query) { return { type: 'message', messageType: 'error', - content: t('Usage: /forget '), + content: t('Usage: /forget [--apply] '), }; } @@ -34,9 +41,43 @@ export const forgetCommand: SlashCommand = { }; } - const result = await forgetManagedAutoMemoryEntries( + if (!apply) { + const selection = await selectManagedAutoMemoryForgetCandidates( + config.getProjectRoot(), + query, + { config }, + ); + return { + type: 'message', + messageType: 'info', + content: + selection.matches.length > 0 + ? [ + t('Forget preview (strategy={{strategy}}):', { + strategy: selection.strategy, + }), + ...(selection.reasoning ? [selection.reasoning] : []), + ...selection.matches.map( + (match, index) => + `${index + 1}. ${match.topic}: ${match.summary}`, + ), + '', + t('Run /forget --apply {{query}} to apply these removals.', { + query, + }), + ].join('\n') + : t('No managed auto-memory entries matched: {{query}}', { query }), + }; + } + + const selection = await selectManagedAutoMemoryForgetCandidates( config.getProjectRoot(), query, + { config }, + ); + const result = await forgetManagedAutoMemoryMatches( + config.getProjectRoot(), + selection.matches, ); return { type: 'message', diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 22fed96ced0..babf1880582 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -18,10 +18,12 @@ import { AUTO_MEMORY_TYPES, getErrorMessage, getManagedAutoMemoryStatus, - forgetManagedAutoMemoryEntries, + forgetManagedAutoMemoryMatches, loadServerHierarchicalMemory, QWEN_DIR, + reviewManagedAutoMemoryGovernance, scheduleAutoMemoryExtract, + selectManagedAutoMemoryForgetCandidates, setGeminiMdFilename, type FileDiscoveryService, type LoadServerHierarchicalMemoryResponse, @@ -37,9 +39,11 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { return String(error); }), getManagedAutoMemoryStatus: vi.fn(), - forgetManagedAutoMemoryEntries: vi.fn(), + forgetManagedAutoMemoryMatches: vi.fn(), loadServerHierarchicalMemory: vi.fn(), + reviewManagedAutoMemoryGovernance: vi.fn(), scheduleAutoMemoryExtract: vi.fn(), + selectManagedAutoMemoryForgetCandidates: vi.fn(), }; }); @@ -56,13 +60,15 @@ vi.mock('node:fs/promises', () => { const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock; const mockScheduleAutoMemoryExtract = scheduleAutoMemoryExtract as Mock; const mockGetManagedAutoMemoryStatus = getManagedAutoMemoryStatus as Mock; -const mockForgetManagedAutoMemoryEntries = forgetManagedAutoMemoryEntries as Mock; +const mockForgetManagedAutoMemoryMatches = forgetManagedAutoMemoryMatches as Mock; +const mockReviewManagedAutoMemoryGovernance = reviewManagedAutoMemoryGovernance as Mock; +const mockSelectManagedAutoMemoryForgetCandidates = selectManagedAutoMemoryForgetCandidates as Mock; const mockReadFile = readFile as unknown as Mock; describe('memoryCommand', () => { let mockContext: CommandContext; - const getSubCommand = (name: 'show' | 'add' | 'refresh' | 'status' | 'tasks' | 'inspect' | 'forget'): SlashCommand => { + const getSubCommand = (name: 'show' | 'add' | 'refresh' | 'status' | 'tasks' | 'inspect' | 'review' | 'forget'): SlashCommand => { const subCommand = memoryCommand.subCommands?.find( (cmd) => cmd.name === name, ); @@ -329,6 +335,7 @@ describe('memoryCommand', () => { lastDreamTouchedTopics: [], }, extractionRunning: false, + extractionTasks: [], dreamTasks: [], topics: AUTO_MEMORY_TYPES.map((topic) => ({ topic, @@ -352,6 +359,7 @@ describe('memoryCommand', () => { expect(text).toContain('Cursor: session=session-1, offset=3'); expect(text).toContain('- user.md: 2 entries'); expect(text).toContain('Extraction: running=no'); + expect(text).toContain('Extraction tasks: active=0, tracked=0'); expect(text).toContain('Dream: last=2026-04-01T01:00:00.000Z'); }); }); @@ -377,6 +385,23 @@ describe('memoryCommand', () => { indexPath: '/test/project/.qwen/memory/MEMORY.md', indexContent: '', extractionRunning: true, + extractionTasks: [ + { + id: 'extract-1', + taskType: 'managed-auto-memory-extraction', + title: 'Managed auto-memory extraction', + projectRoot: '/test/project', + status: 'pending', + createdAt: '2026-04-01T00:00:00.000Z', + updatedAt: '2026-04-01T00:00:10.000Z', + progressText: 'Queued trailing extraction', + metadata: { + trailing: true, + queuedBehindTaskId: 'extract-0', + historyLength: 6, + }, + }, + ], topics: [], dreamTasks: [ { @@ -395,8 +420,12 @@ describe('memoryCommand', () => { await tasksCommand.action?.(mockContext, ''); const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; - expect(text).toContain('extraction: running'); - expect(text).toContain('dream dream-1: running'); + expect(text).toContain('extraction lane: running | active=1 | tracked=1'); + expect(text).toContain('Extraction timeline:'); + expect(text).toContain('extract-1: pending'); + expect(text).toContain('trailing=yes'); + expect(text).toContain('Dream timeline:'); + expect(text).toContain('dream-1: running'); }); }); @@ -422,6 +451,7 @@ describe('memoryCommand', () => { indexPath: '/test/project/.qwen/memory/MEMORY.md', indexContent: '# Managed Auto-Memory Index\n\n- hook', extractionRunning: false, + extractionTasks: [], topics: [], dreamTasks: [], }); @@ -442,6 +472,7 @@ describe('memoryCommand', () => { indexPath: '/test/project/.qwen/memory/MEMORY.md', indexContent: '# Managed Auto-Memory Index', extractionRunning: false, + extractionTasks: [], topics: [], dreamTasks: [], }); @@ -458,6 +489,42 @@ describe('memoryCommand', () => { }); }); + describe('/memory review', () => { + let reviewCommand: SlashCommand; + + beforeEach(() => { + reviewCommand = getSubCommand('review'); + mockReviewManagedAutoMemoryGovernance.mockReset(); + mockContext = createMockCommandContext({ + services: { + config: { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }); + }); + + it('shows governance review suggestions', async () => { + mockReviewManagedAutoMemoryGovernance.mockResolvedValue({ + strategy: 'heuristic', + suggestions: [ + { + type: 'promote', + topic: 'user', + summary: 'User prefers terse responses.', + rationale: 'Needs richer metadata.', + }, + ], + }); + + await reviewCommand.action?.(mockContext, ''); + + const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; + expect(text).toContain('governance review'); + expect(text).toContain('[promote] user: User prefers terse responses.'); + }); + }); + describe('/memory extract-now', () => { let extractCommand: SlashCommand; @@ -509,7 +576,8 @@ describe('memoryCommand', () => { beforeEach(() => { forgetCommand = getSubCommand('forget'); - mockForgetManagedAutoMemoryEntries.mockReset(); + mockForgetManagedAutoMemoryMatches.mockReset(); + mockSelectManagedAutoMemoryForgetCandidates.mockReset(); mockContext = createMockCommandContext({ services: { config: { @@ -524,23 +592,42 @@ describe('memoryCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Usage: /memory forget ', + content: 'Usage: /memory forget [--apply] ', }); }); - it('forgets matching managed memory entries', async () => { - mockForgetManagedAutoMemoryEntries.mockResolvedValue({ - query: 'terse', + it('previews matching managed memory entries by default', async () => { + mockSelectManagedAutoMemoryForgetCandidates.mockResolvedValue({ + strategy: 'model', + reasoning: 'Best semantic match.', + matches: [{ topic: 'user', summary: 'User prefers terse responses.' }], + }); + + await forgetCommand.action?.(mockContext, 'terse'); + + const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; + expect(text).toContain('Forget preview (strategy=model):'); + expect(text).toContain('1. user: User prefers terse responses.'); + expect(text).toContain('/memory forget --apply terse'); + }); + + it('forgets matching managed memory entries after --apply confirmation', async () => { + mockSelectManagedAutoMemoryForgetCandidates.mockResolvedValue({ + strategy: 'heuristic', + matches: [{ topic: 'user', summary: 'User prefers terse responses.' }], + }); + mockForgetManagedAutoMemoryMatches.mockResolvedValue({ + query: '', removedEntries: [{ topic: 'user', summary: 'User prefers terse responses.' }], touchedTopics: ['user'], systemMessage: 'Managed auto-memory forgot 1 entry from user.md', }); - await forgetCommand.action?.(mockContext, 'terse'); + await forgetCommand.action?.(mockContext, '--apply terse'); - expect(mockForgetManagedAutoMemoryEntries).toHaveBeenCalledWith( + expect(mockForgetManagedAutoMemoryMatches).toHaveBeenCalledWith( '/test/project', - 'terse', + [{ topic: 'user', summary: 'User prefers terse responses.' }], ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 8a076047b4d..ff0c51bc1bd 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -8,12 +8,14 @@ import { AUTO_MEMORY_TYPES, getErrorMessage, getManagedAutoMemoryStatus, - forgetManagedAutoMemoryEntries, + forgetManagedAutoMemoryMatches, getAutoMemoryTopicPath, getAllGeminiMdFilenames, loadServerHierarchicalMemory, QWEN_DIR, + reviewManagedAutoMemoryGovernance, scheduleAutoMemoryExtract, + selectManagedAutoMemoryForgetCandidates, } from '@qwen-code/qwen-code-core'; import path from 'node:path'; import os from 'node:os'; @@ -23,6 +25,128 @@ import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; +interface TaskLike { + id: string; + status: string; + updatedAt: string; + progressText?: string; + metadata?: Record; +} + +function summarizeTaskMetadata(task: TaskLike): string { + const metadata = task.metadata ?? {}; + const parts: string[] = []; + + if (Array.isArray(metadata['touchedTopics']) && metadata['touchedTopics'].length > 0) { + parts.push(`topics=${(metadata['touchedTopics'] as string[]).join(',')}`); + } + if (typeof metadata['patchCount'] === 'number') { + parts.push(`patches=${String(metadata['patchCount'])}`); + } + if (typeof metadata['dedupedEntries'] === 'number') { + parts.push(`deduped=${String(metadata['dedupedEntries'])}`); + } + if (typeof metadata['queuedBehindTaskId'] === 'string') { + parts.push(`behind=${metadata['queuedBehindTaskId']}`); + } + if (typeof metadata['skippedReason'] === 'string') { + parts.push(`skip=${metadata['skippedReason']}`); + } + if (metadata['trailing'] === true) { + parts.push('trailing=yes'); + } + if (typeof metadata['historyLength'] === 'number') { + parts.push(`history=${String(metadata['historyLength'])}`); + } + if (typeof metadata['roundCount'] === 'number') { + parts.push(`rounds=${String(metadata['roundCount'])}`); + } + if (typeof metadata['filesTouched'] === 'number') { + parts.push(`files=${String(metadata['filesTouched'])}`); + } + + return parts.join(' | '); +} + +function countActiveTasks(tasks: TaskLike[]): number { + return tasks.filter( + (task) => task.status === 'pending' || task.status === 'running', + ).length; +} + +function buildTaskTimeline(label: string, tasks: TaskLike[]): string[] { + if (tasks.length === 0) { + return [`${label}: none`]; + } + + return [ + `${label}:`, + ...tasks.map((task) => { + const metadataSummary = summarizeTaskMetadata(task); + return `- ${task.id}: ${task.status} | updated=${task.updatedAt}${task.progressText ? ` | ${task.progressText}` : ''}${metadataSummary ? ` | ${metadataSummary}` : ''}`; + }), + ]; +} + +async function buildManagedMemoryReview( + projectRoot: string, + config?: { + getBaseLlmClient?: () => unknown; + }, +): Promise { + const review = await reviewManagedAutoMemoryGovernance(projectRoot, { + config: config as never, + }); + + if (review.suggestions.length === 0) { + return t('Managed auto-memory governance review found no strong suggestions.'); + } + + return [ + t('Managed auto-memory governance review (strategy={{strategy}}):', { + strategy: review.strategy, + }), + ...review.suggestions.map((suggestion, index) => { + const related = suggestion.relatedSummary + ? ` | related=${suggestion.relatedTopic}:${suggestion.relatedSummary}` + : ''; + const target = suggestion.suggestedTargetTopic + ? ` | target=${suggestion.suggestedTargetTopic}` + : ''; + return `${index + 1}. [${suggestion.type}] ${suggestion.topic}: ${suggestion.summary}${related}${target} | ${suggestion.rationale}`; + }), + ].join('\n'); +} + +async function buildForgetPreview( + projectRoot: string, + query: string, + applyCommand: string, + config?: { + getBaseLlmClient?: () => unknown; + }, +): Promise { + const selection = await selectManagedAutoMemoryForgetCandidates( + projectRoot, + query, + { config: config as never }, + ); + + if (selection.matches.length === 0) { + return t('No managed auto-memory entries matched: {{query}}', { query }); + } + + return [ + t('Forget preview (strategy={{strategy}}):', { strategy: selection.strategy }), + ...(selection.reasoning ? [selection.reasoning] : []), + ...selection.matches.map( + (match, index) => `${index + 1}. ${match.topic}: ${match.summary}`, + ), + '', + t('Run {{command}} to apply these removals.', { command: applyCommand }), + ].join('\n'); +} + async function buildManagedMemoryStatus(projectRoot: string): Promise { const status = await getManagedAutoMemoryStatus(projectRoot); @@ -54,15 +178,23 @@ async function buildManagedMemoryStatus(projectRoot: string): Promise { last: status.metadata?.lastDreamAt || 'n/a', status: status.metadata?.lastDreamStatus || 'n/a', touched: status.metadata?.lastDreamTouchedTopics?.join(', ') || 'none', - activeTasks: String( - status.dreamTasks.filter( - (task: { status: string }) => - task.status === 'pending' || task.status === 'running', - ).length, - ), + activeTasks: String(countActiveTasks(status.dreamTasks)), }, ); + const extractionTaskSummary = t( + 'Extraction tasks: active={{active}}, tracked={{tracked}}', + { + active: String(countActiveTasks(status.extractionTasks)), + tracked: String(status.extractionTasks.length), + }, + ); + + const dreamTaskSummary = t('Dream tasks: active={{active}}, tracked={{tracked}}', { + active: String(countActiveTasks(status.dreamTasks)), + tracked: String(status.dreamTasks.length), + }); + const topicSummaries = status.topics.map( (topic: { topic: string; entryCount: number; hooks: string[] }) => `- ${topic.topic}.md: ${topic.entryCount} entries${topic.hooks.length > 0 ? ` | hooks: ${topic.hooks.join(' ; ')}` : ''}`, @@ -72,7 +204,9 @@ async function buildManagedMemoryStatus(projectRoot: string): Promise { t('Managed auto-memory root: {{root}}', { root: status.root }), cursorSummary, extractionSummary, + extractionTaskSummary, dreamSummary, + dreamTaskSummary, t('Managed auto-memory topics:'), ...topicSummaries, ].join('\n'); @@ -82,19 +216,14 @@ async function buildManagedMemoryTasks(projectRoot: string): Promise { const status = await getManagedAutoMemoryStatus(projectRoot); const lines = [ t('Managed auto-memory background tasks:'), - `- extraction: ${status.extractionRunning ? 'running' : 'idle'}`, + `- extraction lane: ${status.extractionRunning ? 'running' : 'idle'} | active=${countActiveTasks(status.extractionTasks)} | tracked=${status.extractionTasks.length}`, + `- dream lane: active=${countActiveTasks(status.dreamTasks)} | tracked=${status.dreamTasks.length}`, + '', ]; - if (status.dreamTasks.length === 0) { - lines.push('- dream: no tracked tasks'); - return lines.join('\n'); - } - - for (const task of status.dreamTasks) { - lines.push( - `- dream ${task.id}: ${task.status} | updated=${task.updatedAt}${task.progressText ? ` | ${task.progressText}` : ''}`, - ); - } + lines.push(...buildTaskTimeline('Extraction timeline', status.extractionTasks)); + lines.push(''); + lines.push(...buildTaskTimeline('Dream timeline', status.dreamTasks)); return lines.join('\n'); } @@ -335,6 +464,33 @@ export const memoryCommand: SlashCommand = { return; }, }, + { + name: 'review', + get description() { + return t('Review managed auto-memory governance suggestions.'); + }, + kind: CommandKind.BUILT_IN, + action: async (context) => { + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: await buildManagedMemoryReview(config.getProjectRoot(), config), + }, + Date.now(), + ); + + return; + }, + }, { name: 'extract-now', get description() { @@ -397,18 +553,44 @@ export const memoryCommand: SlashCommand = { }; } - const query = args.trim(); + const trimmedArgs = args.trim(); + const apply = trimmedArgs.startsWith('--apply '); + const query = apply + ? trimmedArgs.slice('--apply '.length).trim() + : trimmedArgs; if (!query) { return { type: 'message', messageType: 'error', - content: t('Usage: /memory forget '), + content: t('Usage: /memory forget [--apply] '), }; } - const result = await forgetManagedAutoMemoryEntries( + if (!apply) { + context.ui.addItem( + { + type: MessageType.INFO, + text: await buildForgetPreview( + config.getProjectRoot(), + query, + `/memory forget --apply ${query}`, + config, + ), + }, + Date.now(), + ); + + return; + } + + const selection = await selectManagedAutoMemoryForgetCandidates( config.getProjectRoot(), query, + { config }, + ); + const result = await forgetManagedAutoMemoryMatches( + config.getProjectRoot(), + selection.matches, ); context.ui.addItem( diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index cdb01b2056e..ed95cb44833 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -647,15 +647,14 @@ export class GeminiClient { let requestToSent = await flatMapTextParts(request, async (text) => [text]); if (messageType === SendMessageType.UserQuery) { const systemReminders = []; - const relevantAutoMemory = - await resolveRelevantAutoMemoryPromptForQuery( - this.config.getProjectRoot(), - partToString(request), - { - config: this.config, - excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, - }, - ); + const relevantAutoMemory = await resolveRelevantAutoMemoryPromptForQuery( + this.config.getProjectRoot(), + partToString(request), + { + config: this.config, + excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, + }, + ); const relevantAutoMemoryPrompt = relevantAutoMemory.prompt; if (relevantAutoMemoryPrompt) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7df991d8306..86a4b43d008 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -123,14 +123,17 @@ export * from './background/backgroundAgentRunner.js'; export * from './memory/types.js'; export * from './memory/paths.js'; export * from './memory/store.js'; +export * from './memory/entries.js'; export * from './memory/indexer.js'; export * from './memory/prompt.js'; export * from './memory/state.js'; export * from './memory/status.js'; export * from './memory/forget.js'; +export * from './memory/governance.js'; export * from './memory/extractionAgentPlanner.js'; export * from './memory/extractionPlanner.js'; export * from './memory/extract.js'; +export * from './memory/extractScheduler.js'; export * from './memory/dreamAgentPlanner.js'; export * from './memory/dream.js'; export * from './memory/dreamScheduler.js'; diff --git a/packages/core/src/memory/dream.test.ts b/packages/core/src/memory/dream.test.ts index 1fd75e51e8f..51668ad7671 100644 --- a/packages/core/src/memory/dream.test.ts +++ b/packages/core/src/memory/dream.test.ts @@ -72,6 +72,44 @@ describe('managed auto-memory dream', () => { expect(index.match(/User prefers terse responses\./g)).toHaveLength(1); }); + it('preserves richer schema metadata when deduplicating entries', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + [ + '---', + 'type: user', + 'title: User Memory', + 'description: User profile', + '---', + '', + '# User Memory', + '', + '- User prefers terse responses.', + ' - Why: They repeatedly ask for concise replies.', + '- User prefers terse responses.', + ' - Stability: stable', + ].join('\n'), + 'utf-8', + ); + + const contentBefore = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + 'utf-8', + ); + expect(contentBefore).toContain('Stability: stable'); + + await runManagedAutoMemoryDream(projectRoot); + + const content = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + 'utf-8', + ); + + expect(content.match(/User prefers terse responses\./g)).toHaveLength(1); + expect(content).toContain(' - Why: They repeatedly ask for concise replies.'); + expect(content).toContain(' - Stability: stable'); + }); + it('restores the empty placeholder when no bullet entries remain', async () => { await fs.writeFile( getAutoMemoryTopicPath(projectRoot, 'project'), diff --git a/packages/core/src/memory/dream.ts b/packages/core/src/memory/dream.ts index ddc908dc915..5e23a787d7b 100644 --- a/packages/core/src/memory/dream.ts +++ b/packages/core/src/memory/dream.ts @@ -6,6 +6,12 @@ import * as fs from 'node:fs/promises'; import type { Config } from '../config/config.js'; +import { + getAutoMemoryBodyHeading, + mergeAutoMemoryEntry, + parseAutoMemoryEntries, + renderAutoMemoryBody, +} from './entries.js'; import { getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; @@ -23,15 +29,9 @@ export interface AutoMemoryDreamResult { systemMessage?: string; } -function normalizeBullet(line: string): string { - return line.replace(/^[-*]\s+/, '').replace(/\s+/g, ' ').trim(); -} - function countDuplicateBullets(body: string): number { - const bullets = body - .split('\n') - .filter((line) => /^[-*]\s+/.test(line.trim())) - .map(normalizeBullet) + const bullets = parseAutoMemoryEntries(body) + .map((entry) => entry.summary) .filter((line) => line.length > 0); return Math.max( @@ -41,27 +41,22 @@ function countDuplicateBullets(body: string): number { } function buildDreamedBody(body: string): { body: string; dedupedEntries: number } { - const lines = body - .split('\n') - .map((line) => line.trimEnd()) - .filter((line) => line.length > 0); - - const heading = lines.find((line) => line.startsWith('# ')) ?? '# Memory'; - const bullets = lines - .filter((line) => /^[-*]\s+/.test(line)) - .map(normalizeBullet) - .filter((line) => line.length > 0); - - const uniqueBullets = Array.from( - new Map(bullets.map((line) => [line.toLowerCase(), line])).values(), - ).sort((a, b) => a.localeCompare(b)); + const heading = getAutoMemoryBodyHeading(body); + const entries = parseAutoMemoryEntries(body); + const mergedEntries = Array.from( + entries.reduce((map, entry) => { + const key = entry.summary.toLowerCase(); + const current = map.get(key); + map.set(key, current ? mergeAutoMemoryEntry(current, entry) : entry); + return map; + }, new Map[number]>()), + ) + .map(([, entry]) => entry) + .sort((a, b) => a.summary.localeCompare(b.summary)); return { - body: - uniqueBullets.length > 0 - ? [heading, '', ...uniqueBullets.map((line) => `- ${line}`)].join('\n') - : [heading, '', '_No entries yet._'].join('\n'), - dedupedEntries: Math.max(0, bullets.length - uniqueBullets.length), + body: renderAutoMemoryBody(heading, mergedEntries), + dedupedEntries: Math.max(0, entries.length - mergedEntries.length), }; } diff --git a/packages/core/src/memory/entries.test.ts b/packages/core/src/memory/entries.test.ts new file mode 100644 index 00000000000..b936d0a05cd --- /dev/null +++ b/packages/core/src/memory/entries.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + parseAutoMemoryEntries, + renderAutoMemoryBody, +} from './entries.js'; + +describe('managed auto-memory entries', () => { + it('parses and renders richer schema fields', () => { + const body = [ + '# User Memory', + '', + '- User prefers terse responses.', + ' - Why: This reduces back-and-forth.', + ' - How to apply: Prefer concise summaries first.', + ' - Stability: stable', + ].join('\n'); + + const entries = parseAutoMemoryEntries(body); + expect(entries).toEqual([ + { + summary: 'User prefers terse responses.', + why: 'This reduces back-and-forth.', + howToApply: 'Prefer concise summaries first.', + stability: 'stable', + }, + ]); + + expect(renderAutoMemoryBody('# User Memory', entries)).toContain( + ' - How to apply: Prefer concise summaries first.', + ); + }); +}); \ No newline at end of file diff --git a/packages/core/src/memory/entries.ts b/packages/core/src/memory/entries.ts new file mode 100644 index 00000000000..6347379d6fa --- /dev/null +++ b/packages/core/src/memory/entries.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export type ManagedAutoMemoryEntryStability = 'stable' | 'working'; + +export interface ManagedAutoMemoryEntry { + summary: string; + why?: string; + howToApply?: string; + stability?: ManagedAutoMemoryEntryStability; +} + +function normalizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +export function getAutoMemoryBodyHeading(body: string): string { + return ( + body + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('# ')) ?? '# Memory' + ); +} + +export function parseAutoMemoryEntries(body: string): ManagedAutoMemoryEntry[] { + const entries: ManagedAutoMemoryEntry[] = []; + let current: ManagedAutoMemoryEntry | null = null; + + for (const rawLine of body.split('\n')) { + const trimmed = rawLine.trim(); + if (!trimmed || trimmed === '_No entries yet._' || trimmed.startsWith('# ')) { + continue; + } + + if (current) { + const nestedMatch = rawLine.match( + /^\s{2,}(?:[-*]\s+)?(Why|How to apply|How_to_apply|Stability):\s*(.+)$/i, + ); + if (nestedMatch) { + const [, rawKey, rawValue] = nestedMatch; + const value = normalizeText(rawValue); + if (!value) { + continue; + } + + switch (rawKey.toLowerCase()) { + case 'why': + current.why = value; + break; + case 'how to apply': + case 'how_to_apply': + current.howToApply = value; + break; + case 'stability': + current.stability = + value.toLowerCase() === 'stable' ? 'stable' : 'working'; + break; + } + continue; + } + } + + if (/^[-*]\s+/.test(trimmed)) { + if (current) { + entries.push(current); + } + current = { + summary: normalizeText(trimmed.replace(/^[-*]\s+/, '')), + }; + continue; + } + } + + if (current) { + entries.push(current); + } + + return entries.filter((entry) => entry.summary.length > 0); +} + +export function renderAutoMemoryBody( + heading: string, + entries: ManagedAutoMemoryEntry[], +): string { + const normalizedHeading = heading.trim().startsWith('# ') + ? heading.trim() + : '# Memory'; + + if (entries.length === 0) { + return [normalizedHeading, '', '_No entries yet._'].join('\n'); + } + + const lines = [normalizedHeading, '']; + for (const entry of entries) { + lines.push(`- ${normalizeText(entry.summary)}`); + if (entry.why) { + lines.push(` - Why: ${normalizeText(entry.why)}`); + } + if (entry.howToApply) { + lines.push(` - How to apply: ${normalizeText(entry.howToApply)}`); + } + if (entry.stability) { + lines.push(` - Stability: ${entry.stability}`); + } + } + + return lines.join('\n'); +} + +export function mergeAutoMemoryEntry( + current: ManagedAutoMemoryEntry, + incoming: ManagedAutoMemoryEntry, +): ManagedAutoMemoryEntry { + return { + summary: incoming.summary || current.summary, + why: current.why ?? incoming.why, + howToApply: current.howToApply ?? incoming.howToApply, + stability: + current.stability === 'stable' || incoming.stability === 'stable' + ? 'stable' + : (current.stability ?? incoming.stability), + }; +} + +export function buildAutoMemoryEntrySearchText( + entry: ManagedAutoMemoryEntry, +): string { + return [entry.summary, entry.why, entry.howToApply, entry.stability] + .filter((value): value is string => Boolean(value)) + .join(' ') + .toLowerCase(); +} \ No newline at end of file diff --git a/packages/core/src/memory/extract.test.ts b/packages/core/src/memory/extract.test.ts index 07d9c146a20..13435519c01 100644 --- a/packages/core/src/memory/extract.test.ts +++ b/packages/core/src/memory/extract.test.ts @@ -15,10 +15,9 @@ import { extractMemoryPatchesFromTranscript, loadUnprocessedTranscriptSlice, runAutoMemoryExtract, - scheduleAutoMemoryExtract, } from './extract.js'; import { ensureAutoMemoryScaffold } from './store.js'; -import { markExtractRunning, resetAutoMemoryStateForTests } from './state.js'; +import { resetAutoMemoryStateForTests } from './state.js'; describe('auto-memory extraction', () => { let tempDir: string; @@ -88,6 +87,29 @@ describe('auto-memory extraction', () => { expect(index).toContain('grafana.internal/d/api-latency'); }); + it('writes richer schema fields when extraction patches include them', async () => { + const touched = await applyExtractedMemoryPatches(projectRoot, [ + { + topic: 'user', + summary: 'User prefers terse responses.', + why: 'They explicitly asked for concise replies.', + howToApply: 'Lead with a short answer before details.', + stability: 'stable', + sourceOffset: 0, + }, + ]); + + const userTopic = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + 'utf-8', + ); + + expect(touched).toEqual(['user']); + expect(userTopic).toContain(' - Why: They explicitly asked for concise replies.'); + expect(userTopic).toContain(' - How to apply: Lead with a short answer before details.'); + expect(userTopic).toContain(' - Stability: stable'); + }); + it('updates cursor and avoids duplicate writes for repeated extraction', async () => { const history = [ { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, @@ -115,17 +137,4 @@ describe('auto-memory extraction', () => { expect(cursor.sessionId).toBe('session-1'); expect(cursor.processedOffset).toBe(2); }); - - it('skips scheduled extraction while the project is already running', async () => { - markExtractRunning(projectRoot); - - const result = await scheduleAutoMemoryExtract({ - projectRoot, - sessionId: 'session-1', - history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], - }); - - expect(result.skippedReason).toBe('already_running'); - expect(result.touchedTopics).toEqual([]); - }); }); \ No newline at end of file diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts index 47ebd70bbae..c4e0f8ca4ca 100644 --- a/packages/core/src/memory/extract.ts +++ b/packages/core/src/memory/extract.ts @@ -11,20 +11,23 @@ import { createDebugLogger } from '../utils/debugLogger.js'; import { partToString } from '../utils/partUtils.js'; import { getAutoMemoryExtractCursorPath, getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; import { ensureAutoMemoryScaffold } from './store.js'; +import { + getAutoMemoryBodyHeading, + mergeAutoMemoryEntry, + parseAutoMemoryEntries, + renderAutoMemoryBody, + type ManagedAutoMemoryEntryStability, +} from './entries.js'; import { parseAutoMemoryTopicDocument } from './scan.js'; import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; +import { scheduleManagedAutoMemoryExtract } from './extractScheduler.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; import { type AutoMemoryExtractCursor, type AutoMemoryMetadata, type AutoMemoryType, } from './types.js'; -import { - clearExtractRunning, - isExtractRunning, - markExtractRunning, -} from './state.js'; const MIN_CANDIDATE_LENGTH = 12; const debugLogger = createDebugLogger('AUTO_MEMORY_EXTRACT'); @@ -38,13 +41,16 @@ export interface AutoMemoryTranscriptMessage { export interface AutoMemoryExtractPatch { topic: AutoMemoryType; summary: string; + why?: string; + howToApply?: string; + stability?: ManagedAutoMemoryEntryStability; sourceOffset: number; } export interface AutoMemoryExtractResult { patches: AutoMemoryExtractPatch[]; touchedTopics: AutoMemoryType[]; - skippedReason?: 'already_running'; + skippedReason?: 'already_running' | 'queued' | 'memory_tool'; systemMessage?: string; cursor: AutoMemoryExtractCursor; } @@ -198,6 +204,14 @@ function normalizeExtractPatch( return { topic: patch.topic, summary, + why: patch.why ? normalizeSummary(patch.why) : undefined, + howToApply: patch.howToApply + ? normalizeSummary(patch.howToApply) + : undefined, + stability: + patch.stability === 'stable' || patch.stability === 'working' + ? patch.stability + : undefined, sourceOffset: patch.sourceOffset, }; } @@ -320,27 +334,51 @@ async function bumpMetadata( } } -function appendSummaryToTopicContent(content: string, summary: string): string | null { +function appendPatchToTopicContent( + content: string, + patch: AutoMemoryExtractPatch, +): string | null { const parsed = parseAutoMemoryTopicDocument('/virtual/topic.md', content); if (!parsed) { return null; } - const normalizedSummary = summary.toLowerCase(); - const hasDuplicate = parsed.body - .split('\n') - .map((line) => line.replace(/^[-*]\s+/, '').trim().toLowerCase()) - .some((line) => line === normalizedSummary); + const heading = getAutoMemoryBodyHeading(parsed.body); + const entries = parseAutoMemoryEntries(parsed.body); + const normalizedSummary = patch.summary.toLowerCase(); + const existingIndex = entries.findIndex( + (entry) => entry.summary.toLowerCase() === normalizedSummary, + ); - if (hasDuplicate) { - return null; + if (existingIndex >= 0) { + const merged = mergeAutoMemoryEntry(entries[existingIndex], { + summary: patch.summary, + why: patch.why, + howToApply: patch.howToApply, + stability: patch.stability, + }); + const current = entries[existingIndex]; + if ( + current.summary === merged.summary && + current.why === merged.why && + current.howToApply === merged.howToApply && + current.stability === merged.stability + ) { + return null; + } + + entries[existingIndex] = merged; + return content.replace(parsed.body, renderAutoMemoryBody(heading, entries)); } - const replacement = parsed.body.includes('_No entries yet._') - ? `- ${summary}` - : `${parsed.body.trimEnd()}\n- ${summary}`; + entries.push({ + summary: patch.summary, + why: patch.why, + howToApply: patch.howToApply, + stability: patch.stability, + }); - return content.replace(parsed.body, replacement); + return content.replace(parsed.body, renderAutoMemoryBody(heading, entries)); } export async function applyExtractedMemoryPatches( @@ -354,7 +392,7 @@ export async function applyExtractedMemoryPatches( for (const patch of patches) { const topicPath = getAutoMemoryTopicPath(projectRoot, patch.topic); const current = await fs.readFile(topicPath, 'utf-8'); - const next = appendSummaryToTopicContent(current, patch.summary); + const next = appendPatchToTopicContent(current, patch); if (!next) { continue; } @@ -430,22 +468,5 @@ export async function scheduleAutoMemoryExtract(params: { now?: Date; config?: Config; }): Promise { - if (isExtractRunning(params.projectRoot)) { - return { - patches: [], - touchedTopics: [], - skippedReason: 'already_running', - cursor: { - sessionId: params.sessionId, - updatedAt: (params.now ?? new Date()).toISOString(), - }, - }; - } - - markExtractRunning(params.projectRoot); - try { - return await runAutoMemoryExtract(params); - } finally { - clearExtractRunning(params.projectRoot); - } + return scheduleManagedAutoMemoryExtract(params); } \ No newline at end of file diff --git a/packages/core/src/memory/extractScheduler.test.ts b/packages/core/src/memory/extractScheduler.test.ts new file mode 100644 index 00000000000..f8c7847bfe7 --- /dev/null +++ b/packages/core/src/memory/extractScheduler.test.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createManagedAutoMemoryExtractRuntimeForTests } from './extractScheduler.js'; +import { getAutoMemoryTopicPath } from './paths.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { markExtractRunning, resetAutoMemoryStateForTests } from './state.js'; + +describe('managed auto-memory extraction runtime', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'auto-memory-extract-runtime-'), + ); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot); + }); + + afterEach(async () => { + resetAutoMemoryStateForTests(); + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('queues a trailing extraction while another extraction is running', async () => { + const runtime = createManagedAutoMemoryExtractRuntimeForTests(); + + const firstPromise = runtime.schedule({ + projectRoot, + sessionId: 'session-1', + history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], + }); + + const queued = await runtime.schedule({ + projectRoot, + sessionId: 'session-1', + history: [ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + { role: 'model', parts: [{ text: 'Done.' }] }, + { + role: 'user', + parts: [{ text: 'The latency dashboard is https://grafana.example/d/api' }], + }, + ], + }); + + expect(queued.skippedReason).toBe('queued'); + + const first = await firstPromise; + expect(first.touchedTopics).toEqual(['user']); + + const drained = await runtime.drain({ timeoutMs: 1_000 }); + expect(drained).toBe(true); + + const referenceTopic = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'reference'), + 'utf-8', + ); + expect(referenceTopic).toContain('grafana.example/d/api'); + + const tasks = runtime.listTasks(projectRoot); + expect(tasks.some((task) => task.status === 'completed')).toBe(true); + expect(tasks.some((task) => task.metadata?.['trailing'] === true)).toBe(true); + }); + + it('skips extraction when save_memory was used in the same slice', async () => { + const runtime = createManagedAutoMemoryExtractRuntimeForTests(); + + const result = await runtime.schedule({ + projectRoot, + sessionId: 'session-1', + history: [ + { + role: 'model', + parts: [{ functionResponse: { name: 'save_memory', response: { output: 'ok' } } }], + }, + ], + }); + + expect(result.skippedReason).toBe('memory_tool'); + expect(runtime.listTasks(projectRoot)[0]?.status).toBe('skipped'); + }); + + it('returns already_running when extraction state is externally locked', async () => { + markExtractRunning(projectRoot); + const runtime = createManagedAutoMemoryExtractRuntimeForTests(); + + const result = await runtime.schedule({ + projectRoot, + sessionId: 'session-1', + history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], + }); + + expect(result.skippedReason).toBe('already_running'); + }); +}); \ No newline at end of file diff --git a/packages/core/src/memory/extractScheduler.ts b/packages/core/src/memory/extractScheduler.ts new file mode 100644 index 00000000000..3f2c615d461 --- /dev/null +++ b/packages/core/src/memory/extractScheduler.ts @@ -0,0 +1,253 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { + BackgroundTaskDrainer, + type DrainBackgroundTasksOptions, +} from '../background/taskDrainer.js'; +import { + BackgroundTaskRegistry, + type BackgroundTaskState, +} from '../background/taskRegistry.js'; +import { partToString } from '../utils/partUtils.js'; +import { + type AutoMemoryExtractResult, + runAutoMemoryExtract, +} from './extract.js'; +import { + clearExtractRunning, + isExtractRunning, + markExtractRunning, +} from './state.js'; + +export interface ScheduleAutoMemoryExtractParams { + projectRoot: string; + sessionId: string; + history: Content[]; + now?: Date; + config?: Config; +} + +interface QueuedExtractionRequest { + taskId: string; + params: ScheduleAutoMemoryExtractParams; +} + +function buildSkippedExtractResult( + params: ScheduleAutoMemoryExtractParams, + skippedReason: AutoMemoryExtractResult['skippedReason'], +): AutoMemoryExtractResult { + return { + patches: [], + touchedTopics: [], + skippedReason, + cursor: { + sessionId: params.sessionId, + updatedAt: (params.now ?? new Date()).toISOString(), + }, + }; +} + +function historySliceUsesMemoryTool(history: Content[]): boolean { + return history.some((message) => { + const rendered = partToString(message.parts ?? [], { verbose: true }); + return ( + rendered.includes('[Function Response: save_memory]') || + rendered.includes('[Function Call: save_memory]') + ); + }); +} + +export class ManagedAutoMemoryExtractRuntime { + readonly registry = new BackgroundTaskRegistry(); + readonly drainer = new BackgroundTaskDrainer(); + + private readonly currentTaskIdByProject = new Map(); + private readonly queuedByProject = new Map(); + + async schedule( + params: ScheduleAutoMemoryExtractParams, + ): Promise { + if (historySliceUsesMemoryTool(params.history)) { + const task = this.registry.register({ + taskType: 'managed-auto-memory-extraction', + title: 'Managed auto-memory extraction', + projectRoot: params.projectRoot, + sessionId: params.sessionId, + metadata: { + skippedReason: 'memory_tool', + historyLength: params.history.length, + }, + }); + this.registry.update(task.id, { + status: 'skipped', + progressText: + 'Skipped managed auto-memory extraction because save_memory already handled this turn.', + }); + return buildSkippedExtractResult(params, 'memory_tool'); + } + + if (isExtractRunning(params.projectRoot)) { + const currentTaskId = this.currentTaskIdByProject.get(params.projectRoot); + if (!currentTaskId) { + return buildSkippedExtractResult(params, 'already_running'); + } + + const queued = this.queuedByProject.get(params.projectRoot); + if (queued) { + queued.params = params; + this.registry.update(queued.taskId, { + status: 'pending', + progressText: + 'Updated trailing managed auto-memory extraction request while another extraction is running.', + metadata: { + queuedBehindTaskId: currentTaskId, + historyLength: params.history.length, + supersededAt: new Date().toISOString(), + }, + }); + } else { + const pendingTask = this.registry.register({ + taskType: 'managed-auto-memory-extraction', + title: 'Managed auto-memory extraction', + projectRoot: params.projectRoot, + sessionId: params.sessionId, + metadata: { + trailing: true, + queuedBehindTaskId: currentTaskId, + historyLength: params.history.length, + }, + }); + this.registry.update(pendingTask.id, { + status: 'pending', + progressText: + 'Queued trailing managed auto-memory extraction until the active extraction completes.', + }); + this.queuedByProject.set(params.projectRoot, { + taskId: pendingTask.id, + params, + }); + } + + return buildSkippedExtractResult(params, 'queued'); + } + + const task = this.registry.register({ + taskType: 'managed-auto-memory-extraction', + title: 'Managed auto-memory extraction', + projectRoot: params.projectRoot, + sessionId: params.sessionId, + metadata: { + historyLength: params.history.length, + }, + }); + + return this.drainer.track(task.id, this.runTask(task.id, params)); + } + + listTasks(projectRoot?: string): BackgroundTaskState[] { + return this.registry.list(projectRoot); + } + + drain(options?: DrainBackgroundTasksOptions): Promise { + return this.drainer.drain(options); + } + + resetForTests(): void { + this.currentTaskIdByProject.clear(); + this.queuedByProject.clear(); + } + + private async runTask( + taskId: string, + params: ScheduleAutoMemoryExtractParams, + ): Promise { + this.currentTaskIdByProject.set(params.projectRoot, taskId); + markExtractRunning(params.projectRoot); + this.registry.update(taskId, { + status: 'running', + progressText: 'Running managed auto-memory extraction.', + metadata: { + historyLength: params.history.length, + }, + }); + + try { + const result = await runAutoMemoryExtract(params); + this.registry.update(taskId, { + status: result.skippedReason ? 'skipped' : 'completed', + progressText: + result.systemMessage ?? + (result.patches.length > 0 + ? `Planned ${result.patches.length} managed auto-memory patch${result.patches.length === 1 ? '' : 'es'}.` + : 'Managed auto-memory extraction completed without durable changes.'), + metadata: { + patchCount: result.patches.length, + touchedTopics: result.touchedTopics, + processedOffset: result.cursor.processedOffset, + skippedReason: result.skippedReason, + }, + }); + return result; + } catch (error) { + this.registry.update(taskId, { + status: 'failed', + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + this.currentTaskIdByProject.delete(params.projectRoot); + clearExtractRunning(params.projectRoot); + void this.startQueuedIfNeeded(params.projectRoot); + } + } + + private async startQueuedIfNeeded(projectRoot: string): Promise { + if (isExtractRunning(projectRoot)) { + return; + } + + const queued = this.queuedByProject.get(projectRoot); + if (!queued) { + return; + } + + this.queuedByProject.delete(projectRoot); + await this.drainer.track( + queued.taskId, + this.runTask(queued.taskId, queued.params), + ); + } +} + +const defaultManagedAutoMemoryExtractRuntime = + new ManagedAutoMemoryExtractRuntime(); + +export async function scheduleManagedAutoMemoryExtract( + params: ScheduleAutoMemoryExtractParams, +): Promise { + return defaultManagedAutoMemoryExtractRuntime.schedule(params); +} + +export function getManagedAutoMemoryExtractTaskRegistry(): BackgroundTaskRegistry { + return defaultManagedAutoMemoryExtractRuntime.registry; +} + +export async function drainManagedAutoMemoryExtractTasks( + options?: DrainBackgroundTasksOptions, +): Promise { + return defaultManagedAutoMemoryExtractRuntime.drain(options); +} + +export function createManagedAutoMemoryExtractRuntimeForTests(): ManagedAutoMemoryExtractRuntime { + return new ManagedAutoMemoryExtractRuntime(); +} + +export function resetManagedAutoMemoryExtractRuntimeForTests(): void { + defaultManagedAutoMemoryExtractRuntime.resetForTests(); +} \ No newline at end of file diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index bf283de37c8..a4687660071 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -49,6 +49,16 @@ const EXTRACTION_AGENT_RESPONSE_SCHEMA: Record = { summary: { type: 'string', }, + why: { + type: 'string', + }, + howToApply: { + type: 'string', + }, + stability: { + type: 'string', + enum: ['stable', 'working'], + }, sourceOffset: { type: 'integer', }, @@ -146,6 +156,9 @@ function validateExtractionAgentResponse( return parsed.patches.map((patch) => ({ topic: patch.topic as AutoMemoryType, summary: patch.summary.trim(), + why: patch.why?.trim(), + howToApply: patch.howToApply?.trim(), + stability: patch.stability, sourceOffset: patch.sourceOffset, })); } diff --git a/packages/core/src/memory/extractionPlanner.ts b/packages/core/src/memory/extractionPlanner.ts index 21da3c8a621..1753156fa7f 100644 --- a/packages/core/src/memory/extractionPlanner.ts +++ b/packages/core/src/memory/extractionPlanner.ts @@ -50,6 +50,16 @@ const RESPONSE_SCHEMA: Record = { summary: { type: 'string', }, + why: { + type: 'string', + }, + howToApply: { + type: 'string', + }, + stability: { + type: 'string', + enum: ['stable', 'working'], + }, sourceOffset: { type: 'integer', }, @@ -160,6 +170,9 @@ export async function planAutoMemoryExtractionPatchesByModel( return response.patches.map((patch) => ({ topic: patch.topic as AutoMemoryType, summary: patch.summary, + why: patch.why, + howToApply: patch.howToApply, + stability: patch.stability, sourceOffset: patch.sourceOffset, })); } diff --git a/packages/core/src/memory/forget.test.ts b/packages/core/src/memory/forget.test.ts index 46fc028681e..7c4eccaa1f1 100644 --- a/packages/core/src/memory/forget.test.ts +++ b/packages/core/src/memory/forget.test.ts @@ -8,7 +8,12 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { forgetManagedAutoMemoryEntries, findManagedAutoMemoryForgetCandidates } from './forget.js'; +import { + forgetManagedAutoMemoryEntries, + forgetManagedAutoMemoryMatches, + findManagedAutoMemoryForgetCandidates, + selectManagedAutoMemoryForgetCandidates, +} from './forget.js'; import { getAutoMemoryIndexPath, getAutoMemoryTopicPath } from './paths.js'; import { ensureAutoMemoryScaffold } from './store.js'; @@ -45,11 +50,12 @@ describe('managed auto-memory forget', () => { '# User Memory', '', '- User prefers terse responses.', + ' - How to apply: Keep the first paragraph short.', ].join('\n'), 'utf-8', ); - const matches = await findManagedAutoMemoryForgetCandidates(projectRoot, 'terse'); + const matches = await findManagedAutoMemoryForgetCandidates(projectRoot, 'first paragraph short'); expect(matches).toEqual([ { topic: 'user', @@ -114,4 +120,46 @@ describe('managed auto-memory forget', () => { expect(content).toContain('_No entries yet._'); }); + + it('supports explicit candidate deletion after preview selection', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + [ + '---', + 'type: user', + 'title: User Memory', + 'description: User profile', + '---', + '', + '# User Memory', + '', + '- User prefers terse responses.', + '- User likes dark mode.', + ].join('\n'), + 'utf-8', + ); + + const selection = await selectManagedAutoMemoryForgetCandidates( + projectRoot, + 'dark mode', + ); + const result = await forgetManagedAutoMemoryMatches( + projectRoot, + selection.matches, + ); + const content = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + 'utf-8', + ); + + expect(selection.matches).toEqual([ + { + topic: 'user', + summary: 'User likes dark mode.', + }, + ]); + expect(result.removedEntries).toEqual(selection.matches); + expect(content).not.toContain('dark mode'); + expect(content).toContain('terse responses'); + }); }); diff --git a/packages/core/src/memory/forget.ts b/packages/core/src/memory/forget.ts index f254132175d..b8ab026ab37 100644 --- a/packages/core/src/memory/forget.ts +++ b/packages/core/src/memory/forget.ts @@ -5,6 +5,16 @@ */ import * as fs from 'node:fs/promises'; +import type { Content } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { runSideQuery } from '../auxiliary/sideQuery.js'; +import { + buildAutoMemoryEntrySearchText, + getAutoMemoryBodyHeading, + parseAutoMemoryEntries, + renderAutoMemoryBody, + type ManagedAutoMemoryEntryStability, +} from './entries.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; import { getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; import { parseAutoMemoryTopicDocument } from './scan.js'; @@ -24,44 +34,113 @@ export interface AutoMemoryForgetResult { systemMessage?: string; } -function normalizeBullet(line: string): string { - return line.replace(/^[-*]\s+/, '').replace(/\s+/g, ' ').trim(); +export interface AutoMemoryForgetSelectionResult { + matches: AutoMemoryForgetMatch[]; + strategy: 'none' | 'heuristic' | 'model'; + reasoning?: string; } -function buildUpdatedBody( - body: string, +interface IndexedForgetCandidate extends AutoMemoryForgetMatch { + id: string; + why?: string; + howToApply?: string; + stability?: ManagedAutoMemoryEntryStability; +} + +const FORGET_SELECTION_RESPONSE_SCHEMA: Record = { + type: 'object', + properties: { + selectedCandidateIds: { + type: 'array', + items: { type: 'string' }, + }, + reasoning: { + type: 'string', + }, + }, + required: ['selectedCandidateIds'], +}; + +interface ForgetSelectionResponse { + selectedCandidateIds: string[]; + reasoning?: string; +} + +async function listIndexedForgetCandidates( + projectRoot: string, +): Promise { + const matches: IndexedForgetCandidate[] = []; + for (const topic of AUTO_MEMORY_TYPES) { + const topicPath = getAutoMemoryTopicPath(projectRoot, topic); + try { + const current = await fs.readFile(topicPath, 'utf-8'); + const parsed = parseAutoMemoryTopicDocument(topicPath, current); + if (!parsed) { + continue; + } + + for (const entry of parseAutoMemoryEntries(parsed.body)) { + matches.push({ + id: `${topic}:${entry.summary}`, + topic, + summary: entry.summary, + why: entry.why, + howToApply: entry.howToApply, + stability: entry.stability, + }); + } + } catch { + // Ignore missing or invalid topic files. + } + } + + return matches; +} + +function buildForgetSelectionPrompt( query: string, + candidates: IndexedForgetCandidate[], + limit: number, +): string { + return [ + 'Select the managed auto-memory entries that most likely match the user request to forget something.', + `Return at most ${limit} candidate ids.`, + 'Prefer semantically matching entries even if the wording differs slightly.', + 'If nothing should be forgotten, return an empty array.', + '', + `Forget request: ${query.trim()}`, + '', + 'Candidates:', + ...candidates.map((candidate, index) => + [ + `Candidate ${index + 1}`, + `id: ${candidate.id}`, + `topic: ${candidate.topic}`, + `summary: ${candidate.summary}`, + `why: ${candidate.why ?? '(none)'}`, + `howToApply: ${candidate.howToApply ?? '(none)'}`, + `stability: ${candidate.stability ?? '(none)'}`, + ].join('\n'), + ), + ].join('\n'); +} + +function buildUpdatedBodyForMatches( + body: string, + summariesToRemove: Set, ): { body: string; removedEntries: string[] } { - const queryLower = query.trim().toLowerCase(); - const lines = body.split('\n').map((line) => line.trimEnd()); + const entries = parseAutoMemoryEntries(body); const removedEntries: string[] = []; - - const nextLines = lines.filter((line) => { - if (!/^[-*]\s+/.test(line.trim())) { - return true; - } - const normalized = normalizeBullet(line); - const shouldRemove = normalized.toLowerCase().includes(queryLower); - if (shouldRemove) { - removedEntries.push(normalized); + const nextEntries = entries.filter((entry) => { + if (summariesToRemove.has(entry.summary.toLowerCase())) { + removedEntries.push(entry.summary); return false; } return true; }); - const hasBullets = nextLines.some((line) => /^[-*]\s+/.test(line.trim())); - if (!hasBullets) { - const headingIndex = nextLines.findIndex((line) => line.startsWith('# ')); - if (headingIndex >= 0) { - return { - body: [...nextLines.slice(0, headingIndex + 1), '', '_No entries yet._'].join('\n'), - removedEntries, - }; - } - } - return { - body: nextLines.join('\n').trim(), + body: renderAutoMemoryBody(getAutoMemoryBodyHeading(body), nextEntries), removedEntries, }; } @@ -100,13 +179,9 @@ export async function findManagedAutoMemoryForgetCandidates( continue; } - for (const line of parsed.body.split('\n')) { - if (!/^[-*]\s+/.test(line.trim())) { - continue; - } - const summary = normalizeBullet(line); - if (summary.toLowerCase().includes(normalizedQuery)) { - matches.push({ topic, summary }); + for (const entry of parseAutoMemoryEntries(parsed.body)) { + if (buildAutoMemoryEntrySearchText(entry).includes(normalizedQuery)) { + matches.push({ topic, summary: entry.summary }); } } } catch { @@ -117,25 +192,107 @@ export async function findManagedAutoMemoryForgetCandidates( return matches; } -export async function forgetManagedAutoMemoryEntries( +export async function selectManagedAutoMemoryForgetCandidates( projectRoot: string, query: string, + options: { + config?: Config; + limit?: number; + } = {}, +): Promise { + const normalizedQuery = query.trim(); + if (!normalizedQuery) { + return { matches: [], strategy: 'none' }; + } + + const candidates = await listIndexedForgetCandidates(projectRoot); + if (candidates.length === 0) { + return { matches: [], strategy: 'none' }; + } + + const limit = Math.max(1, Math.min(options.limit ?? 10, candidates.length)); + if (options.config) { + try { + const candidateIds = new Set(candidates.map((candidate) => candidate.id)); + const contents: Content[] = [ + { + role: 'user', + parts: [ + { + text: buildForgetSelectionPrompt(normalizedQuery, candidates, limit), + }, + ], + }, + ]; + const response = await runSideQuery(options.config, { + purpose: 'auto-memory-forget-select', + contents, + schema: FORGET_SELECTION_RESPONSE_SCHEMA, + abortSignal: AbortSignal.timeout(7_500), + config: { + temperature: 0, + }, + validate: (value) => { + if (value.selectedCandidateIds.length > limit) { + return 'Forget selector returned too many candidates'; + } + if (value.selectedCandidateIds.some((id) => !candidateIds.has(id))) { + return 'Forget selector returned an unknown candidate id'; + } + return null; + }, + }); + + const selectedIds = new Set(response.selectedCandidateIds); + return { + matches: candidates + .filter((candidate) => selectedIds.has(candidate.id)) + .map(({ topic, summary }) => ({ topic, summary })), + strategy: selectedIds.size > 0 ? 'model' : 'none', + reasoning: response.reasoning, + }; + } catch { + // Fall back to heuristic matching. + } + } + + const queryLower = normalizedQuery.toLowerCase(); + const matches = candidates + .filter((candidate) => + buildAutoMemoryEntrySearchText(candidate).includes(queryLower), + ) + .slice(0, limit) + .map(({ topic, summary }) => ({ topic, summary })); + + return { + matches, + strategy: matches.length > 0 ? 'heuristic' : 'none', + }; +} + +export async function forgetManagedAutoMemoryMatches( + projectRoot: string, + matches: AutoMemoryForgetMatch[], now = new Date(), ): Promise { - const trimmedQuery = query.trim(); await ensureAutoMemoryScaffold(projectRoot, now); - if (!trimmedQuery) { - return { - query: trimmedQuery, - removedEntries: [], - touchedTopics: [], - }; + + const removalsByTopic = new Map>(); + for (const match of matches) { + const existing = removalsByTopic.get(match.topic) ?? new Set(); + existing.add(match.summary.toLowerCase()); + removalsByTopic.set(match.topic, existing); } const removedEntries: AutoMemoryForgetMatch[] = []; const touchedTopics = new Set(); for (const topic of AUTO_MEMORY_TYPES) { + const summariesToRemove = removalsByTopic.get(topic); + if (!summariesToRemove || summariesToRemove.size === 0) { + continue; + } + const topicPath = getAutoMemoryTopicPath(projectRoot, topic); const current = await fs.readFile(topicPath, 'utf-8'); const parsed = parseAutoMemoryTopicDocument(topicPath, current); @@ -143,7 +300,7 @@ export async function forgetManagedAutoMemoryEntries( continue; } - const updated = buildUpdatedBody(parsed.body, trimmedQuery); + const updated = buildUpdatedBodyForMatches(parsed.body, summariesToRemove); if (updated.removedEntries.length === 0 || updated.body === parsed.body.trim()) { continue; } @@ -161,7 +318,7 @@ export async function forgetManagedAutoMemoryEntries( } return { - query: trimmedQuery, + query: '', removedEntries, touchedTopics: [...touchedTopics], systemMessage: @@ -170,3 +327,31 @@ export async function forgetManagedAutoMemoryEntries( : undefined, }; } + +export async function forgetManagedAutoMemoryEntries( + projectRoot: string, + query: string, + now = new Date(), +): Promise { + const trimmedQuery = query.trim(); + if (!trimmedQuery) { + return { + query: trimmedQuery, + removedEntries: [], + touchedTopics: [], + }; + } + + const selection = await selectManagedAutoMemoryForgetCandidates(projectRoot, trimmedQuery, { + limit: Number.MAX_SAFE_INTEGER, + }); + const result = await forgetManagedAutoMemoryMatches( + projectRoot, + selection.matches, + now, + ); + return { + ...result, + query: trimmedQuery, + }; +} diff --git a/packages/core/src/memory/governance.test.ts b/packages/core/src/memory/governance.test.ts new file mode 100644 index 00000000000..cce81077409 --- /dev/null +++ b/packages/core/src/memory/governance.test.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { reviewManagedAutoMemoryGovernance } from './governance.js'; +import { getAutoMemoryTopicPath } from './paths.js'; +import { ensureAutoMemoryScaffold } from './store.js'; + +describe('managed auto-memory governance review', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-governance-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot); + }); + + afterEach(async () => { + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('produces heuristic governance suggestions', async () => { + await fs.writeFile( + getAutoMemoryTopicPath(projectRoot, 'project'), + [ + '---', + 'type: project', + 'title: Project Memory', + 'description: Project facts', + '---', + '', + '# Project Memory', + '', + '- Dashboard: https://grafana.example/d/api', + '- Dashboard: https://grafana.example/d/api', + '- This is only temporary for this task.', + ].join('\n'), + 'utf-8', + ); + + const review = await reviewManagedAutoMemoryGovernance(projectRoot); + + expect(review.strategy).toBe('heuristic'); + expect(review.suggestions.some((item) => item.type === 'duplicate')).toBe(true); + expect(review.suggestions.some((item) => item.type === 'migrate')).toBe(true); + expect(review.suggestions.some((item) => item.type === 'forget')).toBe(true); + }); +}); \ No newline at end of file diff --git a/packages/core/src/memory/governance.ts b/packages/core/src/memory/governance.ts new file mode 100644 index 00000000000..3e7e5c1baa0 --- /dev/null +++ b/packages/core/src/memory/governance.ts @@ -0,0 +1,321 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { runSideQuery } from '../auxiliary/sideQuery.js'; +import { parseAutoMemoryEntries } from './entries.js'; +import { getAutoMemoryTopicPath } from './paths.js'; +import { parseAutoMemoryTopicDocument } from './scan.js'; +import type { AutoMemoryType } from './types.js'; +import { AUTO_MEMORY_TYPES } from './types.js'; +import * as fs from 'node:fs/promises'; + +export type AutoMemoryGovernanceSuggestionType = + | 'duplicate' + | 'conflict' + | 'outdated' + | 'promote' + | 'migrate' + | 'forget'; + +export interface AutoMemoryGovernanceSuggestion { + type: AutoMemoryGovernanceSuggestionType; + topic: AutoMemoryType; + summary: string; + rationale: string; + relatedTopic?: AutoMemoryType; + relatedSummary?: string; + suggestedTargetTopic?: AutoMemoryType; +} + +export interface AutoMemoryGovernanceReview { + suggestions: AutoMemoryGovernanceSuggestion[]; + strategy: 'none' | 'heuristic' | 'model'; +} + +interface IndexedGovernanceEntry { + id: string; + topic: AutoMemoryType; + summary: string; + why?: string; + howToApply?: string; + stability?: 'stable' | 'working'; +} + +const RESPONSE_SCHEMA: Record = { + type: 'object', + properties: { + suggestions: { + type: 'array', + items: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['duplicate', 'conflict', 'outdated', 'promote', 'migrate', 'forget'], + }, + entryId: { type: 'string' }, + relatedEntryId: { type: 'string' }, + suggestedTargetTopic: { + type: 'string', + enum: ['user', 'feedback', 'project', 'reference'], + }, + rationale: { type: 'string' }, + }, + required: ['type', 'entryId', 'rationale'], + }, + }, + }, + required: ['suggestions'], +}; + +interface GovernanceResponse { + suggestions: Array<{ + type: AutoMemoryGovernanceSuggestionType; + entryId: string; + relatedEntryId?: string; + suggestedTargetTopic?: AutoMemoryType; + rationale: string; + }>; +} + +async function listGovernanceEntries( + projectRoot: string, +): Promise { + const entries: IndexedGovernanceEntry[] = []; + for (const topic of AUTO_MEMORY_TYPES) { + const topicPath = getAutoMemoryTopicPath(projectRoot, topic); + try { + const content = await fs.readFile(topicPath, 'utf-8'); + const parsed = parseAutoMemoryTopicDocument(topicPath, content); + if (!parsed) { + continue; + } + + for (const entry of parseAutoMemoryEntries(parsed.body)) { + entries.push({ + id: `${topic}:${entry.summary}`, + topic, + summary: entry.summary, + why: entry.why, + howToApply: entry.howToApply, + stability: entry.stability, + }); + } + } catch { + // Ignore missing or invalid topic files. + } + } + return entries; +} + +function classifyExpectedTopic(summary: string): AutoMemoryType | null { + if (/https?:\/\/|\b(grafana|dashboard|runbook|ticket|docs?|wiki|notion|jira)\b/i.test(summary)) { + return 'reference'; + } + if (/\b(i|we)\s+(prefer|like|need|want)\b|\bmy\s+(preferred|favorite)\b/i.test(summary)) { + return 'user'; + } + if (/\b(please|always|never|avoid|respond|format|style|terse|concise|detailed)\b/i.test(summary)) { + return 'feedback'; + } + if (/\b(project|repo|repository|service|release|deadline|freeze|incident|environment|stack)\b/i.test(summary)) { + return 'project'; + } + return null; +} + +function maybeConflict(a: string, b: string): boolean { + const pairChecks: Array<[RegExp, RegExp]> = [ + [/\balways\b/i, /\bnever\b/i], + [/\bterse|concise\b/i, /\bdetailed\b/i], + ]; + return pairChecks.some( + ([left, right]) => + (left.test(a) && right.test(b)) || (left.test(b) && right.test(a)), + ); +} + +function buildModelPrompt(entries: IndexedGovernanceEntry[]): string { + return [ + 'Review managed auto-memory entries and emit governance suggestions.', + 'Only suggest duplicate, conflict, outdated, promote, migrate, or forget when the case is strong.', + 'Prefer promote suggestions for entries that are durable but still missing why/howToApply/stability context.', + '', + 'Entries:', + ...entries.map((entry, index) => + [ + `Entry ${index + 1}`, + `id: ${entry.id}`, + `topic: ${entry.topic}`, + `summary: ${entry.summary}`, + `why: ${entry.why ?? '(none)'}`, + `howToApply: ${entry.howToApply ?? '(none)'}`, + `stability: ${entry.stability ?? '(none)'}`, + ].join('\n'), + ), + ].join('\n'); +} + +function buildHeuristicSuggestions( + entries: IndexedGovernanceEntry[], +): AutoMemoryGovernanceSuggestion[] { + const suggestions: AutoMemoryGovernanceSuggestion[] = []; + const seenBySummary = new Map(); + + for (const entry of entries) { + const key = entry.summary.toLowerCase(); + const existing = seenBySummary.get(key); + if (existing) { + suggestions.push({ + type: 'duplicate', + topic: entry.topic, + summary: entry.summary, + relatedTopic: existing.topic, + relatedSummary: existing.summary, + rationale: 'This entry duplicates an existing durable memory summary.', + }); + continue; + } + seenBySummary.set(key, entry); + } + + for (const entry of entries) { + const expectedTopic = classifyExpectedTopic(entry.summary); + if (expectedTopic && expectedTopic !== entry.topic) { + suggestions.push({ + type: 'migrate', + topic: entry.topic, + summary: entry.summary, + suggestedTargetTopic: expectedTopic, + rationale: 'The summary appears to belong in a different managed memory topic.', + }); + } + + if (/\b(temporary|for this task|this session|currently)\b/i.test(entry.summary)) { + suggestions.push({ + type: 'forget', + topic: entry.topic, + summary: entry.summary, + rationale: 'The entry appears temporary rather than durable.', + }); + } + + if (/\b(deprecated|obsolete|sunset|legacy|old)\b/i.test(entry.summary)) { + suggestions.push({ + type: 'outdated', + topic: entry.topic, + summary: entry.summary, + rationale: 'The entry contains wording that suggests it may be outdated.', + }); + } + + if (!entry.why || !entry.howToApply || !entry.stability) { + suggestions.push({ + type: 'promote', + topic: entry.topic, + summary: entry.summary, + rationale: 'This durable entry could be upgraded with why/howToApply/stability metadata.', + }); + } + } + + for (let index = 0; index < entries.length; index += 1) { + for (let inner = index + 1; inner < entries.length; inner += 1) { + const left = entries[index]; + const right = entries[inner]; + if (left.topic !== right.topic) { + continue; + } + if (maybeConflict(left.summary, right.summary)) { + suggestions.push({ + type: 'conflict', + topic: right.topic, + summary: right.summary, + relatedTopic: left.topic, + relatedSummary: left.summary, + rationale: 'These entries may encode conflicting guidance.', + }); + } + } + } + + return suggestions.slice(0, 20); +} + +export async function reviewManagedAutoMemoryGovernance( + projectRoot: string, + options: { + config?: Config; + } = {}, +): Promise { + const entries = await listGovernanceEntries(projectRoot); + if (entries.length === 0) { + return { suggestions: [], strategy: 'none' }; + } + + if (options.config) { + try { + const entryById = new Map(entries.map((entry) => [entry.id, entry])); + const response = await runSideQuery(options.config, { + purpose: 'auto-memory-governance-review', + contents: [ + { + role: 'user', + parts: [{ text: buildModelPrompt(entries) }], + }, + ] as Content[], + schema: RESPONSE_SCHEMA, + abortSignal: AbortSignal.timeout(8_000), + config: { + temperature: 0, + }, + validate: (value) => { + if (value.suggestions.some((suggestion) => !entryById.has(suggestion.entryId))) { + return 'Governance reviewer returned an unknown entry id'; + } + if ( + value.suggestions.some( + (suggestion) => + suggestion.relatedEntryId && !entryById.has(suggestion.relatedEntryId), + ) + ) { + return 'Governance reviewer returned an unknown related entry id'; + } + return null; + }, + }); + + return { + suggestions: response.suggestions.map((suggestion) => { + const entry = entryById.get(suggestion.entryId)!; + const related = suggestion.relatedEntryId + ? entryById.get(suggestion.relatedEntryId) + : undefined; + return { + type: suggestion.type, + topic: entry.topic, + summary: entry.summary, + rationale: suggestion.rationale, + relatedTopic: related?.topic, + relatedSummary: related?.summary, + suggestedTargetTopic: suggestion.suggestedTargetTopic, + } satisfies AutoMemoryGovernanceSuggestion; + }), + strategy: response.suggestions.length > 0 ? 'model' : 'none', + }; + } catch { + // Fall back to heuristics. + } + } + + const suggestions = buildHeuristicSuggestions(entries); + return { + suggestions, + strategy: suggestions.length > 0 ? 'heuristic' : 'none', + }; +} \ No newline at end of file diff --git a/packages/core/src/memory/indexer.ts b/packages/core/src/memory/indexer.ts index d895d5a63c8..49e9661bdce 100644 --- a/packages/core/src/memory/indexer.ts +++ b/packages/core/src/memory/indexer.ts @@ -5,6 +5,7 @@ */ import * as fs from 'node:fs/promises'; +import { parseAutoMemoryEntries } from './entries.js'; import { getAutoMemoryIndexPath, getAutoMemoryMetadataPath } from './paths.js'; import { scanAutoMemoryTopicDocuments, type ScannedAutoMemoryDocument } from './scan.js'; import type { AutoMemoryMetadata } from './types.js'; @@ -12,12 +13,9 @@ import type { AutoMemoryMetadata } from './types.js'; const MAX_TOPIC_HOOKS = 3; function getBodyBulletLines(body: string): string[] { - return body - .split('\n') - .map((line) => line.trim()) - .filter((line) => /^[-*]\s+/.test(line)) - .map((line) => line.replace(/^[-*]\s+/, '').trim()) - .filter((line) => line.length > 0); + return parseAutoMemoryEntries(body) + .map((entry) => entry.summary) + .filter((summary) => summary.length > 0); } export function countAutoMemoryTopicEntries(body: string): number { diff --git a/packages/core/src/memory/status.ts b/packages/core/src/memory/status.ts index 345bdcd85b4..3f86a020077 100644 --- a/packages/core/src/memory/status.ts +++ b/packages/core/src/memory/status.ts @@ -5,6 +5,7 @@ */ import * as fs from 'node:fs/promises'; +import { getManagedAutoMemoryExtractTaskRegistry } from './extractScheduler.js'; import { getManagedAutoMemoryDreamTaskRegistry } from './dreamScheduler.js'; import { buildAutoMemoryTopicHooks, countAutoMemoryTopicEntries } from './indexer.js'; import { @@ -40,6 +41,7 @@ export interface ManagedAutoMemoryStatus { metadata?: AutoMemoryMetadata; extractionRunning: boolean; topics: ManagedAutoMemoryTopicStatus[]; + extractionTasks: BackgroundTaskState[]; dreamTasks: BackgroundTaskState[]; } @@ -105,6 +107,9 @@ export async function getManagedAutoMemoryStatus( metadata, extractionRunning: isExtractRunning(projectRoot), topics, + extractionTasks: getManagedAutoMemoryExtractTaskRegistry() + .list(projectRoot) + .slice(0, 8), dreamTasks: getManagedAutoMemoryDreamTaskRegistry().list(projectRoot).slice(0, 5), }; } From 223e8e64e14c4bcb55112d4e1acef2b6ddc3ba03 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 3 Apr 2026 11:57:21 +0800 Subject: [PATCH 20/56] test(memory): add managed lifecycle integration coverage --- docs/auto-memory-work-log.md | 49 +++++ .../memoryLifecycle.integration.test.ts | 157 +++++++++++++++ .../memoryLifecycle.integration.test.ts | 183 ++++++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 packages/cli/src/ui/commands/memoryLifecycle.integration.test.ts create mode 100644 packages/core/src/memory/memoryLifecycle.integration.test.ts diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md index 9f138c3f6f7..8bef942bdbc 100644 --- a/docs/auto-memory-work-log.md +++ b/docs/auto-memory-work-log.md @@ -62,6 +62,55 @@ Completed --- +## Part 20 - Memory module integration coverage + +### Start review + +- The managed memory feature set is now functionally complete for the current non-team/private parity scope. +- Existing tests already cover most units and command behaviors, but the module still needed end-to-end lifecycle coverage proving that the pieces work together in a realistic flow. +- Scope for this part: add deterministic integration tests spanning extraction, recall, dream, governance, forget, and CLI command surfaces without relying on flaky live-model behavior. + +### Goal + +- Add a core integration test that exercises the managed memory lifecycle across multiple subsystems +- Add a CLI integration test that validates the user-facing command experience over real managed-memory state +- Re-run focused regressions and typechecks to confirm the new integration layer is stable + +### Implemented + +- Added `packages/core/src/memory/memoryLifecycle.integration.test.ts` +- Added `packages/cli/src/ui/commands/memoryLifecycle.integration.test.ts` + +### Functional verification + +- The core integration test verifies a Claude-style durable-memory lifecycle: background extraction with trailing semantics, relevant-memory recall, dream dedupe, governance review, and forget preview/apply mutation. +- The CLI integration test verifies that `/memory extract-now`, `/memory status`, `/memory tasks`, `/memory inspect`, `/memory review`, `/memory forget`, and `/forget` all operate correctly over real managed-memory files. +- The new tests are deterministic and exercise the real filesystem-backed managed-memory implementation rather than mocks of the underlying lifecycle. + +### Test verification + +- Passed targeted integration tests: + - `cd packages/core && npx vitest run src/memory/memoryLifecycle.integration.test.ts` + - `cd packages/cli && npx vitest run src/ui/commands/memoryLifecycle.integration.test.ts` +- Passed focused regression tests: + - `cd packages/core && npx vitest run src/memory/memoryLifecycle.integration.test.ts src/memory/entries.test.ts src/memory/extractScheduler.test.ts src/memory/forget.test.ts src/memory/governance.test.ts` + - `cd packages/cli && npx vitest run src/ui/commands/memoryLifecycle.integration.test.ts src/ui/commands/memoryCommand.test.ts src/ui/commands/forgetCommand.test.ts` +- Passed build / typecheck verification: + - `npm run build --workspace=packages/core` + - `npm run typecheck --workspace=packages/core` + - `npm run typecheck --workspace=packages/cli` + +### Notes + +- These tests intentionally avoid relying on live side-query/model responses; they validate the production fallback and lifecycle behavior that users depend on most. +- The integration coverage is aimed at the managed-memory experience shape seen in Claude Code: extract → recall → consolidate → review → forget. + +### Status + +Completed + +--- + ## Part 18 - Extraction runtime lifecycle and task timelines ### Start review diff --git a/packages/cli/src/ui/commands/memoryLifecycle.integration.test.ts b/packages/cli/src/ui/commands/memoryLifecycle.integration.test.ts new file mode 100644 index 00000000000..de61f1dc316 --- /dev/null +++ b/packages/cli/src/ui/commands/memoryLifecycle.integration.test.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + applyExtractedMemoryPatches, + ensureAutoMemoryScaffold, + getAutoMemoryTopicPath, + resetManagedAutoMemoryExtractRuntimeForTests, + resetAutoMemoryStateForTests, +} from '@qwen-code/qwen-code-core'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { forgetCommand } from './forgetCommand.js'; +import { memoryCommand } from './memoryCommand.js'; +import type { SlashCommand, CommandContext } from './types.js'; + +describe('managed memory CLI integration', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-cli-int-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T00:00:00.000Z')); + }); + + afterEach(async () => { + resetAutoMemoryStateForTests(); + resetManagedAutoMemoryExtractRuntimeForTests(); + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + function getMemorySubCommand(name: string): SlashCommand { + const subCommand = memoryCommand.subCommands?.find((command) => command.name === name); + if (!subCommand) { + throw new Error(`Missing /memory ${name} command`); + } + return subCommand; + } + + function createContext(history: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }> = []): CommandContext { + return createMockCommandContext({ + services: { + config: { + getProjectRoot: vi.fn().mockReturnValue(projectRoot), + getSessionId: vi.fn().mockReturnValue('session-1'), + getGeminiClient: vi.fn().mockReturnValue({ + getChat: vi.fn().mockReturnValue({ + getHistory: vi.fn().mockReturnValue(history), + }), + }), + }, + }, + }); + } + + it('surfaces extraction, status, tasks, inspect, review, and forget flows through CLI commands', async () => { + const extractContext = createContext([ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + { + role: 'user', + parts: [{ text: 'The latency dashboard is https://grafana.example/d/api-latency' }], + }, + ]); + + await getMemorySubCommand('extract-now').action?.(extractContext, ''); + const extractText = (extractContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; + expect(extractText).toContain('Managed auto-memory updated'); + + const statusContext = createContext(); + await getMemorySubCommand('status').action?.(statusContext, ''); + const statusText = (statusContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; + expect(statusText).toContain(`Managed auto-memory root: ${projectRoot}/.qwen/memory`); + expect(statusText).toContain('Extraction tasks: active=0, tracked=1'); + + const tasksContext = createContext(); + await getMemorySubCommand('tasks').action?.(tasksContext, ''); + const tasksText = (tasksContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; + expect(tasksText).toContain('Managed auto-memory background tasks:'); + expect(tasksText).toContain('Extraction timeline:'); + + const inspectContext = createContext(); + await getMemorySubCommand('inspect').action?.(inspectContext, 'user'); + const inspectText = (inspectContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; + expect(inspectText).toContain('I prefer terse responses.'); + + await applyExtractedMemoryPatches(projectRoot, [ + { + topic: 'project', + summary: 'This is temporary for this task.', + sourceOffset: 10, + }, + ]); + + const reviewContext = createContext(); + await getMemorySubCommand('review').action?.(reviewContext, ''); + const reviewText = (reviewContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; + expect(reviewText).toContain('Managed auto-memory governance review'); + expect(reviewText).toMatch(/\[(forget|promote)\]/); + + const memoryForgetContext = createContext(); + await getMemorySubCommand('forget').action?.(memoryForgetContext, 'temporary for this task'); + const previewText = (memoryForgetContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; + expect(previewText).toContain('Forget preview'); + expect(previewText).toContain('/memory forget --apply temporary for this task'); + + await getMemorySubCommand('forget').action?.(memoryForgetContext, '--apply temporary for this task'); + const memoryForgetApplyText = (memoryForgetContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; + expect(memoryForgetApplyText).toContain('Managed auto-memory forgot 1 entry'); + + const forgetContext = createContext(); + const topLevelPreview = await forgetCommand.action?.( + forgetContext, + 'terse responses', + ); + expect(topLevelPreview).toEqual( + expect.objectContaining({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Forget preview'), + }), + ); + expect((topLevelPreview as { content: string }).content).toContain( + '/forget --apply terse responses', + ); + + const topLevelApply = await forgetCommand.action?.( + forgetContext, + '--apply terse responses', + ); + expect(topLevelApply).toEqual( + expect.objectContaining({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Managed auto-memory forgot 1 entry'), + }), + ); + + const userContentAfterForget = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'user'), + 'utf-8', + ); + expect(userContentAfterForget).not.toContain('I prefer terse responses.'); + }); +}); \ No newline at end of file diff --git a/packages/core/src/memory/memoryLifecycle.integration.test.ts b/packages/core/src/memory/memoryLifecycle.integration.test.ts new file mode 100644 index 00000000000..0c7df4725dd --- /dev/null +++ b/packages/core/src/memory/memoryLifecycle.integration.test.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runManagedAutoMemoryDream } from './dream.js'; +import { + drainManagedAutoMemoryExtractTasks, + resetManagedAutoMemoryExtractRuntimeForTests, + scheduleManagedAutoMemoryExtract, +} from './extractScheduler.js'; +import { applyExtractedMemoryPatches } from './extract.js'; +import { + forgetManagedAutoMemoryMatches, + selectManagedAutoMemoryForgetCandidates, +} from './forget.js'; +import { reviewManagedAutoMemoryGovernance } from './governance.js'; +import { rebuildManagedAutoMemoryIndex } from './indexer.js'; +import { getAutoMemoryIndexPath, getAutoMemoryTopicPath } from './paths.js'; +import { resolveRelevantAutoMemoryPromptForQuery } from './recall.js'; +import { getManagedAutoMemoryStatus } from './status.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { resetAutoMemoryStateForTests } from './state.js'; + +describe('managed auto-memory lifecycle integration', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-lifecycle-int-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T00:00:00.000Z')); + }); + + afterEach(async () => { + resetAutoMemoryStateForTests(); + resetManagedAutoMemoryExtractRuntimeForTests(); + await fs.rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('supports a Claude-style durable memory lifecycle across extraction, recall, dream, governance, and forget', async () => { + const firstExtraction = scheduleManagedAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], + }); + + const queuedExtraction = await scheduleManagedAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + history: [ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + { role: 'model', parts: [{ text: 'Understood.' }] }, + { + role: 'user', + parts: [ + { + text: 'The latency dashboard is https://grafana.example/d/api-latency', + }, + ], + }, + ], + }); + + expect(queuedExtraction.skippedReason).toBe('queued'); + + const firstResult = await firstExtraction; + expect(firstResult.touchedTopics).toEqual(['user']); + + const drained = await drainManagedAutoMemoryExtractTasks({ timeoutMs: 1_000 }); + expect(drained).toBe(true); + + await applyExtractedMemoryPatches(projectRoot, [ + { + topic: 'project', + summary: 'The latency dashboard is https://grafana.example/d/api-latency', + sourceOffset: 100, + }, + { + topic: 'project', + summary: 'This is temporary for this task.', + sourceOffset: 101, + }, + ]); + + const userPath = getAutoMemoryTopicPath(projectRoot, 'user'); + const duplicatedUserContent = `${( + await fs.readFile(userPath, 'utf-8') + ).trimEnd()}\n- I prefer terse responses.\n - Why: User repeatedly asks for concise replies.\n`; + await fs.writeFile(userPath, duplicatedUserContent, 'utf-8'); + await rebuildManagedAutoMemoryIndex(projectRoot); + + const dreamResult = await runManagedAutoMemoryDream( + projectRoot, + new Date('2026-04-01T03:00:00.000Z'), + ); + expect(dreamResult.touchedTopics).toContain('user'); + expect(dreamResult.dedupedEntries).toBeGreaterThan(0); + + const userContent = await fs.readFile(userPath, 'utf-8'); + const projectContent = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'project'), + 'utf-8', + ); + const referenceContent = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'reference'), + 'utf-8', + ); + const indexContent = await fs.readFile( + getAutoMemoryIndexPath(projectRoot), + 'utf-8', + ); + + expect(userContent.match(/I prefer terse responses\./g)).toHaveLength(1); + expect(userContent).toContain(' - Why: User repeatedly asks for concise replies.'); + expect(referenceContent).toContain('grafana.example/d/api-latency'); + expect(projectContent).toContain('This is temporary for this task.'); + expect(indexContent).toContain('I prefer terse responses.'); + + const recall = await resolveRelevantAutoMemoryPromptForQuery( + projectRoot, + 'Check the latency dashboard and use a terse answer.', + ); + expect(recall.strategy).toBe('heuristic'); + expect(recall.prompt).toContain('## Relevant Managed Auto-Memory'); + expect(recall.prompt).toContain('user.md'); + expect(recall.prompt).toContain('reference.md'); + + const review = await reviewManagedAutoMemoryGovernance(projectRoot); + const suggestionTypes = new Set(review.suggestions.map((item) => item.type)); + expect(review.strategy).toBe('heuristic'); + expect(suggestionTypes).toContain('duplicate'); + expect(suggestionTypes).toContain('migrate'); + expect(suggestionTypes).toContain('forget'); + expect(suggestionTypes).toContain('promote'); + + const forgetSelection = await selectManagedAutoMemoryForgetCandidates( + projectRoot, + 'temporary for this task', + ); + expect(forgetSelection.strategy).toBe('heuristic'); + expect(forgetSelection.matches).toEqual([ + { + topic: 'project', + summary: 'This is temporary for this task.', + }, + ]); + + const forgetResult = await forgetManagedAutoMemoryMatches( + projectRoot, + forgetSelection.matches, + new Date('2026-04-01T04:00:00.000Z'), + ); + const projectContentAfterForget = await fs.readFile( + getAutoMemoryTopicPath(projectRoot, 'project'), + 'utf-8', + ); + const indexAfterForget = await fs.readFile( + getAutoMemoryIndexPath(projectRoot), + 'utf-8', + ); + const status = await getManagedAutoMemoryStatus(projectRoot); + + expect(forgetResult.removedEntries).toEqual(forgetSelection.matches); + expect(projectContentAfterForget).not.toContain('temporary for this task'); + expect(indexAfterForget).not.toContain('temporary for this task'); + expect(status.extractionTasks.length).toBeGreaterThan(0); + expect(status.topics.find((topic) => topic.topic === 'user')).toEqual( + expect.objectContaining({ entryCount: 1 }), + ); + }); +}); \ No newline at end of file From e392a153d5c598314412c183c8523ae58efad286 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 7 Apr 2026 17:32:42 +0800 Subject: [PATCH 21/56] feat: same to cc --- docs/auto-memory-implementation-report.md | 457 ------- docs/auto-memory-work-log.md | 1084 ----------------- .../src/services/BuiltinCommandLoader.test.ts | 29 - .../cli/src/services/BuiltinCommandLoader.ts | 12 +- packages/cli/src/ui/AppContainer.tsx | 14 + .../cli/src/ui/commands/dreamCommand.test.ts | 55 - packages/cli/src/ui/commands/dreamCommand.ts | 12 +- .../cli/src/ui/commands/forgetCommand.test.ts | 96 -- packages/cli/src/ui/commands/forgetCommand.ts | 17 +- .../cli/src/ui/commands/memoryCommand.test.ts | 875 +------------ packages/cli/src/ui/commands/memoryCommand.ts | 812 +----------- .../memoryLifecycle.integration.test.ts | 157 --- .../src/ui/commands/rememberCommand.test.ts | 44 - .../cli/src/ui/commands/rememberCommand.ts | 32 +- packages/cli/src/ui/commands/types.ts | 1 + .../cli/src/ui/components/DialogManager.tsx | 4 + .../src/ui/components/InputPrompt.test.tsx | 22 +- .../src/ui/components/MemoryDialog.test.tsx | 60 + .../cli/src/ui/components/MemoryDialog.tsx | 323 +++++ .../cli/src/ui/contexts/UIActionsContext.tsx | 2 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../ui/hooks/slashCommandProcessor.test.ts | 42 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 5 + packages/cli/src/ui/hooks/useDialogClose.ts | 9 + .../cli/src/ui/hooks/useGeminiStream.test.tsx | 2 +- packages/cli/src/ui/hooks/useMemoryDialog.ts | 31 + .../src/ui/hooks/useSlashCompletion.test.ts | 14 +- .../cli/src/ui/utils/commandUtils.test.ts | 4 +- packages/cli/src/utils/commands.test.ts | 58 +- packages/cli/src/utils/commands.ts | 2 +- packages/core/src/config/config.test.ts | 10 +- packages/core/src/config/config.ts | 19 +- .../core/__snapshots__/prompts.test.ts.snap | 15 - packages/core/src/core/client.test.ts | 15 +- packages/core/src/core/client.ts | 81 +- packages/core/src/core/prompts.ts | 1 - packages/core/src/index.ts | 6 +- packages/core/src/memory/dream.test.ts | 161 ++- packages/core/src/memory/dream.ts | 125 +- .../core/src/memory/dreamAgentPlanner.test.ts | 101 +- packages/core/src/memory/dreamAgentPlanner.ts | 197 ++- .../core/src/memory/dreamScheduler.test.ts | 29 +- packages/core/src/memory/dreamScheduler.ts | 9 +- packages/core/src/memory/entries.test.ts | 11 +- packages/core/src/memory/entries.ts | 103 +- packages/core/src/memory/extract.test.ts | 26 +- packages/core/src/memory/extract.ts | 145 ++- packages/core/src/memory/extractAgent.test.ts | 9 +- packages/core/src/memory/extractModel.test.ts | 16 +- .../core/src/memory/extractScheduler.test.ts | 9 +- packages/core/src/memory/extractScheduler.ts | 20 +- .../src/memory/extractionAgentPlanner.test.ts | 3 + .../core/src/memory/extractionAgentPlanner.ts | 48 +- .../core/src/memory/extractionPlanner.test.ts | 7 +- packages/core/src/memory/extractionPlanner.ts | 20 +- packages/core/src/memory/forget.test.ts | 165 --- packages/core/src/memory/forget.ts | 298 ++--- packages/core/src/memory/governance.test.ts | 61 - packages/core/src/memory/governance.ts | 140 ++- packages/core/src/memory/indexer.test.ts | 55 +- packages/core/src/memory/indexer.ts | 76 +- packages/core/src/memory/memoryAge.ts | 51 + .../memoryLifecycle.integration.test.ts | 108 +- packages/core/src/memory/paths.ts | 105 +- packages/core/src/memory/prompt.test.ts | 52 +- packages/core/src/memory/prompt.ts | 215 +++- packages/core/src/memory/recall.test.ts | 11 +- packages/core/src/memory/recall.ts | 14 +- .../core/src/memory/relevanceSelector.test.ts | 17 +- packages/core/src/memory/relevanceSelector.ts | 124 +- packages/core/src/memory/scan.test.ts | 21 +- packages/core/src/memory/scan.ts | 70 +- packages/core/src/memory/status.test.ts | 74 -- packages/core/src/memory/status.ts | 68 +- packages/core/src/memory/store.test.ts | 13 +- packages/core/src/memory/store.ts | 62 +- packages/core/src/memory/types.ts | 20 - packages/core/src/tools/read-file.ts | 29 +- packages/core/test-setup.ts | 5 + 79 files changed, 2212 insertions(+), 5104 deletions(-) delete mode 100644 docs/auto-memory-implementation-report.md delete mode 100644 docs/auto-memory-work-log.md delete mode 100644 packages/cli/src/ui/commands/dreamCommand.test.ts delete mode 100644 packages/cli/src/ui/commands/forgetCommand.test.ts delete mode 100644 packages/cli/src/ui/commands/memoryLifecycle.integration.test.ts delete mode 100644 packages/cli/src/ui/commands/rememberCommand.test.ts create mode 100644 packages/cli/src/ui/components/MemoryDialog.test.tsx create mode 100644 packages/cli/src/ui/components/MemoryDialog.tsx create mode 100644 packages/cli/src/ui/hooks/useMemoryDialog.ts delete mode 100644 packages/core/src/memory/forget.test.ts delete mode 100644 packages/core/src/memory/governance.test.ts create mode 100644 packages/core/src/memory/memoryAge.ts delete mode 100644 packages/core/src/memory/status.test.ts diff --git a/docs/auto-memory-implementation-report.md b/docs/auto-memory-implementation-report.md deleted file mode 100644 index 44fdada6fd1..00000000000 --- a/docs/auto-memory-implementation-report.md +++ /dev/null @@ -1,457 +0,0 @@ -# Auto-Memory 功能实现报告 - -## 1. 结论摘要 - -当前实现已经完成设计文档中的 **Part 1–5 全部既定 MVP 范围**,形成了一套可运行、可测试、可观察的 managed auto-memory 子系统,覆盖: - -- 独立存储层:`.qwen/memory/` -- 主提示词集成:`MEMORY.md` 索引并入 `userMemory` -- 查询时相关记忆召回:按 query 注入 relevant memory block -- turn-end 自动提炼:从当前 session transcript 增量抽取 durable memory -- dream/consolidation:手动触发的去重整理 -- CLI 入口:`/memory status`、`/memory extract-now`、`/dream`、`/remember` - -如果以“**设计文档的第一阶段目标**”衡量,当前实现是 **已完成**。 - -如果以“**Claude Code 当前完整 memory system 的实际能力**”衡量,当前实现是 **中高完成度的 MVP**: - -- **已对齐**:taxonomy、独立 memory 目录、显式保存入口、基础 recall、基础 extract、基础 dream、基本命令入口。 -- **未完全对齐**:Claude 的模型驱动 recall、forked extractor、后台 auto-dream 调度、task 可视化、forget/governance 深水区、team/private 双层 memory。 - -## 2. 对比基线 - -### 2.1 设计文档基线 - -设计目标来自 [auto-memory-doc/02-technical-design.md](../auto-memory-doc/02-technical-design.md),核心要求是: - -1. 独立 managed memory 存储层 -2. relevant memory recall -3. turn-end 自动提炼 -4. 周期性 consolidation / dream -5. 保持对 `QWEN.md` / `AGENTS.md` / `save_memory` 的兼容 - -### 2.2 Claude Code 对标基线 - -Claude 当前 memory system 的关键能力,可从以下实现侧看到: - -- taxonomy 与 memory prompt 规则: [src/memdir/memoryTypes.ts](../src/memdir/memoryTypes.ts) -- query 相关记忆选择: [src/memdir/findRelevantMemories.ts](../src/memdir/findRelevantMemories.ts) -- turn-end extractor: [src/services/extractMemories/extractMemories.ts](../src/services/extractMemories/extractMemories.ts) -- background auto-dream: [src/services/autoDream/autoDream.ts](../src/services/autoDream/autoDream.ts) - -## 3. 当前实现概览 - -当前 Qwen 实现位于 memory-worktree,主要落点如下: - -- storage/scaffold: [packages/core/src/memory/store.ts](packages/core/src/memory/store.ts) -- managed index prompt: [packages/core/src/memory/prompt.ts](packages/core/src/memory/prompt.ts) -- topic 扫描: [packages/core/src/memory/scan.ts](packages/core/src/memory/scan.ts) -- recall: [packages/core/src/memory/recall.ts](packages/core/src/memory/recall.ts) -- extraction: [packages/core/src/memory/extract.ts](packages/core/src/memory/extract.ts) -- dream: [packages/core/src/memory/dream.ts](packages/core/src/memory/dream.ts) -- 配置集成: [packages/core/src/config/config.ts](packages/core/src/config/config.ts) -- client 注入/触发: [packages/core/src/core/client.ts](packages/core/src/core/client.ts) -- CLI 命令: [packages/cli/src/ui/commands/memoryCommand.ts](packages/cli/src/ui/commands/memoryCommand.ts)、[packages/cli/src/ui/commands/dreamCommand.ts](packages/cli/src/ui/commands/dreamCommand.ts)、[packages/cli/src/ui/commands/rememberCommand.ts](packages/cli/src/ui/commands/rememberCommand.ts) -- 命令注册: [packages/cli/src/services/BuiltinCommandLoader.ts](packages/cli/src/services/BuiltinCommandLoader.ts) -- 交付记录: [docs/auto-memory-work-log.md](docs/auto-memory-work-log.md) - -## 4. 与设计文档的逐项对比 - -### 4.1 Storage Layer - -**设计要求** - -- 独立 `.qwen/memory/` 目录 -- `MEMORY.md` + topic files + `meta.json` + `extract-cursor.json` -- 与 `QWEN.md` 分离 - -**当前实现** - -- 已实现 `MEMORY.md`、`meta.json`、`extract-cursor.json` -- 已实现 4 个 topic files:`user.md`、`feedback.md`、`project.md`、`reference.md` -- 已保持机器维护内容不写回 `QWEN.md` - -**结论** - -- **已完成,且与文档一致。** - -**说明** - -- taxonomy 已严格对齐 Claude 的 4 类,而没有引入文档中被明确降级为后续扩展的 `workflow` / `debugging` 等分类。 - -### 4.2 Prompt / Compatibility Layer - -**设计要求** - -- 与 `QWEN.md` / `AGENTS.md` / `save_memory` 兼容 -- managed memory 以低侵入方式并入现有 prompt 体系 - -**当前实现** - -- `refreshHierarchicalMemory()` 会在原有 hierarchical memory 基础上追加 managed index -- 现有 `save_memory` 行为未被破坏 -- 原有 `QWEN.md` / `AGENTS.md` 发现逻辑未修改 - -**结论** - -- **已完成。** - -### 4.3 Recall Layer - -**设计要求** - -- 扫描 memory 元数据 -- 基于 query 做相关性筛选 -- 构造注入 prompt -- 控制 token 成本 - -**当前实现** - -- 已实现 topic 文件扫描与 frontmatter 解析 -- 已实现 query-token + type-keyword 的启发式评分选择 -- 已在 `UserQuery` 请求路径下注入 `Relevant Managed Auto-Memory` block -- 已对单文档注入体做截断 - -**结论** - -- **MVP 已完成。** - -**与文档差异** - -- 文档没有强制要求必须模型驱动;当前实现采用启发式检索,风险更低,但召回质量上限低于 Claude。 - -### 4.4 Extraction Layer - -**设计要求** - -- turn-end 异步触发 -- 基于 transcript 增量提炼 -- 幂等与 cursor 控制 -- 可独立关闭/回滚 - -**当前实现** - -- 已在完成 `UserQuery` 后触发 extraction -- 已用 `extract-cursor.json` 维护 session-aware 增量游标 -- 已实现同进程并发保护 -- 已做 topic file 幂等追加与 metadata 更新时间维护 - -**结论** - -- **MVP 已完成。** - -**与文档差异** - -- 文档中更理想的方案是 headless extractor agent + structured memory patch。 -- 当前实现是 **host 侧启发式抽取**,不是独立 extractor agent。 -- 因此当前版本在复杂总结、跨消息归纳、why/how 提炼方面弱于文档理想态。 - -### 4.5 Dream Layer - -**设计要求** - -- 周期性 consolidation -- 去重、重组、更新索引 -- 并发锁与状态管理 - -**当前实现** - -- 已实现手动触发的 dream primitive -- 已实现 topic file bullet 去重、排序、占位恢复、metadata bump - -**结论** - -- **MVP 部分完成。** - -**未达成点** - -- 尚未实现自动调度 -- 尚未实现 consolidation lock 文件 -- 尚未实现模型驱动重写/提纯/重组 -- 尚未重写 `MEMORY.md` 索引摘要 - -### 4.6 Control / Observability Layer - -**设计要求** - -- lock -- cursor -- debug 日志 -- CLI 命令 -- task / governance 可观测性 -- richer durable schema - -**当前实现** - -- 已实现 cursor -- 已实现 consolidation lock、dream/extraction task registry 与 CLI 可视化 -- 已实现 `/memory status`、`/memory tasks`、`/memory inspect`、`/memory review`、`/memory forget`、`/forget` -- 已通过 system message、task timeline 与 governance review 暴露 memory 更新结果 -- 已引入 `why` / `howToApply` / `stability` richer schema - -**结论** - -- **大部完成。** - -**缺口** - -- task UI 仍可继续向更完整的交互式观察体验打磨 -- team/private 双层 memory 仍未纳入当前范围 - -## 5. 与 Claude Code memory system 的对比 - -### 5.1 已基本对齐的能力 - -#### 1) Memory taxonomy - -Qwen 当前实现使用 `user` / `feedback` / `project` / `reference` 四类,已与 Claude 的 taxonomy 对齐,符合 [src/memdir/memoryTypes.ts](../src/memdir/memoryTypes.ts) 的实际定义。 - -#### 2) 独立 managed memory 存储 - -Claude 将 auto-memory 独立于人工维护 memory 文件;Qwen 当前也已做到这一点,避免把机器维护内容继续塞入 `QWEN.md`。 - -#### 3) 显式记忆写入入口 - -Claude 支持在主对话里直接 remember;Qwen 当前通过 `/remember` 和既有 `save_memory` 工具实现了同类显式入口。 - -#### 4) 查询时 relevant recall - -两边都在主请求前追加相关记忆,而不是把所有 topic 文件完整灌入 prompt。 - -### 5.2 部分对齐、但实现深度不同的能力 - -#### 1) Recall 选择机制 - -Claude 的 recall 选择是 **side-query + 模型判定**,见 [src/memdir/findRelevantMemories.ts](../src/memdir/findRelevantMemories.ts)。 - -Qwen 当前是 **启发式 token/keyword 评分**。 - -影响: - -- 优点:更稳定、成本更低、实现风险更小 -- 缺点:语义召回能力弱,难处理隐式关联和复杂描述 - -#### 2) Extraction 实现形态 - -Claude 的 extractor 是 **forked agent**,具备: - -- 只读探索 + memory 目录限域写入 -- 依据 prompt 进行结构化提炼 -- 主 agent 已经写 memory 时可跳过 extractor -- 正在运行时支持 trailing run / stash 行为 - -对应实现见 [src/services/extractMemories/extractMemories.ts](../src/services/extractMemories/extractMemories.ts)。 - -Qwen 当前 extraction 是 **本地启发式规则抽取**。 - -影响: - -- 优点:轻量、可预测、容易验证 -- 缺点:对复杂对话、跨 turn 归纳、why/how 结构化沉淀明显不如 Claude - -#### 3) Dream 实现形态 - -Claude 的 dream 是 **后台 forked consolidation agent**,并带有: - -- 时间门限 -- session 数门限 -- consolidation lock -- background dream task -- progress watcher -- 完成/失败/中止状态流转 - -对应实现见 [src/services/autoDream/autoDream.ts](../src/services/autoDream/autoDream.ts)。 - -Qwen 当前 dream 仅为 **手动 dedupe/normalize**。 - -影响: - -- 当前只能算 dream 的低风险占位版 -- 还不具备 Claude 的后台整理能力和任务可视化能力 - -### 5.3 尚未对齐的能力 - -#### 1) 自动后台 dream 调度 - -Claude 有 stop-hook/background housekeeping 驱动的 auto-dream;Qwen 当前无自动调度。 - -#### 2) Dream / Extract 任务可视化 - -Claude 有 `DreamTask` 等任务态展示;Qwen 当前只有简要 system message / CLI 结果文本。 - -#### 3) Forget / memory 治理闭环 - -Claude 的“完整记忆系统”不仅有 remember,也强调治理、审查、整理。 - -Qwen 当前: - -- 有 `/memory show`、`/memory status` -- 有 `/remember` -- 有 `/dream` -- **但没有显式 `/forget`** -- **也没有更深入的 memory 审查/提升/迁移工作流** - -#### 4) Team/private 双层 memory - -Claude 代码里已经考虑 private/team 语义;Qwen 当前第一阶段未覆盖,这与设计文档非目标一致,不算偏航,但属于 Claude parity 未完成项。 - -## 6. 功能完成度判断 - -### 6.1 按设计文档第一阶段判断 - -| 领域 | 结论 | -| --- | --- | -| 存储 scaffold | 完成 | -| 索引 prompt 集成 | 完成 | -| relevant recall | 完成(MVP) | -| turn-end extraction | 完成(MVP) | -| dream primitive | 部分完成 | -| CLI 入口 | 完成(MVP) | -| 兼容 `QWEN.md` / `AGENTS.md` / `save_memory` | 完成 | - -总体判断:**文档第一阶段目标已完成。** - -### 6.2 按 Claude Code parity 判断 - -| 领域 | 完成度 | -| --- | --- | -| taxonomy / storage 形态 | 高 | -| prompt 集成与兼容性 | 高 | -| explicit remember | 高 | -| recall 能力深度 | 中 | -| extraction 能力深度 | 中 | -| dream 能力深度 | 低到中 | -| memory 治理与审查 | 中偏低 | -| 后台任务与状态可视化 | 低 | - -总体判断:**已达到 Claude 风格 memory system 的 MVP 骨架,但还未达到 Claude 当前实现深度。** - -## 7. 已完成验证 - -根据 [docs/auto-memory-work-log.md](docs/auto-memory-work-log.md) 的记录,当前实现已完成: - -- core 定向测试 -- core 回归测试 -- cli 定向测试 -- core / cli typecheck -- 工作日志记录 -- 分阶段提交 - -关键提交: - -- Part 4:`a5b6683f8` — `feat(core): add managed auto-memory extraction` -- Part 5:`eefd3e9d0` — `feat(cli): add managed auto-memory dream commands` - -## 8. 主要缺口与后续建议 - -### 优先级 P1 - -1. 将 extraction 从启发式规则升级为模型驱动 extractor -2. 为 dream 增加自动调度、锁和最小状态记录 -3. 重写 `MEMORY.md`,让它真正反映 topic 文件摘要,而不只是 scaffold index - -### 优先级 P2 - -1. 增加 `/forget` 或等效治理入口 -2. 为 `/memory` 增加更完整的 topic 审查/编辑/迁移视图 -3. 为 durable memory 引入更丰富的结构化 schema 与治理辅助信息 - -### 优先级 P3 - -1. 引入 task 级可视化或后台状态面板 -2. 评估 team/private 双层 memory -3. 继续打磨治理建议的交互体验与执行闭环 - -## 9. 最终判断 - -当前实现不是“半成品”,而是一个 **已经闭环的第一阶段交付**: - -- 有存储 -- 有注入 -- 有召回 -- 有自动提炼 -- 有手动整理 -- 有命令入口 -- 有测试验证 - -但如果目标是 **严格追平 Claude Code 当前 memory system 的行为深度和运营能力**,那么当前仍属于: - -**结构完成、能力可用、深度未完全追平。** - -更准确地说: - -- **对设计文档:已完成第一阶段目标** -- **对 Claude Code:已完成 MVP 级别对标,未完成 full parity** - -## 10. 当前版本相对 Claude Code 的对齐情况 - -> 本章基于当前 `feat/auto-memory` 分支的最新实现状态补充。若与前文较早阶段的判断存在差异,以本章为准。 - -### 10.1 当前已经完成或基本对齐的内容 - -1. **memory taxonomy 与 managed 存储形态** - - 已采用 `user` / `feedback` / `project` / `reference` 四类 taxonomy。 - - 已维护独立 `.qwen/memory/` 目录,包含 `MEMORY.md`、topic files、`meta.json`、`extract-cursor.json` 与 `consolidation.lock`。 - -2. **recall 主链路** - - 已实现模型驱动 relevance selector。 - - 已保留 heuristic fallback。 - - 已实现 surfaced memory 的会话级去重。 - - 在“主请求前注入 relevant memories”这一能力形态上,已与 Claude Code 基本对齐。 - -3. **extraction 主链路** - - 已从单纯规则抽取升级为:agent planner → side-query planner → heuristic fallback。 - - agent stage B 已支持受限 `read_file`、多轮预算、`filesTouched`、`roundCount` 与 cancelled 状态。 - - 说明 Qwen 已进入模型驱动 extraction 阶段。 - -4. **dream / consolidation 主链路** - - 已具备 auto-dream 调度、时间/会话门限、`consolidation.lock`、background task registry、agent-first planner 与 mechanical fallback。 - - 这意味着 Qwen 已不再只是手动 `/dream` 的占位实现。 - -5. **动态 `MEMORY.md` 索引** - - `MEMORY.md` 已会随 extraction、dream、forget 自动重写。 - - 已与 Claude 把 `MEMORY.md` 作为“索引入口”而非正文存储位的设计基本一致。 - -6. **governance 基础入口** - - 当前已提供 `/memory status`、`/memory tasks`、`/memory inspect`、`/memory forget`、`/forget`、`/dream`、`/remember`。 - - 已经具备基础治理入口,而不只是写入入口。 - -7. **通用 runtime 基础设施** - - 已具备 shared side-query、background task runtime、`BackgroundAgentRunner`。 - - 从结构分层看,Qwen 已补齐 Claude memory system 很大一部分公共底座。 - -8. **extraction 完整后台生命周期** - - 已补齐 extraction runtime、trailing queue、pending drain、save_memory 同轮 skip 与 extraction task tracking。 - - 在 turn-end 提炼的后台语义上,已明显接近 Claude 的运行形态。 - -9. **task UI 深度可视化** - - `/memory status` 与 `/memory tasks` 已展示 extraction / dream 双 lane、timeline、progressText 与关键 metadata。 - - 任务可观测性已不再停留在简单 system message 层面。 - -10. **治理建议流与 richer schema** - - 已新增 governance review,支持 duplicate / conflict / outdated / promote / migrate / forget suggestion。 - - 已补齐 durable entry richer schema:`why`、`howToApply`、`stability`,并打通 extract / dream / index / forget 链路。 - -11. **模型辅助 forget candidate 选择** - - `/forget` 与 `/memory forget` 已升级为 preview-first + `--apply` 确认流。 - - forget 候选选择已支持 side-query / heuristic 双路径,而不再只是直接 substring 删除。 - -### 10.2 当前仍等待与 Claude Code 进一步对齐的部分 - -1. **team/private 双层 memory** - - 当前仍以单层 managed memory 为主,未完成更复杂的 scope 语义。 - - 这一项仍属于 Claude 更复杂的 memory scope 语义,不在当前 Qwen Code 目标范围内。 - -### 10.3 现阶段总体判断 - -如果只看“第一阶段 MVP 是否完成”,答案已经是 **完成**。 - -如果看“当前版本相对 Claude Code 到底完成了什么”,更准确的判断是: - -- **主干结构:已基本对齐** -- **主要链路:除 team/private scope 外已基本具备对应能力形态** -- **执行深度与治理成熟度:已进入可用且可治理阶段** - -可以把当前状态概括为:结构对齐高,主能力链路对齐高,治理成熟度中高,任务可视化/UI 深度中高;若排除 team/private scope,已接近 Claude 当前 memory system 的主要能力面。 - -因此,Qwen 当前已经不再只是“memory MVP 原型”,而是进入了“**从结构对齐走向深度对齐**”的阶段。 diff --git a/docs/auto-memory-work-log.md b/docs/auto-memory-work-log.md deleted file mode 100644 index 8bef942bdbc..00000000000 --- a/docs/auto-memory-work-log.md +++ /dev/null @@ -1,1084 +0,0 @@ -# Auto-Memory Implementation Work Log - -## Overall Goal - -Implement a Claude Code parity memory system in Qwen Code incrementally: - -1. Human-managed context: `QWEN.md` / `AGENTS.md` -2. Explicit user memory: keep and narrow `save_memory` -3. Managed auto-memory: `.qwen/memory/` -4. Recall / Extract / Dream lifecycle -5. `/memory` / `/dream` / `/remember` UX and observability - -## Working Rules - -- Each part should be independently deliverable. -- Each part must include tests. -- Each part must finish with: - - functional verification - - targeted tests passing - - regression checks passing - - git commit completed - - work log updated -- At the start and end of each part, review the overall plan and this log. - ---- - -## Part 0 - Baseline and breakdown - -### Start review - -- Source plan: `auto-memory-doc/02-technical-design.md` in the analysis repo. -- Current implementation repo: `/Users/mochi/code/memory-worktree` -- Branch: `feat/auto-memory` -- Working tree baseline: clean - -### Goal - -- Confirm repo baseline -- Create implementation work log in repo -- Break work into independently verifiable parts - -### Result - -- Confirmed target repo and clean baseline -- Confirmed implementation branch `feat/auto-memory` -- Created in-repo work log -- Established staged plan: - 1. storage skeleton - 2. prompt integration - 3. recall - 4. extraction - 5. dream and commands - -### Verification - -- `git status --short` was empty before changes -- No code behavior changed in this part - -### Status - -Completed - ---- - -## Part 20 - Memory module integration coverage - -### Start review - -- The managed memory feature set is now functionally complete for the current non-team/private parity scope. -- Existing tests already cover most units and command behaviors, but the module still needed end-to-end lifecycle coverage proving that the pieces work together in a realistic flow. -- Scope for this part: add deterministic integration tests spanning extraction, recall, dream, governance, forget, and CLI command surfaces without relying on flaky live-model behavior. - -### Goal - -- Add a core integration test that exercises the managed memory lifecycle across multiple subsystems -- Add a CLI integration test that validates the user-facing command experience over real managed-memory state -- Re-run focused regressions and typechecks to confirm the new integration layer is stable - -### Implemented - -- Added `packages/core/src/memory/memoryLifecycle.integration.test.ts` -- Added `packages/cli/src/ui/commands/memoryLifecycle.integration.test.ts` - -### Functional verification - -- The core integration test verifies a Claude-style durable-memory lifecycle: background extraction with trailing semantics, relevant-memory recall, dream dedupe, governance review, and forget preview/apply mutation. -- The CLI integration test verifies that `/memory extract-now`, `/memory status`, `/memory tasks`, `/memory inspect`, `/memory review`, `/memory forget`, and `/forget` all operate correctly over real managed-memory files. -- The new tests are deterministic and exercise the real filesystem-backed managed-memory implementation rather than mocks of the underlying lifecycle. - -### Test verification - -- Passed targeted integration tests: - - `cd packages/core && npx vitest run src/memory/memoryLifecycle.integration.test.ts` - - `cd packages/cli && npx vitest run src/ui/commands/memoryLifecycle.integration.test.ts` -- Passed focused regression tests: - - `cd packages/core && npx vitest run src/memory/memoryLifecycle.integration.test.ts src/memory/entries.test.ts src/memory/extractScheduler.test.ts src/memory/forget.test.ts src/memory/governance.test.ts` - - `cd packages/cli && npx vitest run src/ui/commands/memoryLifecycle.integration.test.ts src/ui/commands/memoryCommand.test.ts src/ui/commands/forgetCommand.test.ts` -- Passed build / typecheck verification: - - `npm run build --workspace=packages/core` - - `npm run typecheck --workspace=packages/core` - - `npm run typecheck --workspace=packages/cli` - -### Notes - -- These tests intentionally avoid relying on live side-query/model responses; they validate the production fallback and lifecycle behavior that users depend on most. -- The integration coverage is aimed at the managed-memory experience shape seen in Claude Code: extract → recall → consolidate → review → forget. - -### Status - -Completed - ---- - -## Part 18 - Extraction runtime lifecycle and task timelines - -### Start review - -- Overall parity work now focuses on the remaining Claude-aligned execution details rather than the basic memory backbone. -- Previous parts already delivered model-driven recall/extract/dream and governance entrypoints, but extraction still lacked a fuller background lifecycle and the CLI only exposed limited task visibility. -- Scope for this part: move extraction onto a tracked runtime with trailing semantics and surface richer extraction/dream task state through `/memory status` and `/memory tasks`. - -### Goal - -- Add a managed extraction runtime with queued trailing execution and drain support -- Skip extraction when the same turn already used `save_memory` -- Track extraction tasks alongside dream tasks in managed-memory status -- Expand CLI task views with extraction/dream timelines and metadata summaries - -### Implemented - -- Added `packages/core/src/memory/extractScheduler.ts` -- Added `packages/core/src/memory/extractScheduler.test.ts` -- Updated `packages/core/src/memory/extract.ts` to delegate scheduling to the extraction runtime -- Updated `packages/core/src/memory/status.ts` to expose `extractionTasks` -- Updated `packages/cli/src/ui/commands/memoryCommand.ts` with dual-lane task summaries and timelines -- Updated `packages/cli/src/ui/commands/memoryCommand.test.ts` with extraction timeline coverage - -### Functional verification - -- Managed extraction now supports trailing queued execution instead of only returning a coarse already-running skip. -- The runtime detects `save_memory` activity in the same slice and skips redundant extraction safely. -- `/memory status` and `/memory tasks` now show extraction and dream task counts, timelines, progress text, and key metadata such as trailing state and touched topics. - -### Test verification - -- Passed targeted tests: - - `cd packages/core && npx vitest run src/memory/extractScheduler.test.ts src/memory/extract.test.ts src/memory/status.test.ts src/core/client.test.ts` - - `cd packages/cli && npx vitest run src/ui/commands/memoryCommand.test.ts` - -### Notes - -- This part keeps extraction writes host-side; the new runtime improves lifecycle semantics and observability without changing the underlying patch-application ownership. - -### Status - -Completed - ---- - -## Part 19 - Governance review, preview-first forget, and richer schema - -### Start review - -- After closing extraction lifecycle and task visibility, the remaining non-team/private parity work concentrated on governance maturity. -- The system still needed structured durable entries, deeper governance suggestions, and a safer preview-first forget workflow. -- Scope for this part: add richer durable-entry schema, model-assisted forget candidate selection, and governance review suggestions end to end. - -### Goal - -- Add a structured durable-entry representation with `why`, `howToApply`, and `stability` -- Preserve that schema across extraction, dream, indexing, and forget flows -- Add governance review suggestions with heuristic/model paths -- Upgrade `/forget` and `/memory forget` to preview-first candidate selection with `--apply` - -### Implemented - -- Added `packages/core/src/memory/entries.ts` -- Added `packages/core/src/memory/entries.test.ts` -- Added `packages/core/src/memory/governance.ts` -- Added `packages/core/src/memory/governance.test.ts` -- Updated `packages/core/src/memory/extract.ts`, `dream.ts`, `indexer.ts`, `forget.ts`, `extractionPlanner.ts`, and `extractionAgentPlanner.ts` to use structured durable entries -- Updated `packages/cli/src/ui/commands/memoryCommand.ts` with `/memory review` and preview-first `/memory forget` -- Updated `packages/cli/src/ui/commands/forgetCommand.ts` with preview-first `/forget` -- Updated CLI/core tests for richer schema, governance review, and preview/apply forget flows -- Removed the previously started auto-memory feature-flag wiring after confirming that feature flags are not part of Qwen Code's target scope - -### Functional verification - -- Durable managed-memory entries can now carry `why`, `howToApply`, and `stability`, and those fields survive extraction, dedupe, indexing, inspection, and deletion. -- Governance review can now surface duplicate, conflict, outdated, promote, migrate, and forget suggestions for manual inspection. -- `/forget` and `/memory forget` now preview selected removal candidates first, then require `--apply` for the actual mutation path. - -### Test verification - -- Passed targeted tests: - - `cd packages/core && npx vitest run src/memory/entries.test.ts src/memory/extractScheduler.test.ts src/memory/extract.test.ts src/memory/dream.test.ts src/memory/forget.test.ts src/memory/governance.test.ts src/memory/status.test.ts src/core/client.test.ts` - - `cd packages/core && npx vitest run src/memory/extractionPlanner.test.ts src/memory/extractionAgentPlanner.test.ts src/memory/extractAgent.test.ts src/memory/extractModel.test.ts src/memory/indexer.test.ts src/memory/dreamScheduler.test.ts` - - `cd packages/cli && npx vitest run src/ui/commands/forgetCommand.test.ts src/ui/commands/memoryCommand.test.ts src/services/BuiltinCommandLoader.test.ts` -- Passed build / typecheck verification: - - `npm run typecheck --workspace=packages/core` - - `npm run build --workspace=packages/core` - - `npm run typecheck --workspace=packages/cli` - -### Notes - -- Feature flags were explicitly removed from scope during this stage and are therefore not part of the delivered parity set. -- The only major Claude-side item still intentionally out of scope is team/private multi-scope memory. - -### Status - -Completed - ---- - -## Part 13 - Dream agent consumer stage A - -### Start review - -- Overall plan continues from the shared background runtime and extraction-agent work toward the next real consumer: dream/consolidation. -- Parts 10 to 12 already delivered auto dream scheduling, a reusable `BackgroundAgentRunner`, and extraction agent planning. -- Scope for this part: let dream attempt a tool-free background-agent rewrite plan first, while preserving the existing mechanical dream path as a safe fallback. - -### Goal - -- Add a tool-free dream agent planner that returns structured topic rewrites -- Wire managed dream to prefer agent rewrites when `Config` is available -- Preserve the existing mechanical dream path as fallback on planner failure or absence of config -- Add targeted tests for planner validation, agent-first dream behavior, fallback behavior, and client wiring - -### Implemented - -- Added `packages/core/src/memory/dreamAgentPlanner.ts` -- Added `packages/core/src/memory/dreamAgentPlanner.test.ts` -- Updated `packages/core/src/memory/dream.ts` to prefer agent-produced topic rewrites and fall back to mechanical dream -- Updated `packages/core/src/memory/dreamScheduler.ts` to accept optional `Config` and pass it into dream execution -- Updated `packages/core/src/core/client.ts` to pass `Config` into auto-dream scheduling -- Updated `packages/core/src/memory/dream.test.ts` with agent-first and fallback coverage -- Updated `packages/core/src/core/client.test.ts` with dream scheduler config coverage -- Exported dream agent planner helpers from `packages/core/src/index.ts` - -### Functional verification - -- Managed dream can now use `BackgroundAgentRunner` as a tool-free consolidation planner that rewrites full topic bodies in JSON form. -- If the dream agent planner fails, returns invalid output, or no `Config` is available, dream safely falls back to the existing mechanical dedupe implementation. -- Auto dream scheduling now passes runtime config through from the main client so background dream tasks can use the new agent path. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/dreamAgentPlanner.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts src/core/client.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/background/backgroundAgentRunner.test.ts src/memory/extractionAgentPlanner.test.ts src/memory/extractAgent.test.ts src/memory/extractModel.test.ts src/memory/extract.test.ts src/memory/dreamAgentPlanner.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts src/core/client.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This stage intentionally keeps the dream agent tool-free and JSON-only, matching the low-risk rollout shape used for extraction agent stage A. -- The existing mechanical dream remains the safety net and still supports manual `/dream` use without requiring runtime config. - -### Status - -Completed - ---- - -## Part 14 - Dynamic MEMORY index rewrite - -### Start review - -- Overall plan now shifts from initial dream-agent adoption to the next high-value consistency gap: keeping `MEMORY.md` aligned with real topic-file state. -- Part 13 already delivered agent-first dream planning, but the managed index was still largely scaffold-shaped and not rebuilt after extraction or dream. -- Scope for this part: add a mechanical dynamic index builder, regenerate `MEMORY.md` after topic mutations, and keep the index compact for both prompt loading and manual review. - -### Goal - -- Add a reusable managed index builder that summarizes topic documents into short hooks -- Rebuild `MEMORY.md` automatically after extraction and dream touch topic files -- Keep the default scaffold index in the same compact dynamic format -- Add targeted tests for hook extraction, index formatting, and extract/dream integration - -### Implemented - -- Added `packages/core/src/memory/indexer.ts` -- Added `packages/core/src/memory/indexer.test.ts` -- Updated `packages/core/src/memory/store.ts` so the default scaffold index uses the dynamic index format -- Updated `packages/core/src/memory/extract.ts` to rebuild `MEMORY.md` after successful topic patch application -- Updated `packages/core/src/memory/dream.ts` to rebuild `MEMORY.md` after agent or mechanical consolidation -- Updated `packages/core/src/memory/extract.test.ts` and `packages/core/src/memory/dream.test.ts` with index rewrite coverage -- Exported index helpers from `packages/core/src/index.ts` - -### Functional verification - -- `MEMORY.md` is now a compact, dynamic topic index that lists durable entry counts and short hooks derived from topic bullets. -- Extraction and dream now keep the managed index synchronized with topic-file mutations instead of leaving the scaffold stale. -- Manual review and prompt loading now see a more representative managed memory landing page without needing a model-generated summary step. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/indexer.test.ts src/memory/store.test.ts src/memory/extract.test.ts src/memory/dream.test.ts src/memory/prompt.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This stage intentionally keeps index generation mechanical by using the first few unique topic bullets as hooks. -- A later stage can upgrade the index from mechanical hooks to model-generated summaries if needed. - -### Status - -Completed - ---- - -## Part 15 - Memory governance entrypoints - -### Start review - -- Overall plan now moves from keeping `MEMORY.md` synchronized to making managed memory easier to inspect and govern from the CLI. -- Part 14 already gave the system a dynamic index, but `/memory` still exposed only a minimal status view and no direct task/index inspection path. -- Scope for this part: add reusable managed-memory status aggregation, enrich metadata with recent extraction/dream outcomes, and expand `/memory` with governance-oriented status, tasks, and inspect commands. - -### Goal - -- Add a reusable status reader for managed auto-memory state -- Record recent extraction/dream outcomes in metadata for governance views -- Expand `/memory` with richer `status`, plus `tasks` and `inspect` subcommands -- Add targeted tests for status aggregation and CLI governance flows - -### Implemented - -- Added `packages/core/src/memory/status.ts` -- Added `packages/core/src/memory/status.test.ts` -- Extended `packages/core/src/memory/types.ts` metadata with recent extraction/dream result fields -- Updated `packages/core/src/memory/extract.ts` to persist recent extraction status into metadata -- Updated `packages/core/src/memory/dream.ts` to persist recent dream status into metadata -- Exported status helpers from `packages/core/src/index.ts` -- Enhanced `packages/cli/src/ui/commands/memoryCommand.ts` to use structured status aggregation -- Added `/memory tasks` and `/memory inspect` governance subcommands -- Updated `packages/cli/src/ui/commands/memoryCommand.test.ts` with governance coverage - -### Functional verification - -- Managed memory now has a reusable status surface covering cursor state, dynamic index, topic hooks, extraction running-state, recent dream/extraction results, and tracked dream tasks. -- `/memory status` now surfaces more operational context instead of only file counts. -- `/memory tasks` and `/memory inspect` provide direct governance entrypoints for recent task activity and current managed memory contents. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/status.test.ts src/memory/extract.test.ts src/memory/dreamScheduler.test.ts src/memory/dream.test.ts` - - `npm exec --workspace=packages/cli -- vitest run src/ui/commands/memoryCommand.test.ts` - -### Notes - -- Extraction still runs in-process rather than in the shared background task runtime, so governance currently reports extraction running-state separately from tracked dream tasks. -- This part focuses on observability and inspectability, not on adding an interactive review UI. - -### Status - -Completed - ---- - -## Part 16 - Forget loop closure - -### Start review - -- Overall plan now advances from observing managed memory to actively governing it with both add and remove flows. -- Part 15 already exposed richer status and inspect commands, but the system still had no explicit way to remove stale or unwanted managed entries. -- Scope for this part: add a safe managed-memory forget path, keep topic/index state synchronized after deletion, and expose that capability through built-in CLI commands. - -### Goal - -- Add managed auto-memory search/delete helpers for explicit forget requests -- Regenerate `MEMORY.md` after removals and restore placeholders when a topic becomes empty -- Add `/forget` plus `/memory forget` command entrypoints with user-visible feedback -- Add targeted tests for forget matching, topic/index updates, and command registration - -### Implemented - -- Added `packages/core/src/memory/forget.ts` -- Added `packages/core/src/memory/forget.test.ts` -- Exported forget helpers from `packages/core/src/index.ts` -- Added `packages/cli/src/ui/commands/forgetCommand.ts` -- Added `packages/cli/src/ui/commands/forgetCommand.test.ts` -- Updated `packages/cli/src/ui/commands/memoryCommand.ts` with `/memory forget` -- Updated `packages/cli/src/ui/commands/memoryCommand.test.ts` with forget coverage -- Updated `packages/cli/src/services/BuiltinCommandLoader.ts` and `packages/cli/src/services/BuiltinCommandLoader.test.ts` to register `/forget` - -### Functional verification - -- Managed memory now supports explicit forget operations by searching topic bullets and removing matching entries. -- Topic files restore `_No entries yet._` when all durable entries for a topic are removed. -- `MEMORY.md` stays synchronized after forget operations, so governance and prompt-loading still see the current state. -- Users can now invoke forget through either `/forget` or `/memory forget` and receive direct feedback. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/forget.test.ts src/memory/indexer.test.ts src/memory/status.test.ts` - - `npm exec --workspace=packages/cli -- vitest run src/ui/commands/forgetCommand.test.ts src/ui/commands/memoryCommand.test.ts src/services/BuiltinCommandLoader.test.ts` - -### Notes - -- This stage keeps forget matching mechanical by using case-insensitive substring matching over durable bullet entries. -- A later stage can layer model-assisted candidate selection or interactive confirmation on top of the same host-side deletion path. - -### Status - -Completed - ---- - -## Part 17 - Agent stage B observability and constrained tools - -### Start review - -- Overall plan now advances from basic tool-free agent planners to a more realistic constrained-agent runtime shape. -- Parts 11 to 13 already established `BackgroundAgentRunner` and initial dream/extraction agent consumers, but they still ran in a mostly JSON-only, tool-free configuration with limited task observability. -- Scope for this part: make the shared runner expose richer runtime metadata, support cancelled task results, and upgrade extraction/dream planners to a constrained read-only tool mode with larger budgets. - -### Goal - -- Improve `BackgroundAgentRunner` observability with budget, round, and touched-file tracking -- Support cancelled background agent results distinctly from generic failures -- Upgrade extraction and dream planners from tool-free stage A into constrained read-only multi-turn agents -- Add targeted tests for runner metadata, cancelled behavior, and planner configuration - -### Implemented - -- Updated `packages/core/src/background/taskScheduler.ts` to allow explicit final task status from background callbacks -- Updated `packages/core/src/background/backgroundAgentRunner.ts` to track budget metadata, current round, `filesTouched`, and cancelled outcomes -- Updated `packages/core/src/background/backgroundAgentRunner.test.ts` with filesTouched/round/cancelled coverage -- Updated `packages/core/src/memory/extractionAgentPlanner.ts` to expose topic file paths, increase agent budget, and allow constrained `read_file` use -- Updated `packages/core/src/memory/extractionAgentPlanner.test.ts` for the new stage-B planner configuration -- Updated `packages/core/src/memory/dreamAgentPlanner.ts` to expose topic file paths, increase agent budget, and allow constrained `read_file` use -- Updated `packages/core/src/memory/dreamAgentPlanner.test.ts` for the new stage-B planner configuration - -### Functional verification - -- Background agent tasks now expose more useful runtime metadata for governance and future UI work, including budget, rounds, and touched file paths. -- Cancelled headless-agent runs are now represented as cancelled outcomes instead of being collapsed into generic failures. -- Dream and extraction agent planners can now perform constrained read-only inspection of topic files when the provided summaries are insufficient, while still keeping host-side write application. - -### Test verification - -- Passed targeted tests: - - `npm run build --workspace=packages/core` - - `npm run typecheck --workspace=packages/core` - - `npm exec --workspace=packages/core -- vitest run src/background/backgroundAgentRunner.test.ts src/background/taskScheduler.test.ts src/memory/extractionAgentPlanner.test.ts src/memory/dreamAgentPlanner.test.ts src/memory/extractAgent.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts src/memory/extract.test.ts` - - `npm exec --workspace=packages/cli -- vitest run src/ui/commands/forgetCommand.test.ts src/ui/commands/memoryCommand.test.ts src/services/BuiltinCommandLoader.test.ts` - - `npm run generate && npm run build --workspace=packages/web-templates && npm run typecheck --workspace=packages/cli` - -### Notes - -- This stage still keeps host-side application of extraction patches and dream rewrites; agents only inspect and propose. -- The allowed tool surface is intentionally narrow (`read_file`) to keep the rollout low-risk while still exercising the shared background-agent runtime more realistically. - -### Status - -Completed - ---- - -## Part 6 - Auxiliary side-query foundation - -### Start review - -- Overall plan has shifted from memory-only MVP work to shared runtime infrastructure needed for model-driven recall, extraction, and future background work. -- Parts 1 to 5 already completed the memory MVP; the next slice should be reusable outside memory. -- Scope for this part: introduce a small, independently testable side-query foundation for structured auxiliary inference and migrate existing lightweight JSON inference call sites onto it. - -### Goal - -- Add a reusable side-query helper under a shared auxiliary module -- Centralize structured-response schema validation for lightweight auxiliary inference -- Migrate existing JSON-only helper call sites onto the new side-query layer -- Add targeted tests for helper behavior and migrated call sites - -### Implemented - -- Added `packages/core/src/auxiliary/sideQuery.ts` -- Added `packages/core/src/auxiliary/sideQuery.test.ts` -- Updated `packages/core/src/utils/nextSpeakerChecker.ts` to use shared side-query execution -- Updated `packages/core/src/utils/subagentGenerator.ts` to use shared side-query execution -- Exported side-query helpers from `packages/core/src/index.ts` - -### Functional verification - -- Structured auxiliary inference now has a shared entrypoint that defaults model selection, prompt IDs, and schema validation. -- Invalid structured side-query responses now fail fast through schema validation instead of each caller re-implementing checks. -- Existing next-speaker detection and subagent generation now run through the same auxiliary inference path while preserving their caller-facing behavior. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/utils/nextSpeakerChecker.test.ts src/utils/subagentGenerator.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/core/baseLlmClient.test.ts src/utils/schemaValidator.test.ts src/utils/nextSpeakerChecker.test.ts src/utils/subagentGenerator.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This part intentionally does not introduce background task scheduling or fork-agent execution yet. -- The new helper is scoped to lightweight, single-shot structured inference and serves as the first reusable building block for later model-driven memory work. - -### Status - -Completed - ---- - -## Part 7 - Model-driven recall selection - -### Start review - -- Overall plan now moves from shared side-query infrastructure to the first memory consumer on top of it. -- Part 6 already established a reusable auxiliary inference path, so the next slice should validate that platform with a real memory workflow. -- Scope for this part: upgrade relevant auto-memory recall from heuristic-only ranking to model-driven side-query selection with safe heuristic fallback and session-level surfacing dedupe. - -### Goal - -- Add a model-driven managed memory recall selector based on side-query -- Keep heuristic recall as a safe fallback path -- Avoid repeatedly surfacing the same managed memory files within one session -- Add targeted tests for selector behavior, fallback behavior, and client integration - -### Implemented - -- Added `packages/core/src/memory/relevanceSelector.ts` -- Added `packages/core/src/memory/relevanceSelector.test.ts` -- Updated `packages/core/src/memory/recall.ts` with model-driven resolution and excluded-file filtering -- Updated `packages/core/src/memory/recall.test.ts` with model/fallback coverage -- Updated `packages/core/src/core/client.ts` to track surfaced managed memory files per session -- Updated `packages/core/src/core/client.test.ts` to cover the new recall integration path -- Exported relevance selector helpers from `packages/core/src/index.ts` - -### Functional verification - -- Relevant auto-memory recall can now ask a lightweight side-query to choose the most relevant topic files from scanned memory candidates. -- If the model selector fails or returns invalid paths, recall safely falls back to the existing heuristic selector. -- Files already surfaced in the current session are excluded from later recall passes, reducing repeated prompt injection. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/relevanceSelector.test.ts src/memory/recall.test.ts src/core/client.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extract.test.ts src/memory/dream.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This part intentionally upgrades only recall selection; extraction and dream remain unchanged. -- Session-level surfacing dedupe is in-memory for the active client process and does not yet persist across restarts. - -### Status - -Completed - ---- - -## Part 8 - Side-query extraction patches - -### Start review - -- Overall plan now advances from recall selection to the next most valuable model-driven memory improvement: extraction quality. -- Part 7 already validated that side-query can safely drive memory decisions with fallback, so extraction is the next consumer. -- Scope for this part: keep host-side cursoring, patch application, and dedupe, but replace heuristic-only patch planning with a model-driven side-query planner plus heuristic fallback. - -### Goal - -- Add a side-query extraction planner that consumes transcript slices and topic summaries -- Keep host-side patch application, dedupe, and cursor updates unchanged -- Fallback safely to the existing heuristic extraction planner on model failure -- Add targeted tests for planner behavior, fallback behavior, and client integration - -### Implemented - -- Added `packages/core/src/memory/extractionPlanner.ts` -- Added `packages/core/src/memory/extractionPlanner.test.ts` -- Added `packages/core/src/memory/extractModel.test.ts` -- Updated `packages/core/src/memory/extract.ts` to use model-driven patch planning with heuristic fallback -- Updated `packages/core/src/core/client.ts` to pass `Config` into managed extraction scheduling -- Updated `packages/core/src/core/client.test.ts` with extraction config coverage -- Exported extraction planner helpers from `packages/core/src/index.ts` - -### Functional verification - -- Managed extraction now supports a side-query planner that consumes transcript slices plus current topic summaries and returns structured topic patches. -- If the planner fails or returns invalid output, extraction safely falls back to the existing heuristic patch extractor. -- Host-side cursor persistence, topic patch application, dedupe, and system messages remain unchanged. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/extractionPlanner.test.ts src/memory/extractModel.test.ts src/memory/extract.test.ts src/core/client.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extractionPlanner.test.ts src/memory/extract.test.ts src/memory/extractModel.test.ts src/memory/dream.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This part intentionally stays single-shot and structured; it does not introduce forked extractor agents or background task runtime yet. - -### Status - -Completed - ---- - -## Part 9 - Background task runtime foundation - -### Start review - -- Overall plan now shifts from side-query consumers to the next shared runtime layer needed for auto-dream and future fork-agent execution. -- Parts 6 to 8 already delivered the auxiliary inference layer plus memory recall/extraction consumers. -- Scope for this part: add a minimal background task runtime foundation with registry, scheduler, and drainer primitives, but do not yet wire automatic dream scheduling onto it. - -### Goal - -- Add a reusable background task registry with status updates and snapshots -- Add a scheduler that can run tracked tasks with simple dedupe behavior -- Add a drainer that waits for in-flight background work with timeout protection -- Add targeted tests for lifecycle updates, dedupe handling, and drain behavior - -### Implemented - -- Added `packages/core/src/background/taskRegistry.ts` -- Added `packages/core/src/background/taskDrainer.ts` -- Added `packages/core/src/background/taskScheduler.ts` -- Added `packages/core/src/background/taskRegistry.test.ts` -- Added `packages/core/src/background/taskDrainer.test.ts` -- Added `packages/core/src/background/taskScheduler.test.ts` -- Exported background runtime helpers from `packages/core/src/index.ts` - -### Functional verification - -- Background work now has a shared registry for task snapshots, updates, and subscriptions. -- Background tasks can be scheduled through a common scheduler with simple dedupe-key skipping. -- In-flight background work can be tracked and drained with timeout protection before shutdown. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/background/taskRegistry.test.ts src/background/taskDrainer.test.ts src/background/taskScheduler.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/background/taskRegistry.test.ts src/background/taskDrainer.test.ts src/background/taskScheduler.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extractionPlanner.test.ts src/memory/extract.test.ts src/memory/extractModel.test.ts src/memory/dream.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This part intentionally does not yet hook memory dream or extraction into the new background runtime. - -### Status - -Completed - ---- - -## Part 10 - Auto dream scheduling stage A - -### Start review - -- Overall plan now applies the new background runtime to the first real consumer: managed auto-memory dream. -- Part 9 already delivered the shared registry/scheduler/drainer layer, so this slice focuses on wiring mechanical dream into that runtime with minimal gating. -- Scope for this part: schedule mechanical dream in the background after user-query extraction, add consolidation lock and persisted dream gating metadata, but do not yet introduce model-driven dream rewriting or background agents. - -### Goal - -- Add a managed auto-memory dream scheduler built on the shared background runtime -- Add minimal persisted gating for dream cadence and same-session suppression -- Reuse the existing mechanical dream implementation as the task body -- Add targeted tests for gating, locking, scheduling, and client integration - -### Implemented - -- Added `packages/core/src/memory/dreamScheduler.ts` -- Added `packages/core/src/memory/dreamScheduler.test.ts` -- Extended `AutoMemoryMetadata` with dream scheduling fields -- Updated `packages/core/src/core/client.ts` to fire-and-forget dream scheduling after managed extraction -- Updated `packages/core/src/core/client.test.ts` to cover dream scheduling integration -- Exported dream scheduler helpers from `packages/core/src/index.ts` - -### Functional verification - -- Managed auto-memory dream can now be scheduled as a background task using the shared task registry, scheduler, and drainer. -- Dream scheduling persists minimal gating state in metadata, including `lastDreamAt`, `lastDreamSessionId`, and distinct sessions seen since the last dream. -- A consolidation lock now prevents concurrent dream execution for the same project, while the existing mechanical dream logic remains the execution core. -- User-query completion now asynchronously attempts dream scheduling without blocking the main response path. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/dreamScheduler.test.ts src/memory/dream.test.ts src/core/client.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/background/taskRegistry.test.ts src/background/taskDrainer.test.ts src/background/taskScheduler.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extractionPlanner.test.ts src/memory/extract.test.ts src/memory/extractModel.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This part intentionally keeps dream execution mechanical and background-only; task visualization and model-driven dream agents remain for later phases. - -### Status - -Completed - ---- - -## Part 11 - Background agent runner foundation - -### Start review - -- Overall plan now extends the background runtime from plain tasks to reusable headless agent orchestration. -- Part 10 already proved the scheduler/drainer path with mechanical dream tasks, so the next slice can safely wrap `AgentHeadless` itself. -- Scope for this part: add a shared `BackgroundAgentRunner` that binds task registry updates to `AgentEventEmitter`, but do not yet connect any memory workflow to it. - -### Goal - -- Add a reusable background agent runner on top of `AgentHeadless` -- Map core agent events into background task registry progress and metadata -- Reuse the shared background scheduler and drainer for lifecycle tracking -- Add targeted tests for success and failure execution paths - -### Implemented - -- Added `packages/core/src/background/backgroundAgentRunner.ts` -- Added `packages/core/src/background/backgroundAgentRunner.test.ts` -- Exported background agent runner helpers from `packages/core/src/index.ts` - -### Functional verification - -- Background work can now wrap `AgentHeadless` execution inside the shared task runtime. -- Core agent streaming/tool/usage events are mapped into task registry progress and metadata updates. -- Background agent execution is tracked by the shared scheduler/drainer and returns a summarized completion result. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/background/backgroundAgentRunner.test.ts src/background/taskScheduler.test.ts src/agents/runtime/agent-headless.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/background/taskRegistry.test.ts src/background/taskDrainer.test.ts src/background/taskScheduler.test.ts src/background/backgroundAgentRunner.test.ts src/agents/runtime/agent-headless.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extractionPlanner.test.ts src/memory/extract.test.ts src/memory/extractModel.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This part intentionally stops at runner infrastructure; memory extraction/dream agents remain for later phases. - -### Status - -Completed - ---- - -## Part 12 - Extraction agent consumer stage A - -### Start review - -- Overall plan now moves from shared background agent infrastructure to the first memory consumer that actually uses it. -- Part 11 already delivered `BackgroundAgentRunner`, so the next slice should connect managed extraction to it while preserving all existing fallbacks. -- Scope for this part: add an extraction agent planner that emits structured patches via `BackgroundAgentRunner`, then fall back to side-query planner and finally heuristic extraction when needed. - -### Goal - -- Add a managed extraction agent planner on top of `BackgroundAgentRunner` -- Parse and validate structured extraction patches from agent output -- Integrate the agent planner into the extraction fallback chain ahead of side-query/heuristic planning -- Add targeted tests for planner behavior and extraction integration - -### Implemented - -- Added `packages/core/src/memory/extractionAgentPlanner.ts` -- Added `packages/core/src/memory/extractionAgentPlanner.test.ts` -- Added `packages/core/src/memory/extractAgent.test.ts` -- Updated `packages/core/src/memory/extract.ts` to try extraction-agent planning before side-query and heuristic fallback -- Updated `packages/core/src/memory/extractModel.test.ts` to cover the new fallback order -- Exported extraction agent planner helpers from `packages/core/src/index.ts` - -### Functional verification - -- Managed extraction can now first invoke a tool-free background extraction agent through `BackgroundAgentRunner` and parse structured JSON patch output. -- If agent output is invalid or the agent fails, extraction safely falls back to the existing side-query planner and then to heuristic extraction. -- Host-side cursoring, patch application, and dedupe remain unchanged. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/extractionAgentPlanner.test.ts src/memory/extractAgent.test.ts src/memory/extractModel.test.ts src/memory/extract.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/auxiliary/sideQuery.test.ts src/background/taskRegistry.test.ts src/background/taskDrainer.test.ts src/background/taskScheduler.test.ts src/background/backgroundAgentRunner.test.ts src/agents/runtime/agent-headless.test.ts src/core/baseLlmClient.test.ts src/core/client.test.ts src/utils/schemaValidator.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/scan.test.ts src/memory/recall.test.ts src/memory/relevanceSelector.test.ts src/memory/state.test.ts src/memory/extractionAgentPlanner.test.ts src/memory/extractionPlanner.test.ts src/memory/extract.test.ts src/memory/extractAgent.test.ts src/memory/extractModel.test.ts src/memory/dream.test.ts src/memory/dreamScheduler.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This part intentionally keeps the extraction agent tool-free and JSON-only; constrained tool policies come later. - -### Status - -Completed - ---- - -## Part 1 - Managed auto-memory storage scaffold - -### Start review - -- Overall plan remains unchanged: build the full memory system incrementally, starting with the lowest-risk storage layer. -- Current repo baseline after Part 0: clean branch plus work log commit. -- Scope for this part: add independent managed auto-memory storage primitives without touching main prompt flow or existing `/memory` behavior. - -### Goal - -- Add managed auto-memory types -- Add managed auto-memory path helpers -- Add scaffold creation for `.qwen/memory/` -- Add tests for path stability, scaffold creation, idempotency, and read behavior - -### Implemented - -- Added `packages/core/src/memory/types.ts` -- Added `packages/core/src/memory/paths.ts` -- Added `packages/core/src/memory/store.ts` -- Added `packages/core/src/memory/store.test.ts` -- Exported the new modules from `packages/core/src/index.ts` - -### Functional verification - -- `ensureAutoMemoryScaffold(projectRoot)` now creates: - - `.qwen/memory/` - - `MEMORY.md` - - `meta.json` - - `extract-cursor.json` - - `user.md` - - `feedback.md` - - `project.md` - - `reference.md` -- Re-running scaffold creation preserves existing files. - -### Test verification - -- Passed targeted test: - - `npm exec --workspace=packages/core -- vitest run src/memory/store.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/utils/memoryDiscovery.test.ts src/core/prompts.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This part intentionally does not integrate auto-memory into prompt assembly yet. -- Existing `QWEN.md` / `AGENTS.md` behavior remains unchanged. - -### Status - -Completed - ---- - -## Part 2 - Managed auto-memory index prompt integration - -### Start review - -- Overall plan remains: storage first, then controlled prompt integration, then recall/extract/dream. -- Part 1 already established a safe on-disk scaffold under `.qwen/memory/`. -- Scope for this part: load the managed `MEMORY.md` index into prompt memory without changing existing `QWEN.md` / `AGENTS.md` discovery behavior. - -### Goal - -- Add managed auto-memory prompt formatting helpers -- Append managed auto-memory index after hierarchical memory when present -- Preserve legacy behavior when no managed index exists -- Add tests for prompt formatting and config integration - -### Implemented - -- Added `packages/core/src/memory/prompt.ts` -- Added `packages/core/src/memory/prompt.test.ts` -- Updated `Config.refreshHierarchicalMemory()` to append managed auto-memory index content -- Added config tests for merge and legacy fallback behavior -- Exported the new prompt helpers from `packages/core/src/index.ts` - -### Functional verification - -- If `.qwen/memory/MEMORY.md` exists, it is appended to `userMemory` as a dedicated `Managed Auto-Memory` block. -- If managed auto-memory does not exist, `userMemory` remains exactly the same as before. -- Oversized managed indexes are truncated to a safe prompt budget. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/prompt.test.ts src/config/config.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/core/prompts.test.ts src/utils/memoryDiscovery.test.ts src/memory/store.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This part only integrates the managed memory index, not relevant recall. -- Existing hierarchical memory file discovery is unchanged. -- Existing `save_memory` behavior is unchanged. - -### Status - -Completed - ---- - -## Part 3 - Relevant managed auto-memory recall - -### Start review - -- Overall plan remains: storage → managed index integration → relevant recall → extraction → dream and commands. -- Parts 1 and 2 already established the managed `.qwen/memory/` scaffold and appended `MEMORY.md` into `userMemory` safely. -- Scope for this part: add low-risk, query-sensitive relevant recall from managed topic files without changing legacy hierarchical memory discovery or `save_memory` semantics. - -### Goal - -- Scan managed auto-memory topic files into structured documents -- Select relevant managed memory for a user query -- Inject the relevant memory block into the per-request reminder path -- Add tests for scanning, recall selection, and client integration - -### Implemented - -- Added `packages/core/src/memory/scan.ts` -- Added `packages/core/src/memory/scan.test.ts` -- Added `packages/core/src/memory/recall.ts` -- Added `packages/core/src/memory/recall.test.ts` -- Updated `packages/core/src/core/client.ts` to prepend relevant managed auto-memory for `UserQuery` -- Updated `packages/core/src/core/client.test.ts` with recall prompt injection coverage -- Exported the new scan/recall helpers from `packages/core/src/index.ts` - -### Functional verification - -- Managed topic files are parsed into structured recall candidates with title/frontmatter/body support. -- User queries now receive a dedicated `Relevant Managed Auto-Memory` reminder block when matching managed topic content exists. -- If no managed topic files exist or no relevant content is found, request behavior remains unchanged. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/scan.test.ts src/memory/recall.test.ts src/core/client.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/config/config.test.ts src/core/prompts.test.ts src/utils/memoryDiscovery.test.ts src/memory/prompt.test.ts src/memory/store.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This part uses heuristic recall selection for a safe first integration point. -- The recall prompt is injected through the existing request reminder path, minimizing surface-area risk. -- Extraction and dream/consolidation are intentionally deferred to later parts. - -### Status - -Completed - ---- - -## Part 4 - Managed auto-memory extraction flow - -### Start review - -- Overall plan remains: storage → managed index integration → relevant recall → extraction → dream and commands. -- Parts 1 to 3 already provide scaffold, index loading, and request-time relevant recall. -- Scope for this part: add a safe MVP extraction pipeline that runs after a completed user query, tracks incremental cursor state, and writes durable summaries into managed topic files. - -### Goal - -- Add extraction running-state guards -- Add transcript slicing and cursor persistence -- Add heuristic durable-memory patch extraction and topic-file application -- Trigger extraction after completed user queries in the client flow -- Add tests for extraction state, cursor/idempotency, and client integration - -### Implemented - -- Added `packages/core/src/memory/state.ts` -- Added `packages/core/src/memory/state.test.ts` -- Added `packages/core/src/memory/extract.ts` -- Added `packages/core/src/memory/extract.test.ts` -- Updated `packages/core/src/core/client.ts` to trigger managed extraction after completed `UserQuery` turns -- Updated `packages/core/src/core/client.test.ts` with extraction integration coverage -- Exported the new extraction/state helpers from `packages/core/src/index.ts` - -### Functional verification - -- Managed extraction now reads the current session transcript incrementally using `extract-cursor.json`. -- Durable user statements are heuristically classified into `user`, `feedback`, `project`, or `reference` topic patches. -- Topic files are updated idempotently, metadata is bumped, and duplicate writes are avoided on repeated runs. -- Completed user queries can emit a `Managed auto-memory updated` system message when extraction writes topic files. -- Concurrent extraction attempts for the same project are skipped safely in-process. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/state.test.ts src/memory/extract.test.ts src/core/client.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/config/config.test.ts src/core/prompts.test.ts src/utils/memoryDiscovery.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/recall.test.ts src/memory/scan.test.ts src/memory/state.test.ts src/memory/extract.test.ts src/core/client.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - -### Notes - -- This MVP extraction path is intentionally heuristic and host-driven; it does not yet launch a dedicated extractor agent. -- Cursor persistence is session-aware and sufficient for incremental turn-end extraction in the current process model. -- Dream/consolidation and richer extraction prompts remain deferred to the next parts. - -### Status - -Completed - ---- - -## Part 5 - Dream and command entrypoints - -### Start review - -- Overall plan remains: storage → managed index integration → relevant recall → extraction → dream and commands. -- Parts 1 to 4 already provide the managed scaffold, prompt integration, recall, and turn-end extraction. -- Scope for this part: add a safe managed auto-memory dream/consolidation primitive plus basic `/memory`, `/dream`, and `/remember` command entrypoints in the CLI. - -### Goal - -- Add a managed auto-memory dream/consolidation function -- Enhance `/memory` with managed status and manual extraction entrypoints -- Add `/dream` and `/remember` built-in commands -- Register the new commands and add tests for command behavior and loader coverage - -### Implemented - -- Added `packages/core/src/memory/dream.ts` -- Added `packages/core/src/memory/dream.test.ts` -- Exported dream helpers from `packages/core/src/index.ts` -- Enhanced `packages/cli/src/ui/commands/memoryCommand.ts` with `status` and `extract-now` -- Added `packages/cli/src/ui/commands/dreamCommand.ts` -- Added `packages/cli/src/ui/commands/rememberCommand.ts` -- Added tests in `packages/cli/src/ui/commands/memoryCommand.test.ts` -- Added `packages/cli/src/ui/commands/dreamCommand.test.ts` -- Added `packages/cli/src/ui/commands/rememberCommand.test.ts` -- Updated `packages/cli/src/services/BuiltinCommandLoader.ts` and `packages/cli/src/services/BuiltinCommandLoader.test.ts` -- Added CLI i18n strings in `packages/cli/src/i18n/locales/en.js` and `packages/cli/src/i18n/locales/zh.js` - -### Functional verification - -- Managed dream now deduplicates topic-file bullet entries, restores the empty placeholder when needed, and updates metadata best-effort. -- `/memory status` shows the managed memory root, extract cursor summary, and per-topic entry counts. -- `/memory extract-now` manually runs managed extraction for the current session transcript and reports the outcome. -- `/dream` manually runs managed consolidation and reports changed topic files plus deduplication count. -- `/remember` provides a direct built-in entrypoint for `save_memory`, including optional project/global scope selection. -- New commands are registered in the built-in command loader. - -### Test verification - -- Passed targeted tests: - - `npm exec --workspace=packages/core -- vitest run src/memory/dream.test.ts` - - `npm exec --workspace=packages/cli -- vitest run src/ui/commands/memoryCommand.test.ts src/ui/commands/dreamCommand.test.ts src/ui/commands/rememberCommand.test.ts src/services/BuiltinCommandLoader.test.ts` -- Passed regression tests: - - `npm exec --workspace=packages/core -- vitest run src/config/config.test.ts src/core/prompts.test.ts src/utils/memoryDiscovery.test.ts src/memory/store.test.ts src/memory/prompt.test.ts src/memory/recall.test.ts src/memory/scan.test.ts src/memory/state.test.ts src/memory/extract.test.ts src/memory/dream.test.ts src/core/client.test.ts` - - `npm exec --workspace=packages/cli -- vitest run src/ui/commands/memoryCommand.test.ts src/ui/commands/dreamCommand.test.ts src/ui/commands/rememberCommand.test.ts src/services/BuiltinCommandLoader.test.ts` -- Passed typecheck: - - `npm run typecheck --workspace=packages/core` - - `npm run generate && npm run build --workspace=packages/web-templates && npm run typecheck --workspace=packages/cli` - -### Notes - -- This dream implementation is intentionally mechanical and low-risk; it deduplicates and normalizes managed memory rather than invoking a separate consolidation agent. -- `/memory` enhancement is kept minimal for MVP: status inspection and manual extraction trigger. -- The full staged implementation plan is now complete. - -### Status - -Completed - diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 06c944cb731..4687918069b 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -82,24 +82,10 @@ vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} })); vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} })); vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} })); vi.mock('../ui/commands/exportCommand.js', () => ({ exportCommand: {} })); -vi.mock('../ui/commands/dreamCommand.js', () => ({ - dreamCommand: { - name: 'dream', - description: 'Dream command', - kind: 'built-in', - }, -})); vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} })); vi.mock('../ui/commands/extensionsCommand.js', () => ({ extensionsCommand: {}, })); -vi.mock('../ui/commands/forgetCommand.js', () => ({ - forgetCommand: { - name: 'forget', - description: 'Forget command', - kind: 'built-in', - }, -})); vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} })); vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} })); vi.mock('../ui/commands/insightCommand.js', () => ({ insightCommand: {} })); @@ -109,13 +95,6 @@ vi.mock('../ui/commands/modelCommand.js', () => ({ vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {}, })); -vi.mock('../ui/commands/rememberCommand.js', () => ({ - rememberCommand: { - name: 'remember', - description: 'Remember command', - kind: 'built-in', - }, -})); vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} })); vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} })); vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} })); @@ -206,14 +185,6 @@ describe('BuiltinCommandLoader', () => { const modelCmd = commands.find((c) => c.name === 'model'); expect(modelCmd).toBeDefined(); - const dreamCmd = commands.find((c) => c.name === 'dream'); - expect(dreamCmd).toBeDefined(); - - const forgetCmd = commands.find((c) => c.name === 'forget'); - expect(forgetCmd).toBeDefined(); - - const rememberCmd = commands.find((c) => c.name === 'remember'); - expect(rememberCmd).toBeDefined(); }); it('should include trust command when folder trust is enabled', async () => { diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 7aadf039625..dcd03d545d9 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -19,22 +19,22 @@ import { compressCommand } from '../ui/commands/compressCommand.js'; import { contextCommand } from '../ui/commands/contextCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; -import { dreamCommand } from '../ui/commands/dreamCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { exportCommand } from '../ui/commands/exportCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; -import { forgetCommand } from '../ui/commands/forgetCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; import { initCommand } from '../ui/commands/initCommand.js'; import { languageCommand } from '../ui/commands/languageCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; +import { dreamCommand } from '../ui/commands/dreamCommand.js'; +import { forgetCommand } from '../ui/commands/forgetCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { modelCommand } from '../ui/commands/modelCommand.js'; -import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { rememberCommand } from '../ui/commands/rememberCommand.js'; +import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { trustCommand } from '../ui/commands/trustCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; @@ -78,22 +78,22 @@ export class BuiltinCommandLoader implements ICommandLoader { contextCommand, copyCommand, docsCommand, - dreamCommand, directoryCommand, editorCommand, exportCommand, extensionsCommand, - forgetCommand, helpCommand, ...(this.config?.getEnableHooks() ? [hooksCommand] : []), await ideCommand(), initCommand, languageCommand, mcpCommand, + dreamCommand, + forgetCommand, memoryCommand, modelCommand, - permissionsCommand, rememberCommand, + permissionsCommand, ...(this.config?.getFolderTrust() ? [trustCommand] : []), quitCommand, restoreCommand(this.config), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 37dc325180f..ef1f7df24e2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -111,6 +111,7 @@ import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js'; import { useMcpDialog } from './hooks/useMcpDialog.js'; import { useHooksDialog } from './hooks/useHooksDialog.js'; +import { useMemoryDialog } from './hooks/useMemoryDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; import { requestConsentInteractive, @@ -518,6 +519,8 @@ export const AppContainer = (props: AppContainerProps) => { const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = useSettingsCommand(); + const { isMemoryDialogOpen, openMemoryDialog, closeMemoryDialog } = + useMemoryDialog(); const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); @@ -562,6 +565,7 @@ export const AppContainer = (props: AppContainerProps) => { openAuthDialog, openThemeDialog, openEditorDialog, + openMemoryDialog, openSettingsDialog, openModelDialog, openTrustDialog, @@ -589,6 +593,7 @@ export const AppContainer = (props: AppContainerProps) => { openAuthDialog, openThemeDialog, openEditorDialog, + openMemoryDialog, openSettingsDialog, openModelDialog, openArenaDialog, @@ -1153,6 +1158,8 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, isSettingsDialogOpen, closeSettingsDialog, + isMemoryDialogOpen, + closeMemoryDialog, activeArenaDialog, closeArenaDialog, isFolderTrustDialogOpen, @@ -1434,6 +1441,7 @@ export const AppContainer = (props: AppContainerProps) => { !!loopDetectionConfirmationRequest || isThemeDialogOpen || isSettingsDialogOpen || + isMemoryDialogOpen || isModelDialogOpen || isTrustDialogOpen || activeArenaDialog !== null || @@ -1488,6 +1496,7 @@ export const AppContainer = (props: AppContainerProps) => { debugMessage, quittingMessages, isSettingsDialogOpen, + isMemoryDialogOpen, isModelDialogOpen, isTrustDialogOpen, activeArenaDialog, @@ -1590,6 +1599,7 @@ export const AppContainer = (props: AppContainerProps) => { debugMessage, quittingMessages, isSettingsDialogOpen, + isMemoryDialogOpen, isModelDialogOpen, isTrustDialogOpen, activeArenaDialog, @@ -1684,6 +1694,7 @@ export const AppContainer = (props: AppContainerProps) => { () => ({ openThemeDialog, openEditorDialog, + openMemoryDialog, handleThemeSelect, handleThemeHighlight, handleApprovalModeSelect, @@ -1696,6 +1707,7 @@ export const AppContainer = (props: AppContainerProps) => { handleEditorSelect, exitEditorDialog, closeSettingsDialog, + closeMemoryDialog, closeModelDialog, openArenaDialog, closeArenaDialog, @@ -1742,6 +1754,7 @@ export const AppContainer = (props: AppContainerProps) => { [ openThemeDialog, openEditorDialog, + openMemoryDialog, handleThemeSelect, handleThemeHighlight, handleApprovalModeSelect, @@ -1754,6 +1767,7 @@ export const AppContainer = (props: AppContainerProps) => { handleEditorSelect, exitEditorDialog, closeSettingsDialog, + closeMemoryDialog, closeModelDialog, openArenaDialog, closeArenaDialog, diff --git a/packages/cli/src/ui/commands/dreamCommand.test.ts b/packages/cli/src/ui/commands/dreamCommand.test.ts deleted file mode 100644 index c62fc3cf908..00000000000 --- a/packages/cli/src/ui/commands/dreamCommand.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { dreamCommand } from './dreamCommand.js'; -import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { runManagedAutoMemoryDream } from '@qwen-code/qwen-code-core'; - -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - runManagedAutoMemoryDream: vi.fn(), - }; -}); - -describe('dreamCommand', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns an error when config is unavailable', async () => { - const context = createMockCommandContext({ services: { config: null } }); - const result = await dreamCommand.action?.(context, ''); - - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Config not loaded.', - }); - }); - - it('runs managed auto-memory dream and returns the summary', async () => { - vi.mocked(runManagedAutoMemoryDream).mockResolvedValue({ - touchedTopics: ['user'], - dedupedEntries: 2, - systemMessage: 'Managed auto-memory dream updated: user.md', - }); - const context = createMockCommandContext({ - services: { config: { getProjectRoot: vi.fn().mockReturnValue('/test/project') } as never }, - }); - - const result = await dreamCommand.action?.(context, ''); - - expect(runManagedAutoMemoryDream).toHaveBeenCalledWith('/test/project'); - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: 'Managed auto-memory dream updated: user.md\nDeduplicated entries: 2', - }); - }); -}); \ No newline at end of file diff --git a/packages/cli/src/ui/commands/dreamCommand.ts b/packages/cli/src/ui/commands/dreamCommand.ts index 7da7459d4cf..8c2360cad6b 100644 --- a/packages/cli/src/ui/commands/dreamCommand.ts +++ b/packages/cli/src/ui/commands/dreamCommand.ts @@ -4,9 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - runManagedAutoMemoryDream, -} from '@qwen-code/qwen-code-core'; +import { runManagedAutoMemoryDream } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; @@ -27,7 +25,11 @@ export const dreamCommand: SlashCommand = { }; } - const result = await runManagedAutoMemoryDream(config.getProjectRoot()); + const result = await runManagedAutoMemoryDream( + config.getProjectRoot(), + new Date(), + config, + ); return { type: 'message', messageType: 'info', @@ -38,4 +40,4 @@ export const dreamCommand: SlashCommand = { : t('Managed auto-memory dream found nothing to improve.'), }; }, -}; \ No newline at end of file +}; diff --git a/packages/cli/src/ui/commands/forgetCommand.test.ts b/packages/cli/src/ui/commands/forgetCommand.test.ts deleted file mode 100644 index fae46f96a0b..00000000000 --- a/packages/cli/src/ui/commands/forgetCommand.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it, vi } from 'vitest'; -import { - forgetManagedAutoMemoryMatches, - selectManagedAutoMemoryForgetCandidates, -} from '@qwen-code/qwen-code-core'; -import { forgetCommand } from './forgetCommand.js'; -import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; - -vi.mock('@qwen-code/qwen-code-core', () => ({ - forgetManagedAutoMemoryMatches: vi.fn(), - selectManagedAutoMemoryForgetCandidates: vi.fn(), -})); - -describe('forgetCommand', () => { - it('returns usage error when no args are provided', async () => { - const result = await forgetCommand.action?.(createMockCommandContext(), ' '); - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Usage: /forget [--apply] ', - }); - }); - - it('previews matching managed auto-memory entries by default', async () => { - vi.mocked(selectManagedAutoMemoryForgetCandidates).mockResolvedValue({ - strategy: 'model', - reasoning: 'Best semantic match.', - matches: [{ topic: 'user', summary: 'User prefers terse responses.' }], - }); - - const result = await forgetCommand.action?.( - createMockCommandContext({ - services: { - config: { - getProjectRoot: vi.fn().mockReturnValue('/test/project'), - }, - }, - }), - 'terse', - ); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: ['Forget preview (strategy=model):', 'Best semantic match.', '1. user: User prefers terse responses.', '', 'Run /forget --apply terse to apply these removals.'].join('\n'), - }); - }); - - it('removes matching managed auto-memory entries after --apply confirmation', async () => { - vi.mocked(selectManagedAutoMemoryForgetCandidates).mockResolvedValue({ - strategy: 'heuristic', - matches: [{ topic: 'user', summary: 'User prefers terse responses.' }], - }); - vi.mocked(forgetManagedAutoMemoryMatches).mockResolvedValue({ - query: '', - removedEntries: [{ topic: 'user', summary: 'User prefers terse responses.' }], - touchedTopics: ['user'], - systemMessage: 'Managed auto-memory forgot 1 entry from user.md', - }); - - const result = await forgetCommand.action?.( - createMockCommandContext({ - services: { - config: { - getProjectRoot: vi.fn().mockReturnValue('/test/project'), - }, - }, - }), - '--apply terse', - ); - - expect(selectManagedAutoMemoryForgetCandidates).toHaveBeenCalledWith( - '/test/project', - 'terse', - { - config: expect.objectContaining({ - getProjectRoot: expect.any(Function), - }), - }, - ); - expect(forgetManagedAutoMemoryMatches).toHaveBeenCalledWith('/test/project', [ - { topic: 'user', summary: 'User prefers terse responses.' }, - ]); - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: 'Managed auto-memory forgot 1 entry from user.md', - }); - }); -}); diff --git a/packages/cli/src/ui/commands/forgetCommand.ts b/packages/cli/src/ui/commands/forgetCommand.ts index dfeb765052a..06c81b6ba98 100644 --- a/packages/cli/src/ui/commands/forgetCommand.ts +++ b/packages/cli/src/ui/commands/forgetCommand.ts @@ -24,6 +24,7 @@ export const forgetCommand: SlashCommand = { const query = apply ? trimmedArgs.slice('--apply '.length).trim() : trimmedArgs; + if (!query) { return { type: 'message', @@ -41,12 +42,13 @@ export const forgetCommand: SlashCommand = { }; } + const selection = await selectManagedAutoMemoryForgetCandidates( + config.getProjectRoot(), + query, + { config }, + ); + if (!apply) { - const selection = await selectManagedAutoMemoryForgetCandidates( - config.getProjectRoot(), - query, - { config }, - ); return { type: 'message', messageType: 'info', @@ -70,11 +72,6 @@ export const forgetCommand: SlashCommand = { }; } - const selection = await selectManagedAutoMemoryForgetCandidates( - config.getProjectRoot(), - query, - { config }, - ); const result = await forgetManagedAutoMemoryMatches( config.getProjectRoot(), selection.matches, diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index babf1880582..001b9de2b64 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -4,877 +4,36 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Mock } from 'vitest'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { memoryCommand } from './memoryCommand.js'; -import type { SlashCommand, CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { MessageType } from '../types.js'; -import type { LoadedSettings } from '../../config/settings.js'; -import { readFile } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { - AUTO_MEMORY_TYPES, - getErrorMessage, - getManagedAutoMemoryStatus, - forgetManagedAutoMemoryMatches, - loadServerHierarchicalMemory, - QWEN_DIR, - reviewManagedAutoMemoryGovernance, - scheduleAutoMemoryExtract, - selectManagedAutoMemoryForgetCandidates, - setGeminiMdFilename, - type FileDiscoveryService, - type LoadServerHierarchicalMemoryResponse, -} from '@qwen-code/qwen-code-core'; - -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const original = - await importOriginal(); - return { - ...original, - getErrorMessage: vi.fn((error: unknown) => { - if (error instanceof Error) return error.message; - return String(error); - }), - getManagedAutoMemoryStatus: vi.fn(), - forgetManagedAutoMemoryMatches: vi.fn(), - loadServerHierarchicalMemory: vi.fn(), - reviewManagedAutoMemoryGovernance: vi.fn(), - scheduleAutoMemoryExtract: vi.fn(), - selectManagedAutoMemoryForgetCandidates: vi.fn(), - }; -}); - -vi.mock('node:fs/promises', () => { - const readFile = vi.fn(); - return { - readFile, - default: { - readFile, - }, - }; -}); - -const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock; -const mockScheduleAutoMemoryExtract = scheduleAutoMemoryExtract as Mock; -const mockGetManagedAutoMemoryStatus = getManagedAutoMemoryStatus as Mock; -const mockForgetManagedAutoMemoryMatches = forgetManagedAutoMemoryMatches as Mock; -const mockReviewManagedAutoMemoryGovernance = reviewManagedAutoMemoryGovernance as Mock; -const mockSelectManagedAutoMemoryForgetCandidates = selectManagedAutoMemoryForgetCandidates as Mock; -const mockReadFile = readFile as unknown as Mock; describe('memoryCommand', () => { - let mockContext: CommandContext; - - const getSubCommand = (name: 'show' | 'add' | 'refresh' | 'status' | 'tasks' | 'inspect' | 'review' | 'forget'): SlashCommand => { - const subCommand = memoryCommand.subCommands?.find( - (cmd) => cmd.name === name, - ); - if (!subCommand) { - throw new Error(`/memory ${name} command not found.`); - } - return subCommand; - }; - - describe('/memory show', () => { - let showCommand: SlashCommand; - let mockGetUserMemory: Mock; - let mockGetGeminiMdFileCount: Mock; - - beforeEach(() => { - setGeminiMdFilename('QWEN.md'); - mockReadFile.mockReset(); - vi.restoreAllMocks(); - - showCommand = getSubCommand('show'); - - mockGetUserMemory = vi.fn(); - mockGetGeminiMdFileCount = vi.fn(); - - mockContext = createMockCommandContext({ - services: { - config: { - getUserMemory: mockGetUserMemory, - getGeminiMdFileCount: mockGetGeminiMdFileCount, - }, - }, - }); - }); - - it('should display a message if memory is empty', async () => { - if (!showCommand.action) throw new Error('Command has no action'); - - mockGetUserMemory.mockReturnValue(''); - mockGetGeminiMdFileCount.mockReturnValue(0); - - await showCommand.action(mockContext, ''); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Memory is currently empty.', - }, - expect.any(Number), - ); - }); - - it('should display the memory content and file count if it exists', async () => { - if (!showCommand.action) throw new Error('Command has no action'); - - const memoryContent = 'This is a test memory.'; - - mockGetUserMemory.mockReturnValue(memoryContent); - mockGetGeminiMdFileCount.mockReturnValue(1); - - await showCommand.action(mockContext, ''); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `Current memory content from 1 file(s):\n\n---\n${memoryContent}\n---`, - }, - expect.any(Number), - ); - }); - - it('should show project memory from the configured context file', async () => { - const projectCommand = showCommand.subCommands?.find( - (cmd) => cmd.name === '--project', - ); - if (!projectCommand?.action) throw new Error('Command has no action'); - - setGeminiMdFilename('AGENTS.md'); - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - mockReadFile.mockResolvedValue('project memory'); - - await projectCommand.action(mockContext, ''); - - const expectedProjectPath = path.join('/test/project', 'AGENTS.md'); - expect(mockReadFile).toHaveBeenCalledWith(expectedProjectPath, 'utf-8'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: expect.stringContaining(expectedProjectPath), - }, - expect.any(Number), - ); - }); - - it('should show global memory from the configured context file', async () => { - const globalCommand = showCommand.subCommands?.find( - (cmd) => cmd.name === '--global', - ); - if (!globalCommand?.action) throw new Error('Command has no action'); - - setGeminiMdFilename('AGENTS.md'); - vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); - mockReadFile.mockResolvedValue('global memory'); - - await globalCommand.action(mockContext, ''); - - const expectedGlobalPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md'); - expect(mockReadFile).toHaveBeenCalledWith(expectedGlobalPath, 'utf-8'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: expect.stringContaining('Global memory content'), - }, - expect.any(Number), - ); - }); - - it('should fall back to AGENTS.md when QWEN.md does not exist for --project', async () => { - const projectCommand = showCommand.subCommands?.find( - (cmd) => cmd.name === '--project', - ); - if (!projectCommand?.action) throw new Error('Command has no action'); - - setGeminiMdFilename(['QWEN.md', 'AGENTS.md']); - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - mockReadFile.mockImplementation(async (filePath: string) => { - if (filePath.endsWith('AGENTS.md')) return 'agents memory content'; - throw new Error('ENOENT'); - }); - - await projectCommand.action(mockContext, ''); - - const expectedPath = path.join('/test/project', 'AGENTS.md'); - expect(mockReadFile).toHaveBeenCalledWith(expectedPath, 'utf-8'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: expect.stringContaining('agents memory content'), - }, - expect.any(Number), - ); - }); - - it('should fall back to AGENTS.md when QWEN.md does not exist for --global', async () => { - const globalCommand = showCommand.subCommands?.find( - (cmd) => cmd.name === '--global', - ); - if (!globalCommand?.action) throw new Error('Command has no action'); - - setGeminiMdFilename(['QWEN.md', 'AGENTS.md']); - vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); - mockReadFile.mockImplementation(async (filePath: string) => { - if (filePath.endsWith('AGENTS.md')) return 'global agents memory'; - throw new Error('ENOENT'); - }); - - await globalCommand.action(mockContext, ''); - - const expectedPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md'); - expect(mockReadFile).toHaveBeenCalledWith(expectedPath, 'utf-8'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: expect.stringContaining('global agents memory'), - }, - expect.any(Number), - ); - }); - - it('should show content from both QWEN.md and AGENTS.md for --project when both exist', async () => { - const projectCommand = showCommand.subCommands?.find( - (cmd) => cmd.name === '--project', - ); - if (!projectCommand?.action) throw new Error('Command has no action'); - - setGeminiMdFilename(['QWEN.md', 'AGENTS.md']); - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - mockReadFile.mockImplementation(async (filePath: string) => { - if (filePath.endsWith('QWEN.md')) return 'qwen memory'; - if (filePath.endsWith('AGENTS.md')) return 'agents memory'; - throw new Error('ENOENT'); - }); - - await projectCommand.action(mockContext, ''); - - expect(mockReadFile).toHaveBeenCalledWith( - path.join('/test/project', 'QWEN.md'), - 'utf-8', - ); - expect(mockReadFile).toHaveBeenCalledWith( - path.join('/test/project', 'AGENTS.md'), - 'utf-8', - ); - const addItemCall = (mockContext.ui.addItem as Mock).mock.calls[0][0]; - expect(addItemCall.text).toContain('qwen memory'); - expect(addItemCall.text).toContain('agents memory'); - }); - - it('should show content from both files for --global when both exist', async () => { - const globalCommand = showCommand.subCommands?.find( - (cmd) => cmd.name === '--global', - ); - if (!globalCommand?.action) throw new Error('Command has no action'); - - setGeminiMdFilename(['QWEN.md', 'AGENTS.md']); - vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); - mockReadFile.mockImplementation(async (filePath: string) => { - if (filePath.endsWith('QWEN.md')) return 'global qwen memory'; - if (filePath.endsWith('AGENTS.md')) return 'global agents memory'; - throw new Error('ENOENT'); - }); - - await globalCommand.action(mockContext, ''); - - expect(mockReadFile).toHaveBeenCalledWith( - path.join('/home/user', QWEN_DIR, 'QWEN.md'), - 'utf-8', - ); - expect(mockReadFile).toHaveBeenCalledWith( - path.join('/home/user', QWEN_DIR, 'AGENTS.md'), - 'utf-8', - ); - const addItemCall = (mockContext.ui.addItem as Mock).mock.calls[0][0]; - expect(addItemCall.text).toContain('global qwen memory'); - expect(addItemCall.text).toContain('global agents memory'); - }); - }); - - describe('/memory status', () => { - let statusCommand: SlashCommand; - - beforeEach(() => { - statusCommand = memoryCommand.subCommands?.find( - (cmd) => cmd.name === 'status', - ) as SlashCommand; - mockGetManagedAutoMemoryStatus.mockReset(); - mockContext = createMockCommandContext({ - services: { - config: { - getProjectRoot: vi.fn().mockReturnValue('/test/project'), - }, - }, - }); - }); - - it('shows managed auto-memory root, cursor and topic counts', async () => { - mockGetManagedAutoMemoryStatus.mockResolvedValue({ - root: '/test/project/.qwen/memory', - indexPath: '/test/project/.qwen/memory/MEMORY.md', - indexContent: '# Managed Auto-Memory Index', - cursor: { - sessionId: 'session-1', - processedOffset: 3, - updatedAt: '2026-04-01T00:00:00.000Z', - }, - metadata: { - version: 1, - createdAt: '2026-04-01T00:00:00.000Z', - updatedAt: '2026-04-01T00:00:00.000Z', - lastExtractionAt: '2026-04-01T00:00:00.000Z', - lastExtractionStatus: 'updated', - lastExtractionTouchedTopics: ['user'], - lastDreamAt: '2026-04-01T01:00:00.000Z', - lastDreamStatus: 'noop', - lastDreamTouchedTopics: [], - }, - extractionRunning: false, - extractionTasks: [], - dreamTasks: [], - topics: AUTO_MEMORY_TYPES.map((topic) => ({ - topic, - title: topic, - entryCount: 2, - hooks: ['one', 'two'], - filePath: `/test/project/.qwen/memory/${topic}.md`, - })), - }); - - await statusCommand.action?.(mockContext, ''); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.INFO, - text: expect.stringContaining('Managed auto-memory root: /test/project/.qwen/memory'), - }), - expect.any(Number), - ); - const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; - expect(text).toContain('Cursor: session=session-1, offset=3'); - expect(text).toContain('- user.md: 2 entries'); - expect(text).toContain('Extraction: running=no'); - expect(text).toContain('Extraction tasks: active=0, tracked=0'); - expect(text).toContain('Dream: last=2026-04-01T01:00:00.000Z'); + it('opens the memory dialog in interactive mode', async () => { + const context = createMockCommandContext({ + executionMode: 'interactive', }); - }); - - describe('/memory tasks', () => { - let tasksCommand: SlashCommand; - - beforeEach(() => { - tasksCommand = getSubCommand('tasks'); - mockGetManagedAutoMemoryStatus.mockReset(); - mockContext = createMockCommandContext({ - services: { - config: { - getProjectRoot: vi.fn().mockReturnValue('/test/project'), - }, - }, - }); - }); - - it('shows extraction and dream task state', async () => { - mockGetManagedAutoMemoryStatus.mockResolvedValue({ - root: '/test/project/.qwen/memory', - indexPath: '/test/project/.qwen/memory/MEMORY.md', - indexContent: '', - extractionRunning: true, - extractionTasks: [ - { - id: 'extract-1', - taskType: 'managed-auto-memory-extraction', - title: 'Managed auto-memory extraction', - projectRoot: '/test/project', - status: 'pending', - createdAt: '2026-04-01T00:00:00.000Z', - updatedAt: '2026-04-01T00:00:10.000Z', - progressText: 'Queued trailing extraction', - metadata: { - trailing: true, - queuedBehindTaskId: 'extract-0', - historyLength: 6, - }, - }, - ], - topics: [], - dreamTasks: [ - { - id: 'dream-1', - taskType: 'managed-auto-memory-dream', - title: 'Managed auto-memory dream', - projectRoot: '/test/project', - status: 'running', - createdAt: '2026-04-01T00:00:00.000Z', - updatedAt: '2026-04-01T00:01:00.000Z', - progressText: 'Consolidating topics', - }, - ], - }); - - await tasksCommand.action?.(mockContext, ''); - - const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; - expect(text).toContain('extraction lane: running | active=1 | tracked=1'); - expect(text).toContain('Extraction timeline:'); - expect(text).toContain('extract-1: pending'); - expect(text).toContain('trailing=yes'); - expect(text).toContain('Dream timeline:'); - expect(text).toContain('dream-1: running'); - }); - }); - - describe('/memory inspect', () => { - let inspectCommand: SlashCommand; - beforeEach(() => { - inspectCommand = getSubCommand('inspect'); - mockGetManagedAutoMemoryStatus.mockReset(); - mockReadFile.mockReset(); - mockContext = createMockCommandContext({ - services: { - config: { - getProjectRoot: vi.fn().mockReturnValue('/test/project'), - }, - }, - }); - }); - - it('shows the managed index by default', async () => { - mockGetManagedAutoMemoryStatus.mockResolvedValue({ - root: '/test/project/.qwen/memory', - indexPath: '/test/project/.qwen/memory/MEMORY.md', - indexContent: '# Managed Auto-Memory Index\n\n- hook', - extractionRunning: false, - extractionTasks: [], - topics: [], - dreamTasks: [], - }); - - await inspectCommand.action?.(mockContext, ''); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - text: '# Managed Auto-Memory Index\n\n- hook', - }), - expect.any(Number), - ); - }); + const result = await memoryCommand.action?.(context, ''); - it('shows a topic file when a valid topic is requested', async () => { - mockGetManagedAutoMemoryStatus.mockResolvedValue({ - root: '/test/project/.qwen/memory', - indexPath: '/test/project/.qwen/memory/MEMORY.md', - indexContent: '# Managed Auto-Memory Index', - extractionRunning: false, - extractionTasks: [], - topics: [], - dreamTasks: [], - }); - mockReadFile.mockResolvedValue('# User Memory\n\n- User prefers terse responses.'); - - await inspectCommand.action?.(mockContext, 'user'); - - expect(mockReadFile).toHaveBeenCalledWith( - '/test/project/.qwen/memory/user.md', - 'utf-8', - ); - const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; - expect(text).toContain('User prefers terse responses.'); + expect(result).toEqual({ + type: 'dialog', + dialog: 'memory', }); }); - describe('/memory review', () => { - let reviewCommand: SlashCommand; - - beforeEach(() => { - reviewCommand = getSubCommand('review'); - mockReviewManagedAutoMemoryGovernance.mockReset(); - mockContext = createMockCommandContext({ - services: { - config: { - getProjectRoot: vi.fn().mockReturnValue('/test/project'), - }, - }, - }); + it('returns a non-interactive fallback message outside the interactive UI', async () => { + const context = createMockCommandContext({ + executionMode: 'non_interactive', }); - it('shows governance review suggestions', async () => { - mockReviewManagedAutoMemoryGovernance.mockResolvedValue({ - strategy: 'heuristic', - suggestions: [ - { - type: 'promote', - topic: 'user', - summary: 'User prefers terse responses.', - rationale: 'Needs richer metadata.', - }, - ], - }); - - await reviewCommand.action?.(mockContext, ''); - - const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; - expect(text).toContain('governance review'); - expect(text).toContain('[promote] user: User prefers terse responses.'); - }); - }); - - describe('/memory extract-now', () => { - let extractCommand: SlashCommand; - - beforeEach(() => { - extractCommand = memoryCommand.subCommands?.find( - (cmd) => cmd.name === 'extract-now', - ) as SlashCommand; - mockScheduleAutoMemoryExtract.mockReset(); - mockContext = createMockCommandContext({ - services: { - config: { - getProjectRoot: vi.fn().mockReturnValue('/test/project'), - getSessionId: vi.fn().mockReturnValue('session-1'), - getGeminiClient: vi.fn().mockReturnValue({ - getChat: vi.fn().mockReturnValue({ - getHistory: vi.fn().mockReturnValue([ - { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, - ]), - }), - }), - }, - }, - }); - }); - - it('runs extraction and shows the returned system message', async () => { - mockScheduleAutoMemoryExtract.mockResolvedValue({ - patches: [], - touchedTopics: ['user'], - cursor: { updatedAt: '2026-04-01T00:00:00.000Z' }, - systemMessage: 'Managed auto-memory updated: user.md', - }); - - await extractCommand.action?.(mockContext, ''); - - expect(mockScheduleAutoMemoryExtract).toHaveBeenCalled(); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Managed auto-memory updated: user.md', - }, - expect.any(Number), - ); - }); - }); - - describe('/memory forget', () => { - let forgetCommand: SlashCommand; - - beforeEach(() => { - forgetCommand = getSubCommand('forget'); - mockForgetManagedAutoMemoryMatches.mockReset(); - mockSelectManagedAutoMemoryForgetCandidates.mockReset(); - mockContext = createMockCommandContext({ - services: { - config: { - getProjectRoot: vi.fn().mockReturnValue('/test/project'), - }, - }, - }); - }); - - it('returns usage error when no args are provided', async () => { - const result = await forgetCommand.action?.(mockContext, ' '); - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Usage: /memory forget [--apply] ', - }); - }); - - it('previews matching managed memory entries by default', async () => { - mockSelectManagedAutoMemoryForgetCandidates.mockResolvedValue({ - strategy: 'model', - reasoning: 'Best semantic match.', - matches: [{ topic: 'user', summary: 'User prefers terse responses.' }], - }); - - await forgetCommand.action?.(mockContext, 'terse'); - - const text = (mockContext.ui.addItem as Mock).mock.calls[0][0].text; - expect(text).toContain('Forget preview (strategy=model):'); - expect(text).toContain('1. user: User prefers terse responses.'); - expect(text).toContain('/memory forget --apply terse'); - }); - - it('forgets matching managed memory entries after --apply confirmation', async () => { - mockSelectManagedAutoMemoryForgetCandidates.mockResolvedValue({ - strategy: 'heuristic', - matches: [{ topic: 'user', summary: 'User prefers terse responses.' }], - }); - mockForgetManagedAutoMemoryMatches.mockResolvedValue({ - query: '', - removedEntries: [{ topic: 'user', summary: 'User prefers terse responses.' }], - touchedTopics: ['user'], - systemMessage: 'Managed auto-memory forgot 1 entry from user.md', - }); - - await forgetCommand.action?.(mockContext, '--apply terse'); - - expect(mockForgetManagedAutoMemoryMatches).toHaveBeenCalledWith( - '/test/project', - [{ topic: 'user', summary: 'User prefers terse responses.' }], - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Managed auto-memory forgot 1 entry from user.md', - }, - expect.any(Number), - ); - }); - }); - - describe('/memory add', () => { - let addCommand: SlashCommand; - - beforeEach(() => { - addCommand = getSubCommand('add'); - mockContext = createMockCommandContext(); - }); - - it('should return an error message if no arguments are provided', () => { - if (!addCommand.action) throw new Error('Command has no action'); - - const result = addCommand.action(mockContext, ' '); - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Usage: /memory add [--global|--project] ', - }); - - expect(mockContext.ui.addItem).not.toHaveBeenCalled(); - }); - - it('should return a tool action and add an info message when arguments are provided', () => { - if (!addCommand.action) throw new Error('Command has no action'); - - const fact = 'remember this'; - const result = addCommand.action(mockContext, ` ${fact} `); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `Attempting to save to memory : "${fact}"`, - }, - expect.any(Number), - ); - - expect(result).toEqual({ - type: 'tool', - toolName: 'save_memory', - toolArgs: { fact }, - }); - }); - - it('should handle --global flag and add scope to tool args', () => { - if (!addCommand.action) throw new Error('Command has no action'); - - const fact = 'remember this globally'; - const result = addCommand.action(mockContext, `--global ${fact}`); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `Attempting to save to memory (global): "${fact}"`, - }, - expect.any(Number), - ); - - expect(result).toEqual({ - type: 'tool', - toolName: 'save_memory', - toolArgs: { fact, scope: 'global' }, - }); - }); - - it('should handle --project flag and add scope to tool args', () => { - if (!addCommand.action) throw new Error('Command has no action'); - - const fact = 'remember this for project'; - const result = addCommand.action(mockContext, `--project ${fact}`); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `Attempting to save to memory (project): "${fact}"`, - }, - expect.any(Number), - ); - - expect(result).toEqual({ - type: 'tool', - toolName: 'save_memory', - toolArgs: { fact, scope: 'project' }, - }); - }); - - it('should return error if flag is provided but no fact follows', () => { - if (!addCommand.action) throw new Error('Command has no action'); - - const result = addCommand.action(mockContext, '--global '); - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Usage: /memory add [--global|--project] ', - }); - - expect(mockContext.ui.addItem).not.toHaveBeenCalled(); - }); - }); - - describe('/memory refresh', () => { - let refreshCommand: SlashCommand; - let mockSetUserMemory: Mock; - let mockSetGeminiMdFileCount: Mock; - - beforeEach(() => { - refreshCommand = getSubCommand('refresh'); - mockSetUserMemory = vi.fn(); - mockSetGeminiMdFileCount = vi.fn(); - const mockConfig = { - setUserMemory: mockSetUserMemory, - setGeminiMdFileCount: mockSetGeminiMdFileCount, - getWorkingDir: () => '/test/dir', - getDebugMode: () => false, - getFileService: () => ({}) as FileDiscoveryService, - getExtensionContextFilePaths: () => [], - shouldLoadMemoryFromIncludeDirectories: () => false, - getWorkspaceContext: () => ({ - getDirectories: () => [], - }), - getFileFilteringOptions: () => ({ - ignore: [], - include: [], - }), - getFolderTrust: () => false, - }; - - mockContext = createMockCommandContext({ - services: { - config: mockConfig, - settings: { - merged: {}, - } as LoadedSettings, - }, - }); - mockLoadServerHierarchicalMemory.mockClear(); - }); - - it('should display success message when memory is refreshed with content', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); - - const refreshResult: LoadServerHierarchicalMemoryResponse = { - memoryContent: 'new memory content', - fileCount: 2, - }; - mockLoadServerHierarchicalMemory.mockResolvedValue(refreshResult); - - await refreshCommand.action(mockContext, ''); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Refreshing memory from source files...', - }, - expect.any(Number), - ); - - expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce(); - expect(mockSetUserMemory).toHaveBeenCalledWith( - refreshResult.memoryContent, - ); - expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith( - refreshResult.fileCount, - ); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).', - }, - expect.any(Number), - ); - }); - - it('should display success message when memory is refreshed with no content', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); - - const refreshResult = { memoryContent: '', fileCount: 0 }; - mockLoadServerHierarchicalMemory.mockResolvedValue(refreshResult); - - await refreshCommand.action(mockContext, ''); - - expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce(); - expect(mockSetUserMemory).toHaveBeenCalledWith(''); - expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith(0); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Memory refreshed successfully. No memory content found.', - }, - expect.any(Number), - ); - }); - - it('should display an error message if refreshing fails', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); - - const error = new Error('Failed to read memory files.'); - mockLoadServerHierarchicalMemory.mockRejectedValue(error); - - await refreshCommand.action(mockContext, ''); - - expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce(); - expect(mockSetUserMemory).not.toHaveBeenCalled(); - expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled(); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: `Error refreshing memory: ${error.message}`, - }, - expect.any(Number), - ); - - expect(getErrorMessage).toHaveBeenCalledWith(error); - }); - - it('should not throw if config service is unavailable', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); - - const nullConfigContext = createMockCommandContext({ - services: { config: null }, - }); - - await expect( - refreshCommand.action(nullConfigContext, ''), - ).resolves.toBeUndefined(); - - expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Refreshing memory from source files...', - }, - expect.any(Number), - ); + const result = await memoryCommand.action?.(context, ''); - expect(loadServerHierarchicalMemory).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'The memory manager is only available in the interactive UI. In non-interactive mode, open the user or project memory files directly.', }); }); }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index ff0c51bc1bd..be2dd72bea6 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -4,806 +4,32 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - AUTO_MEMORY_TYPES, - getErrorMessage, - getManagedAutoMemoryStatus, - forgetManagedAutoMemoryMatches, - getAutoMemoryTopicPath, - getAllGeminiMdFilenames, - loadServerHierarchicalMemory, - QWEN_DIR, - reviewManagedAutoMemoryGovernance, - scheduleAutoMemoryExtract, - selectManagedAutoMemoryForgetCandidates, -} from '@qwen-code/qwen-code-core'; -import path from 'node:path'; -import os from 'node:os'; -import fs from 'node:fs/promises'; -import { MessageType } from '../types.js'; -import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; -interface TaskLike { - id: string; - status: string; - updatedAt: string; - progressText?: string; - metadata?: Record; -} - -function summarizeTaskMetadata(task: TaskLike): string { - const metadata = task.metadata ?? {}; - const parts: string[] = []; - - if (Array.isArray(metadata['touchedTopics']) && metadata['touchedTopics'].length > 0) { - parts.push(`topics=${(metadata['touchedTopics'] as string[]).join(',')}`); - } - if (typeof metadata['patchCount'] === 'number') { - parts.push(`patches=${String(metadata['patchCount'])}`); - } - if (typeof metadata['dedupedEntries'] === 'number') { - parts.push(`deduped=${String(metadata['dedupedEntries'])}`); - } - if (typeof metadata['queuedBehindTaskId'] === 'string') { - parts.push(`behind=${metadata['queuedBehindTaskId']}`); - } - if (typeof metadata['skippedReason'] === 'string') { - parts.push(`skip=${metadata['skippedReason']}`); - } - if (metadata['trailing'] === true) { - parts.push('trailing=yes'); - } - if (typeof metadata['historyLength'] === 'number') { - parts.push(`history=${String(metadata['historyLength'])}`); - } - if (typeof metadata['roundCount'] === 'number') { - parts.push(`rounds=${String(metadata['roundCount'])}`); - } - if (typeof metadata['filesTouched'] === 'number') { - parts.push(`files=${String(metadata['filesTouched'])}`); - } - - return parts.join(' | '); -} - -function countActiveTasks(tasks: TaskLike[]): number { - return tasks.filter( - (task) => task.status === 'pending' || task.status === 'running', - ).length; -} - -function buildTaskTimeline(label: string, tasks: TaskLike[]): string[] { - if (tasks.length === 0) { - return [`${label}: none`]; - } - - return [ - `${label}:`, - ...tasks.map((task) => { - const metadataSummary = summarizeTaskMetadata(task); - return `- ${task.id}: ${task.status} | updated=${task.updatedAt}${task.progressText ? ` | ${task.progressText}` : ''}${metadataSummary ? ` | ${metadataSummary}` : ''}`; - }), - ]; -} - -async function buildManagedMemoryReview( - projectRoot: string, - config?: { - getBaseLlmClient?: () => unknown; - }, -): Promise { - const review = await reviewManagedAutoMemoryGovernance(projectRoot, { - config: config as never, - }); - - if (review.suggestions.length === 0) { - return t('Managed auto-memory governance review found no strong suggestions.'); - } - - return [ - t('Managed auto-memory governance review (strategy={{strategy}}):', { - strategy: review.strategy, - }), - ...review.suggestions.map((suggestion, index) => { - const related = suggestion.relatedSummary - ? ` | related=${suggestion.relatedTopic}:${suggestion.relatedSummary}` - : ''; - const target = suggestion.suggestedTargetTopic - ? ` | target=${suggestion.suggestedTargetTopic}` - : ''; - return `${index + 1}. [${suggestion.type}] ${suggestion.topic}: ${suggestion.summary}${related}${target} | ${suggestion.rationale}`; - }), - ].join('\n'); -} - -async function buildForgetPreview( - projectRoot: string, - query: string, - applyCommand: string, - config?: { - getBaseLlmClient?: () => unknown; - }, -): Promise { - const selection = await selectManagedAutoMemoryForgetCandidates( - projectRoot, - query, - { config: config as never }, - ); - - if (selection.matches.length === 0) { - return t('No managed auto-memory entries matched: {{query}}', { query }); - } - - return [ - t('Forget preview (strategy={{strategy}}):', { strategy: selection.strategy }), - ...(selection.reasoning ? [selection.reasoning] : []), - ...selection.matches.map( - (match, index) => `${index + 1}. ${match.topic}: ${match.summary}`, - ), - '', - t('Run {{command}} to apply these removals.', { command: applyCommand }), - ].join('\n'); -} - -async function buildManagedMemoryStatus(projectRoot: string): Promise { - const status = await getManagedAutoMemoryStatus(projectRoot); - - const cursorSummary = status.cursor - ? t( - 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}', - { - sessionId: status.cursor.sessionId || 'n/a', - offset: String(status.cursor.processedOffset ?? 0), - updatedAt: status.cursor.updatedAt || 'n/a', - }, - ) - : t('No extraction cursor found yet.'); - - const extractionSummary = t( - 'Extraction: running={{running}}, last={{last}}, status={{status}}, touched={{touched}}', - { - running: status.extractionRunning ? 'yes' : 'no', - last: status.metadata?.lastExtractionAt || 'n/a', - status: status.metadata?.lastExtractionStatus || 'n/a', - touched: - status.metadata?.lastExtractionTouchedTopics?.join(', ') || 'none', - }, - ); - - const dreamSummary = t( - 'Dream: last={{last}}, status={{status}}, touched={{touched}}, activeTasks={{activeTasks}}', - { - last: status.metadata?.lastDreamAt || 'n/a', - status: status.metadata?.lastDreamStatus || 'n/a', - touched: status.metadata?.lastDreamTouchedTopics?.join(', ') || 'none', - activeTasks: String(countActiveTasks(status.dreamTasks)), - }, - ); - - const extractionTaskSummary = t( - 'Extraction tasks: active={{active}}, tracked={{tracked}}', - { - active: String(countActiveTasks(status.extractionTasks)), - tracked: String(status.extractionTasks.length), - }, - ); - - const dreamTaskSummary = t('Dream tasks: active={{active}}, tracked={{tracked}}', { - active: String(countActiveTasks(status.dreamTasks)), - tracked: String(status.dreamTasks.length), - }); - - const topicSummaries = status.topics.map( - (topic: { topic: string; entryCount: number; hooks: string[] }) => - `- ${topic.topic}.md: ${topic.entryCount} entries${topic.hooks.length > 0 ? ` | hooks: ${topic.hooks.join(' ; ')}` : ''}`, - ); - - return [ - t('Managed auto-memory root: {{root}}', { root: status.root }), - cursorSummary, - extractionSummary, - extractionTaskSummary, - dreamSummary, - dreamTaskSummary, - t('Managed auto-memory topics:'), - ...topicSummaries, - ].join('\n'); -} - -async function buildManagedMemoryTasks(projectRoot: string): Promise { - const status = await getManagedAutoMemoryStatus(projectRoot); - const lines = [ - t('Managed auto-memory background tasks:'), - `- extraction lane: ${status.extractionRunning ? 'running' : 'idle'} | active=${countActiveTasks(status.extractionTasks)} | tracked=${status.extractionTasks.length}`, - `- dream lane: active=${countActiveTasks(status.dreamTasks)} | tracked=${status.dreamTasks.length}`, - '', - ]; - - lines.push(...buildTaskTimeline('Extraction timeline', status.extractionTasks)); - lines.push(''); - lines.push(...buildTaskTimeline('Dream timeline', status.dreamTasks)); - return lines.join('\n'); -} - -async function buildManagedMemoryInspect( - projectRoot: string, - target?: string, -): Promise { - const normalizedTarget = target?.trim().toLowerCase(); - const status = await getManagedAutoMemoryStatus(projectRoot); - if (!normalizedTarget || normalizedTarget === 'index' || normalizedTarget === 'memory') { - return status.indexContent || t('Managed memory index is empty.'); - } - - if (!AUTO_MEMORY_TYPES.includes(normalizedTarget as (typeof AUTO_MEMORY_TYPES)[number])) { - return t('Unknown managed memory target: {{target}}', { target: target ?? '' }); - } - - const topicPath = getAutoMemoryTopicPath(projectRoot, normalizedTarget as (typeof AUTO_MEMORY_TYPES)[number]); - try { - return await fs.readFile(topicPath, 'utf-8'); - } catch { - return t('Unknown managed memory target: {{target}}', { target: target ?? '' }); - } -} - -/** - * Read all existing memory files from the configured filenames in a directory. - * Returns an array of found files with their paths and contents. - */ -async function findAllExistingMemoryFiles( - dir: string, -): Promise> { - const results: Array<{ filePath: string; content: string }> = []; - for (const filename of getAllGeminiMdFilenames()) { - const filePath = path.join(dir, filename); - try { - const content = await fs.readFile(filePath, 'utf-8'); - if (content.trim().length > 0) { - results.push({ filePath, content }); - } - } catch { - // File doesn't exist, try next - } - } - return results; -} - export const memoryCommand: SlashCommand = { name: 'memory', get description() { - return t('Commands for interacting with memory.'); + return t('Open the memory manager.'); }, kind: CommandKind.BUILT_IN, - subCommands: [ - { - name: 'show', - get description() { - return t('Show the current memory contents.'); - }, - kind: CommandKind.BUILT_IN, - action: async (context) => { - const memoryContent = context.services.config?.getUserMemory() || ''; - const fileCount = context.services.config?.getGeminiMdFileCount() || 0; - - const messageContent = - memoryContent.length > 0 - ? `${t('Current memory content from {{count}} file(s):', { count: String(fileCount) })}\n\n---\n${memoryContent}\n---` - : t('Memory is currently empty.'); - - context.ui.addItem( - { - type: MessageType.INFO, - text: messageContent, - }, - Date.now(), - ); - }, - subCommands: [ - { - name: '--project', - get description() { - return t('Show project-level memory contents.'); - }, - kind: CommandKind.BUILT_IN, - action: async (context) => { - const workingDir = - context.services.config?.getWorkingDir?.() ?? process.cwd(); - const results = await findAllExistingMemoryFiles(workingDir); - - if (results.length > 0) { - const combined = results - .map((r) => - t( - 'Project memory content from {{path}}:\n\n---\n{{content}}\n---', - { path: r.filePath, content: r.content }, - ), - ) - .join('\n\n'); - context.ui.addItem( - { - type: MessageType.INFO, - text: combined, - }, - Date.now(), - ); - } else { - context.ui.addItem( - { - type: MessageType.INFO, - text: t( - 'Project memory file not found or is currently empty.', - ), - }, - Date.now(), - ); - } - }, - }, - { - name: '--global', - get description() { - return t('Show global memory contents.'); - }, - kind: CommandKind.BUILT_IN, - action: async (context) => { - const globalDir = path.join(os.homedir(), QWEN_DIR); - const results = await findAllExistingMemoryFiles(globalDir); - - if (results.length > 0) { - const combined = results - .map((r) => - t('Global memory content:\n\n---\n{{content}}\n---', { - content: r.content, - }), - ) - .join('\n\n'); - context.ui.addItem( - { - type: MessageType.INFO, - text: combined, - }, - Date.now(), - ); - } else { - context.ui.addItem( - { - type: MessageType.INFO, - text: t( - 'Global memory file not found or is currently empty.', - ), - }, - Date.now(), - ); - } - }, - }, - ], - }, - { - name: 'status', - get description() { - return t('Show managed auto-memory status.'); - }, - kind: CommandKind.BUILT_IN, - action: async (context) => { - const config = context.services.config; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - const status = await buildManagedMemoryStatus(config.getProjectRoot()); - context.ui.addItem( - { - type: MessageType.INFO, - text: status, - }, - Date.now(), - ); - - return; - }, - }, - { - name: 'tasks', - get description() { - return t('Show managed auto-memory background task status.'); - }, - kind: CommandKind.BUILT_IN, - action: async (context) => { - const config = context.services.config; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - context.ui.addItem( - { - type: MessageType.INFO, - text: await buildManagedMemoryTasks(config.getProjectRoot()), - }, - Date.now(), - ); - - return; - }, - }, - { - name: 'inspect', - get description() { - return t('Inspect managed auto-memory index or a topic file.'); - }, - kind: CommandKind.BUILT_IN, - action: async (context, args) => { - const config = context.services.config; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - context.ui.addItem( - { - type: MessageType.INFO, - text: await buildManagedMemoryInspect(config.getProjectRoot(), args), - }, - Date.now(), - ); - - return; - }, - }, - { - name: 'review', - get description() { - return t('Review managed auto-memory governance suggestions.'); - }, - kind: CommandKind.BUILT_IN, - action: async (context) => { - const config = context.services.config; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - context.ui.addItem( - { - type: MessageType.INFO, - text: await buildManagedMemoryReview(config.getProjectRoot(), config), - }, - Date.now(), - ); - - return; - }, - }, - { - name: 'extract-now', - get description() { - return t('Run managed auto-memory extraction for the current session.'); - }, - kind: CommandKind.BUILT_IN, - action: async (context) => { - const config = context.services.config; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - const geminiClient = config.getGeminiClient(); - if (!geminiClient) { - return { - type: 'message', - messageType: 'error', - content: t('No chat client available to extract memory.'), - }; - } - - const result = await scheduleAutoMemoryExtract({ - projectRoot: config.getProjectRoot(), - sessionId: config.getSessionId(), - history: geminiClient.getChat().getHistory(), - }); - - const text = result.skippedReason === 'already_running' - ? t('Managed auto-memory extraction is already running.') - : result.systemMessage || t('Managed auto-memory extraction found no new durable memories.'); - - context.ui.addItem( - { - type: MessageType.INFO, - text, - }, - Date.now(), - ); - - return; - }, - }, - { - name: 'forget', - get description() { - return t('Remove matching entries from managed auto-memory.'); - }, - kind: CommandKind.BUILT_IN, - action: async (context, args) => { - const config = context.services.config; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - const trimmedArgs = args.trim(); - const apply = trimmedArgs.startsWith('--apply '); - const query = apply - ? trimmedArgs.slice('--apply '.length).trim() - : trimmedArgs; - if (!query) { - return { - type: 'message', - messageType: 'error', - content: t('Usage: /memory forget [--apply] '), - }; - } - - if (!apply) { - context.ui.addItem( - { - type: MessageType.INFO, - text: await buildForgetPreview( - config.getProjectRoot(), - query, - `/memory forget --apply ${query}`, - config, - ), - }, - Date.now(), - ); - - return; - } - - const selection = await selectManagedAutoMemoryForgetCandidates( - config.getProjectRoot(), - query, - { config }, - ); - const result = await forgetManagedAutoMemoryMatches( - config.getProjectRoot(), - selection.matches, - ); - - context.ui.addItem( - { - type: MessageType.INFO, - text: - result.systemMessage ?? - t('No managed auto-memory entries matched: {{query}}', { - query, - }), - }, - Date.now(), - ); - - return; - }, - }, - { - name: 'add', - get description() { - return t( - 'Add content to the memory. Use --global for global memory or --project for project memory.', - ); - }, - kind: CommandKind.BUILT_IN, - action: (context, args): SlashCommandActionReturn | void => { - if (!args || args.trim() === '') { - return { - type: 'message', - messageType: 'error', - content: t( - 'Usage: /memory add [--global|--project] ', - ), - }; - } - - const trimmedArgs = args.trim(); - let scope: 'global' | 'project' | undefined; - let fact: string; - - // Check for scope flags - if (trimmedArgs.startsWith('--global ')) { - scope = 'global'; - fact = trimmedArgs.substring('--global '.length).trim(); - } else if (trimmedArgs.startsWith('--project ')) { - scope = 'project'; - fact = trimmedArgs.substring('--project '.length).trim(); - } else if (trimmedArgs === '--global' || trimmedArgs === '--project') { - // Flag provided but no text after it - return { - type: 'message', - messageType: 'error', - content: t( - 'Usage: /memory add [--global|--project] ', - ), - }; - } else { - // No scope specified, will be handled by the tool - fact = trimmedArgs; - } - - if (!fact || fact.trim() === '') { - return { - type: 'message', - messageType: 'error', - content: t( - 'Usage: /memory add [--global|--project] ', - ), - }; - } - - const scopeText = scope ? `(${scope})` : ''; - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Attempting to save to memory {{scope}}: "{{fact}}"', { - scope: scopeText, - fact, - }), - }, - Date.now(), - ); - - return { - type: 'tool', - toolName: 'save_memory', - toolArgs: scope ? { fact, scope } : { fact }, - }; - }, - subCommands: [ - { - name: '--project', - get description() { - return t('Add content to project-level memory.'); - }, - kind: CommandKind.BUILT_IN, - action: (context, args): SlashCommandActionReturn | void => { - if (!args || args.trim() === '') { - return { - type: 'message', - messageType: 'error', - content: t('Usage: /memory add --project '), - }; - } - - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Attempting to save to project memory: "{{text}}"', { - text: args.trim(), - }), - }, - Date.now(), - ); - - return { - type: 'tool', - toolName: 'save_memory', - toolArgs: { fact: args.trim(), scope: 'project' }, - }; - }, - }, - { - name: '--global', - get description() { - return t('Add content to global memory.'); - }, - kind: CommandKind.BUILT_IN, - action: (context, args): SlashCommandActionReturn | void => { - if (!args || args.trim() === '') { - return { - type: 'message', - messageType: 'error', - content: t('Usage: /memory add --global '), - }; - } - - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Attempting to save to global memory: "{{text}}"', { - text: args.trim(), - }), - }, - Date.now(), - ); - - return { - type: 'tool', - toolName: 'save_memory', - toolArgs: { fact: args.trim(), scope: 'global' }, - }; - }, - }, - ], - }, - { - name: 'refresh', - get description() { - return t('Refresh the memory from the source.'); - }, - kind: CommandKind.BUILT_IN, - action: async (context) => { - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Refreshing memory from source files...'), - }, - Date.now(), - ); - - try { - const config = context.services.config; - if (config) { - const { memoryContent, fileCount } = - await loadServerHierarchicalMemory( - config.getWorkingDir(), - config.shouldLoadMemoryFromIncludeDirectories() - ? config.getWorkspaceContext().getDirectories() - : [], - config.getFileService(), - config.getExtensionContextFilePaths(), - config.getFolderTrust(), - context.services.settings.merged.context?.importFormat || - 'tree', // Use setting or default to 'tree' - ); - config.setUserMemory(memoryContent); - config.setGeminiMdFileCount(fileCount); - - const successMessage = - memoryContent.length > 0 - ? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).` - : 'Memory refreshed successfully. No memory content found.'; + action: async (context) => { + const executionMode = context.executionMode ?? 'interactive'; + + if (executionMode === 'interactive') { + return { + type: 'dialog', + dialog: 'memory', + }; + } - context.ui.addItem( - { - type: MessageType.INFO, - text: successMessage, - }, - Date.now(), - ); - } - } catch (error) { - const errorMessage = getErrorMessage(error); - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Error refreshing memory: ${errorMessage}`, - }, - Date.now(), - ); - } - }, - }, - ], + return { + type: 'message', + messageType: 'info', + content: t( + 'The memory manager is only available in the interactive UI. In non-interactive mode, open the user or project memory files directly.', + ), + }; + }, }; diff --git a/packages/cli/src/ui/commands/memoryLifecycle.integration.test.ts b/packages/cli/src/ui/commands/memoryLifecycle.integration.test.ts deleted file mode 100644 index de61f1dc316..00000000000 --- a/packages/cli/src/ui/commands/memoryLifecycle.integration.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - applyExtractedMemoryPatches, - ensureAutoMemoryScaffold, - getAutoMemoryTopicPath, - resetManagedAutoMemoryExtractRuntimeForTests, - resetAutoMemoryStateForTests, -} from '@qwen-code/qwen-code-core'; -import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { forgetCommand } from './forgetCommand.js'; -import { memoryCommand } from './memoryCommand.js'; -import type { SlashCommand, CommandContext } from './types.js'; - -describe('managed memory CLI integration', () => { - let tempDir: string; - let projectRoot: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-cli-int-')); - projectRoot = path.join(tempDir, 'project'); - await fs.mkdir(projectRoot, { recursive: true }); - await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T00:00:00.000Z')); - }); - - afterEach(async () => { - resetAutoMemoryStateForTests(); - resetManagedAutoMemoryExtractRuntimeForTests(); - await fs.rm(tempDir, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 10, - }); - }); - - function getMemorySubCommand(name: string): SlashCommand { - const subCommand = memoryCommand.subCommands?.find((command) => command.name === name); - if (!subCommand) { - throw new Error(`Missing /memory ${name} command`); - } - return subCommand; - } - - function createContext(history: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }> = []): CommandContext { - return createMockCommandContext({ - services: { - config: { - getProjectRoot: vi.fn().mockReturnValue(projectRoot), - getSessionId: vi.fn().mockReturnValue('session-1'), - getGeminiClient: vi.fn().mockReturnValue({ - getChat: vi.fn().mockReturnValue({ - getHistory: vi.fn().mockReturnValue(history), - }), - }), - }, - }, - }); - } - - it('surfaces extraction, status, tasks, inspect, review, and forget flows through CLI commands', async () => { - const extractContext = createContext([ - { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, - { - role: 'user', - parts: [{ text: 'The latency dashboard is https://grafana.example/d/api-latency' }], - }, - ]); - - await getMemorySubCommand('extract-now').action?.(extractContext, ''); - const extractText = (extractContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; - expect(extractText).toContain('Managed auto-memory updated'); - - const statusContext = createContext(); - await getMemorySubCommand('status').action?.(statusContext, ''); - const statusText = (statusContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; - expect(statusText).toContain(`Managed auto-memory root: ${projectRoot}/.qwen/memory`); - expect(statusText).toContain('Extraction tasks: active=0, tracked=1'); - - const tasksContext = createContext(); - await getMemorySubCommand('tasks').action?.(tasksContext, ''); - const tasksText = (tasksContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; - expect(tasksText).toContain('Managed auto-memory background tasks:'); - expect(tasksText).toContain('Extraction timeline:'); - - const inspectContext = createContext(); - await getMemorySubCommand('inspect').action?.(inspectContext, 'user'); - const inspectText = (inspectContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; - expect(inspectText).toContain('I prefer terse responses.'); - - await applyExtractedMemoryPatches(projectRoot, [ - { - topic: 'project', - summary: 'This is temporary for this task.', - sourceOffset: 10, - }, - ]); - - const reviewContext = createContext(); - await getMemorySubCommand('review').action?.(reviewContext, ''); - const reviewText = (reviewContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; - expect(reviewText).toContain('Managed auto-memory governance review'); - expect(reviewText).toMatch(/\[(forget|promote)\]/); - - const memoryForgetContext = createContext(); - await getMemorySubCommand('forget').action?.(memoryForgetContext, 'temporary for this task'); - const previewText = (memoryForgetContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; - expect(previewText).toContain('Forget preview'); - expect(previewText).toContain('/memory forget --apply temporary for this task'); - - await getMemorySubCommand('forget').action?.(memoryForgetContext, '--apply temporary for this task'); - const memoryForgetApplyText = (memoryForgetContext.ui.addItem as ReturnType).mock.calls.at(-1)?.[0]?.text; - expect(memoryForgetApplyText).toContain('Managed auto-memory forgot 1 entry'); - - const forgetContext = createContext(); - const topLevelPreview = await forgetCommand.action?.( - forgetContext, - 'terse responses', - ); - expect(topLevelPreview).toEqual( - expect.objectContaining({ - type: 'message', - messageType: 'info', - content: expect.stringContaining('Forget preview'), - }), - ); - expect((topLevelPreview as { content: string }).content).toContain( - '/forget --apply terse responses', - ); - - const topLevelApply = await forgetCommand.action?.( - forgetContext, - '--apply terse responses', - ); - expect(topLevelApply).toEqual( - expect.objectContaining({ - type: 'message', - messageType: 'info', - content: expect.stringContaining('Managed auto-memory forgot 1 entry'), - }), - ); - - const userContentAfterForget = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - 'utf-8', - ); - expect(userContentAfterForget).not.toContain('I prefer terse responses.'); - }); -}); \ No newline at end of file diff --git a/packages/cli/src/ui/commands/rememberCommand.test.ts b/packages/cli/src/ui/commands/rememberCommand.test.ts deleted file mode 100644 index bbe3d1f278e..00000000000 --- a/packages/cli/src/ui/commands/rememberCommand.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from 'vitest'; -import { rememberCommand } from './rememberCommand.js'; -import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; - -describe('rememberCommand', () => { - it('returns usage error when no args are provided', () => { - const result = rememberCommand.action?.(createMockCommandContext(), ' '); - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Usage: /remember [--global|--project] ', - }); - }); - - it('creates a save_memory tool action without scope by default', () => { - const result = rememberCommand.action?.( - createMockCommandContext(), - 'Remember this fact', - ); - expect(result).toEqual({ - type: 'tool', - toolName: 'save_memory', - toolArgs: { fact: 'Remember this fact' }, - }); - }); - - it('creates a project-scoped save_memory tool action', () => { - const result = rememberCommand.action?.( - createMockCommandContext(), - '--project Project-specific fact', - ); - expect(result).toEqual({ - type: 'tool', - toolName: 'save_memory', - toolArgs: { fact: 'Project-specific fact', scope: 'project' }, - }); - }); -}); \ No newline at end of file diff --git a/packages/cli/src/ui/commands/rememberCommand.ts b/packages/cli/src/ui/commands/rememberCommand.ts index 657ad11619f..9c3339d1498 100644 --- a/packages/cli/src/ui/commands/rememberCommand.ts +++ b/packages/cli/src/ui/commands/rememberCommand.ts @@ -4,8 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { getAutoMemoryRoot } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; -import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import type { CommandContext, SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; function parseRememberArgs(args: string): @@ -40,10 +41,10 @@ function parseRememberArgs(args: string): export const rememberCommand: SlashCommand = { name: 'remember', get description() { - return t('Save a durable memory using the save_memory tool.'); + return t('Save a durable memory to the memory system.'); }, kind: CommandKind.BUILT_IN, - action: (_context, args): SlashCommandActionReturn | void => { + action: (context: CommandContext, args): SlashCommandActionReturn | void => { const parsed = parseRememberArgs(args); if (!parsed?.fact) { return { @@ -53,6 +54,29 @@ export const rememberCommand: SlashCommand = { }; } + const config = context.services.config; + const useManagedMemory = config?.getManagedAutoMemoryEnabled() ?? false; + + if (useManagedMemory) { + // In managed auto-memory mode the save_memory tool is not registered. + // Instead, submit a prompt so the main agent writes the per-entry file + // directly, following the instructions in buildManagedAutoMemoryPrompt. + const scopeHint = parsed.scope === 'project' + ? ' (type: project)' + : parsed.scope === 'global' + ? ' (type: user)' + : ''; + const memoryDir = config + ? getAutoMemoryRoot(config.getProjectRoot()) + : undefined; + const dirHint = memoryDir ? ` Save it to \`${memoryDir}\`.` : ''; + return { + type: 'submit_prompt', + content: `Please save the following to your memory system${scopeHint}:${dirHint}\n\n${parsed.fact}`, + }; + } + + // Legacy mode: save_memory tool is registered and handles the write. return { type: 'tool', toolName: 'save_memory', @@ -61,4 +85,4 @@ export const rememberCommand: SlashCommand = { : { fact: parsed.fact }, }; }, -}; \ No newline at end of file +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2bd79805425..701d4ca8ed4 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -156,6 +156,7 @@ export interface OpenDialogActionReturn { | 'theme' | 'editor' | 'settings' + | 'memory' | 'model' | 'subagent_create' | 'subagent_list' diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index e2f1256ff50..ab9b6155af5 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -43,6 +43,7 @@ import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js import { MCPManagementDialog } from './mcp/MCPManagementDialog.js'; import { HooksManagementDialog } from './hooks/HooksManagementDialog.js'; import { SessionPicker } from './SessionPicker.js'; +import { MemoryDialog } from './MemoryDialog.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -225,6 +226,9 @@ export const DialogManager = ({ ); } + if (uiState.isMemoryDialogOpen) { + return ; + } if (uiState.isApprovalModeDialogOpen) { const currentMode = config.getApprovalMode(); return ( diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 347a1e91805..5c46b4a4d3a 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -558,18 +558,18 @@ describe('InputPrompt', () => { }); it('should handle the "backspace" edge case correctly', async () => { - // SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show') + // SCENARIO: /config -> Backspace -> /config -> Tab (to accept 'set') mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, showSuggestions: true, suggestions: [ - { label: 'show', value: 'show' }, - { label: 'add', value: 'add' }, + { label: 'set', value: 'set' }, + { label: 'reset', value: 'reset' }, ], - activeSuggestionIndex: 0, // 'show' is highlighted + activeSuggestionIndex: 0, // 'set' is highlighted }); - // The user has backspaced, so the query is now just '/memory' - props.buffer.setText('/memory'); + // The user has backspaced, so the query is now just '/config' + props.buffer.setText('/config'); const { stdin, unmount } = renderWithProviders(); await wait(); @@ -577,20 +577,20 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - // It should NOT become '/show'. It should correctly become '/memory show'. + // It should NOT become '/set'. It should correctly become '/config set'. expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); it('should complete a partial argument for a command', async () => { - // SCENARIO: /memory add fi- -> Tab + // SCENARIO: /config set fi- -> Tab mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'fix-foo', value: 'fix-foo' }], activeSuggestionIndex: 0, }); - props.buffer.setText('/memory add fi-'); + props.buffer.setText('/config set fi-'); const { stdin, unmount } = renderWithProviders(); await wait(); @@ -856,8 +856,8 @@ describe('InputPrompt', () => { }); it('should NOT trigger completion when cursor is after space following /', async () => { - mockBuffer.text = '/memory add'; - mockBuffer.lines = ['/memory add']; + mockBuffer.text = '/config set'; + mockBuffer.lines = ['/config set']; mockBuffer.cursor = [0, 11]; mockedUseCommandCompletion.mockReturnValue({ diff --git a/packages/cli/src/ui/components/MemoryDialog.test.tsx b/packages/cli/src/ui/components/MemoryDialog.test.tsx new file mode 100644 index 00000000000..5d7012b79c2 --- /dev/null +++ b/packages/cli/src/ui/components/MemoryDialog.test.tsx @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'ink-testing-library'; +import { MemoryDialog } from './MemoryDialog.js'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { useLaunchEditor } from '../hooks/useLaunchEditor.js'; +import { useKeypress } from '../hooks/useKeypress.js'; + +vi.mock('../contexts/ConfigContext.js', () => ({ + useConfig: vi.fn(), +})); + +vi.mock('../hooks/useLaunchEditor.js', () => ({ + useLaunchEditor: vi.fn(), +})); + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +const mockedUseConfig = vi.mocked(useConfig); +const mockedUseLaunchEditor = vi.mocked(useLaunchEditor); +const mockedUseKeypress = vi.mocked(useKeypress); + +describe('MemoryDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockedUseConfig.mockReturnValue({ + getWorkingDir: vi.fn(() => '/tmp/project'), + getProjectRoot: vi.fn(() => '/tmp/project'), + } as never); + + mockedUseLaunchEditor.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('moves selection with down arrow key events', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('› 1. User memory'); + + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + act(() => { + keypressHandler({ name: 'down' } as never); + }); + + expect(lastFrame()).toContain('› 2. Project memory'); + }); +}); diff --git a/packages/cli/src/ui/components/MemoryDialog.tsx b/packages/cli/src/ui/components/MemoryDialog.tsx new file mode 100644 index 00000000000..46622468b13 --- /dev/null +++ b/packages/cli/src/ui/components/MemoryDialog.tsx @@ -0,0 +1,323 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + getAllGeminiMdFilenames, + QWEN_DIR, +} from '@qwen-code/qwen-code-core'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { useLaunchEditor } from '../hooks/useLaunchEditor.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { theme } from '../semantic-colors.js'; +import { t } from '../../i18n/index.js'; + +type MemoryDialogTarget = 'project' | 'global' | 'managed'; + +interface MemoryDialogProps { + onClose: () => void; +} + +interface DialogItem { + label: string; + value: MemoryDialogTarget; + description: string; +} + +interface MemoryStatusState { + lastExtractionAt?: string; + lastDreamAt?: string; +} + +async function resolvePreferredMemoryFile( + dir: string, + fallbackFilename: string, +): Promise { + for (const filename of getAllGeminiMdFilenames()) { + const filePath = path.join(dir, filename); + try { + await fs.access(filePath); + return filePath; + } catch { + // Try the next configured file name. + } + } + + return path.join(dir, fallbackFilename); +} + +function openFolderPath(folderPath: string): void { + let command = 'xdg-open'; + + switch (process.platform) { + case 'darwin': + command = 'open'; + break; + case 'win32': + command = 'explorer'; + break; + default: + command = 'xdg-open'; + break; + } + + const needsShell = + process.platform === 'win32' && + (command.endsWith('.cmd') || command.endsWith('.bat')); + + const result = spawnSync(command, [folderPath], { + stdio: 'inherit', + shell: needsShell, + }); + + if (result.error) { + throw result.error; + } + if (typeof result.status === 'number' && result.status !== 0) { + throw new Error(`Folder opener exited with status ${result.status}`); + } +} + +async function ensureFileExists(filePath: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + try { + await fs.access(filePath); + } catch { + await fs.writeFile(filePath, '', 'utf-8'); + } +} + +function formatDisplayPath(filePath: string): string { + const home = os.homedir(); + if (filePath.startsWith(home)) { + return `~${filePath.slice(home.length)}`; + } + return filePath; +} + +function formatStatusTime(iso?: string): string { + if (!iso) { + return t('never'); + } + + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return t('never'); + } + + return date.toLocaleString(); +} + +export function MemoryDialog({ onClose }: MemoryDialogProps) { + const config = useConfig(); + const launchEditor = useLaunchEditor(); + const [error, setError] = useState(null); + const [status, setStatus] = useState({}); + const [highlightedIndex, setHighlightedIndex] = useState(0); + + const globalMemoryPath = useMemo( + () => path.join(os.homedir(), QWEN_DIR, getAllGeminiMdFilenames()[0] ?? 'QWEN.md'), + [], + ); + const projectMemoryPath = useMemo( + () => path.join(config.getWorkingDir(), getAllGeminiMdFilenames()[0] ?? 'QWEN.md'), + [config], + ); + const managedMemoryPath = useMemo( + () => path.join(config.getProjectRoot(), '.qwen', 'memory'), + [config], + ); + + const items = useMemo( + () => [ + { + label: t('User memory'), + value: 'global', + description: t('Saved in {{path}}', { + path: formatDisplayPath(globalMemoryPath), + }), + }, + { + label: t('Project memory'), + value: 'project', + description: t('Checked in at {{path}}', { + path: path.relative(config.getWorkingDir(), projectMemoryPath) || path.basename(projectMemoryPath), + }), + }, + { + label: t('Open auto-memory folder'), + value: 'managed', + description: t('Browse indexed memory files in {{path}}', { + path: path.relative(config.getWorkingDir(), managedMemoryPath) || '.qwen/memory', + }), + }, + ], + [config, globalMemoryPath, managedMemoryPath, projectMemoryPath], + ); + + useEffect(() => { + let cancelled = false; + + async function loadStatus() { + try { + const metadataPath = path.join(managedMemoryPath, 'meta.json'); + const content = await fs.readFile(metadataPath, 'utf-8'); + const parsed = JSON.parse(content) as MemoryStatusState; + if (!cancelled) { + setStatus(parsed); + } + } catch { + if (!cancelled) { + setStatus({}); + } + } + } + + void loadStatus(); + return () => { + cancelled = true; + }; + }, [managedMemoryPath]); + + const resolveTargetPath = useCallback( + async (target: MemoryDialogTarget): Promise => { + switch (target) { + case 'project': + return resolvePreferredMemoryFile( + config.getWorkingDir(), + getAllGeminiMdFilenames()[0] ?? 'QWEN.md', + ); + case 'global': + return resolvePreferredMemoryFile( + path.join(os.homedir(), QWEN_DIR), + getAllGeminiMdFilenames()[0] ?? 'QWEN.md', + ); + case 'managed': + return path.join( + config.getProjectRoot(), + '.qwen', + 'memory', + ); + } + }, + [config], + ); + + const handleSelect = useCallback( + async (target: MemoryDialogTarget) => { + try { + setError(null); + const targetPath = await resolveTargetPath(target); + if (target === 'managed') { + await fs.mkdir(targetPath, { recursive: true }); + openFolderPath(targetPath); + } else { + await ensureFileExists(targetPath); + await launchEditor(targetPath); + } + onClose(); + } catch (selectionError) { + setError( + selectionError instanceof Error + ? selectionError.message + : String(selectionError), + ); + } + }, + [launchEditor, onClose, resolveTargetPath], + ); + + useKeypress( + (key) => { + if (key.name === 'escape') { + onClose(); + return; + } + + if (key.name === 'up') { + setHighlightedIndex((current) => + current === 0 ? items.length - 1 : current - 1, + ); + return; + } + + if (key.name === 'down') { + setHighlightedIndex((current) => (current + 1) % items.length); + return; + } + + if (key.name === 'return') { + void handleSelect(items[highlightedIndex]?.value ?? 'project'); + return; + } + + if (key.sequence && /^[1-3]$/.test(key.sequence)) { + const nextIndex = Number(key.sequence) - 1; + if (items[nextIndex]) { + setHighlightedIndex(nextIndex); + void handleSelect(items[nextIndex].value); + } + } + }, + { isActive: true }, + ); + + return ( + + {t('Memory')} + + + {t('Auto-memory: on · Last write {{time}}', { + time: formatStatusTime(status.lastExtractionAt), + })} + + + {t('Auto-dream: on · Last run {{time}}', { + time: formatStatusTime(status.lastDreamAt), + })} + + + + {error && ( + + {error} + + )} + + + {items.map((item, index) => { + const isSelected = index === highlightedIndex; + return ( + + + {`${isSelected ? '›' : ' '} ${index + 1}. ${item.label}`} + + + {item.description} + + + ); + })} + + + + {t('Enter to confirm · Esc to cancel')} + + + + ); +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index e1a1010b951..2e5fcfc71e8 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -29,6 +29,7 @@ export interface OpenAICredentials { export interface UIActions { openThemeDialog: () => void; openEditorDialog: () => void; + openMemoryDialog: () => void; handleThemeSelect: ( themeName: string | undefined, scope: SettingScope, @@ -60,6 +61,7 @@ export interface UIActions { ) => void; exitEditorDialog: () => void; closeSettingsDialog: () => void; + closeMemoryDialog: () => void; closeModelDialog: () => void; openArenaDialog: (type: Exclude) => void; closeArenaDialog: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 9c844636891..999334e9e74 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -53,6 +53,7 @@ export interface UIState { debugMessage: string; quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; + isMemoryDialogOpen: boolean; isModelDialogOpen: boolean; isTrustDialogOpen: boolean; activeArenaDialog: ArenaDialogType; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 49cefb39ce2..f9354c01193 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -110,6 +110,7 @@ describe('useSlashCommandProcessor', () => { const mockLoadHistory = vi.fn(); const mockOpenThemeDialog = vi.fn(); const mockOpenAuthDialog = vi.fn(); + const mockOpenMemoryDialog = vi.fn(); const mockOpenModelDialog = vi.fn(); const mockSetQuittingMessages = vi.fn(); @@ -126,6 +127,7 @@ describe('useSlashCommandProcessor', () => { mockFileLoadCommands.mockResolvedValue([]); mockMcpLoadCommands.mockResolvedValue([]); mockOpenModelDialog.mockClear(); + mockOpenMemoryDialog.mockClear(); }); const setupProcessorHook = ( @@ -154,6 +156,7 @@ describe('useSlashCommandProcessor', () => { openAuthDialog: mockOpenAuthDialog, openThemeDialog: mockOpenThemeDialog, openEditorDialog: vi.fn(), + openMemoryDialog: mockOpenMemoryDialog, openSettingsDialog: vi.fn(), openModelDialog: mockOpenModelDialog, openTrustDialog: vi.fn(), @@ -429,6 +432,44 @@ describe('useSlashCommandProcessor', () => { expect(mockOpenModelDialog).toHaveBeenCalled(); }); + it('should handle "dialog: memory" action', async () => { + const command = createTestCommand({ + name: 'memorycmd', + action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'memory' }), + }); + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/memorycmd'); + }); + + expect(mockOpenMemoryDialog).toHaveBeenCalled(); + }); + + it('should pass interactive execution mode to command actions', async () => { + const action = vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'ok', + }); + const command = createTestCommand({ + name: 'interactivecmd', + action, + }); + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/interactivecmd'); + }); + + expect(action).toHaveBeenCalledWith( + expect.objectContaining({ executionMode: 'interactive' }), + '', + ); + }); + it('should handle "load_history" action', async () => { const mockClient = { setHistory: vi.fn(), @@ -928,6 +969,7 @@ describe('useSlashCommandProcessor', () => { openAuthDialog: mockOpenAuthDialog, openThemeDialog: mockOpenThemeDialog, openEditorDialog: vi.fn(), + openMemoryDialog: mockOpenMemoryDialog, openSettingsDialog: vi.fn(), openModelDialog: vi.fn(), openTrustDialog: vi.fn(), diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c0c3fac07c6..71cd8af78e0 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -73,6 +73,7 @@ interface SlashCommandProcessorActions { openArenaDialog?: (type: Exclude) => void; openThemeDialog: () => void; openEditorDialog: () => void; + openMemoryDialog: () => void; openSettingsDialog: () => void; openModelDialog: () => void; openTrustDialog: () => void; @@ -248,6 +249,7 @@ export const useSlashCommandProcessor = ( ); const commandContext = useMemo( (): CommandContext => ({ + executionMode: 'interactive', services: { config, settings, @@ -506,6 +508,9 @@ export const useSlashCommandProcessor = ( case 'settings': actions.openSettingsDialog(); return { type: 'handled' }; + case 'memory': + actions.openMemoryDialog(); + return { type: 'handled' }; case 'model': actions.openModelDialog(); return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 119d1c96cdc..2f8f8828b47 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -43,6 +43,10 @@ export interface DialogCloseOptions { isSettingsDialogOpen: boolean; closeSettingsDialog: () => void; + // Memory dialog + isMemoryDialogOpen: boolean; + closeMemoryDialog: () => void; + // Arena dialogs activeArenaDialog: ArenaDialogType; closeArenaDialog: () => void; @@ -88,6 +92,11 @@ export function useDialogClose(options: DialogCloseOptions) { return true; } + if (options.isMemoryDialogOpen) { + options.closeMemoryDialog(); + return true; + } + if (options.activeArenaDialog !== null) { options.closeArenaDialog(); return true; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 2234db6bdb1..a9b0663c056 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1058,7 +1058,7 @@ describe('useGeminiStream', () => { const { result } = renderTestHook(); await act(async () => { - await result.current.submitQuery('/memory add "test fact"'); + await result.current.submitQuery('/save-test-fact "test fact"'); }); await waitFor(() => { diff --git a/packages/cli/src/ui/hooks/useMemoryDialog.ts b/packages/cli/src/ui/hooks/useMemoryDialog.ts new file mode 100644 index 00000000000..a42c659f1fc --- /dev/null +++ b/packages/cli/src/ui/hooks/useMemoryDialog.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; + +export interface UseMemoryDialogReturn { + isMemoryDialogOpen: boolean; + openMemoryDialog: () => void; + closeMemoryDialog: () => void; +} + +export const useMemoryDialog = (): UseMemoryDialogReturn => { + const [isMemoryDialogOpen, setIsMemoryDialogOpen] = useState(false); + + const openMemoryDialog = useCallback(() => { + setIsMemoryDialogOpen(true); + }, []); + + const closeMemoryDialog = useCallback(() => { + setIsMemoryDialogOpen(false); + }, []); + + return { + isMemoryDialogOpen, + openMemoryDialog, + closeMemoryDialog, + }; +}; diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index b813ff8db92..34a316d0ec1 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -532,12 +532,12 @@ describe('useSlashCompletion', () => { const slashCommands = [ createTestCommand({ - name: 'memory', - description: 'Manage memory', + name: 'config', + description: 'Manage configuration', subCommands: [ createTestCommand({ - name: 'show', - description: 'Show memory', + name: 'set', + description: 'Set configuration', completion: mockCompletionFn, }), ], @@ -547,7 +547,7 @@ describe('useSlashCompletion', () => { const { result } = renderHook(() => useTestHarnessForSlashCompletion( true, - '/memory show --project', + '/config set --project', slashCommands, mockCommandContext, ), @@ -557,8 +557,8 @@ describe('useSlashCompletion', () => { expect(mockCompletionFn).toHaveBeenCalledWith( expect.objectContaining({ invocation: { - raw: '/memory show --project', - name: 'show', + raw: '/config set --project', + name: 'set', args: '--project', }, }), diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 9d2fddd9ae8..766a72ed3de 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -91,14 +91,14 @@ describe('commandUtils', () => { describe('isSlashCommand', () => { it('should return true when query starts with /', () => { expect(isSlashCommand('/help')).toBe(true); - expect(isSlashCommand('/memory show')).toBe(true); + expect(isSlashCommand('/config set')).toBe(true); expect(isSlashCommand('/clear')).toBe(true); expect(isSlashCommand('/')).toBe(true); }); it('should return false when query does not start with /', () => { expect(isSlashCommand('help')).toBe(false); - expect(isSlashCommand('memory show')).toBe(false); + expect(isSlashCommand('config set')).toBe(false); expect(isSlashCommand('')).toBe(false); expect(isSlashCommand('path/to/file')).toBe(false); expect(isSlashCommand(' /help')).toBe(false); diff --git a/packages/cli/src/utils/commands.test.ts b/packages/cli/src/utils/commands.test.ts index 30040a03505..07c5c9fa8f1 100644 --- a/packages/cli/src/utils/commands.test.ts +++ b/packages/cli/src/utils/commands.test.ts @@ -23,20 +23,20 @@ const mockCommands: readonly SlashCommand[] = [ kind: CommandKind.FILE, }, { - name: 'memory', - description: 'Manage memory', - altNames: ['mem'], + name: 'config', + description: 'Manage configuration', + altNames: ['cfg'], subCommands: [ { - name: 'add', - description: 'Add to memory', + name: 'set', + description: 'Set configuration', action: async () => {}, kind: CommandKind.BUILT_IN, }, { - name: 'clear', - description: 'Clear memory', - altNames: ['c'], + name: 'reset', + description: 'Reset configuration', + altNames: ['r'], action: async () => {}, kind: CommandKind.BUILT_IN, }, @@ -64,34 +64,34 @@ describe('parseSlashCommand', () => { }); it('should parse a subcommand', () => { - const result = parseSlashCommand('/memory add', mockCommands); - expect(result.commandToExecute?.name).toBe('add'); + const result = parseSlashCommand('/config set', mockCommands); + expect(result.commandToExecute?.name).toBe('set'); expect(result.args).toBe(''); - expect(result.canonicalPath).toEqual(['memory', 'add']); + expect(result.canonicalPath).toEqual(['config', 'set']); }); it('should parse a subcommand with arguments', () => { const result = parseSlashCommand( - '/memory add some important data', + '/config set theme dark', mockCommands, ); - expect(result.commandToExecute?.name).toBe('add'); - expect(result.args).toBe('some important data'); - expect(result.canonicalPath).toEqual(['memory', 'add']); + expect(result.commandToExecute?.name).toBe('set'); + expect(result.args).toBe('theme dark'); + expect(result.canonicalPath).toEqual(['config', 'set']); }); it('should handle a command alias', () => { - const result = parseSlashCommand('/mem add some data', mockCommands); - expect(result.commandToExecute?.name).toBe('add'); - expect(result.args).toBe('some data'); - expect(result.canonicalPath).toEqual(['memory', 'add']); + const result = parseSlashCommand('/cfg set theme dark', mockCommands); + expect(result.commandToExecute?.name).toBe('set'); + expect(result.args).toBe('theme dark'); + expect(result.canonicalPath).toEqual(['config', 'set']); }); it('should handle a subcommand alias', () => { - const result = parseSlashCommand('/memory c', mockCommands); - expect(result.commandToExecute?.name).toBe('clear'); + const result = parseSlashCommand('/config r', mockCommands); + expect(result.commandToExecute?.name).toBe('reset'); expect(result.args).toBe(''); - expect(result.canonicalPath).toEqual(['memory', 'clear']); + expect(result.canonicalPath).toEqual(['config', 'reset']); }); it('should return undefined for an unknown command', () => { @@ -103,22 +103,22 @@ describe('parseSlashCommand', () => { it('should return the parent command if subcommand is unknown', () => { const result = parseSlashCommand( - '/memory unknownsub some args', + '/config unknownsub some args', mockCommands, ); - expect(result.commandToExecute?.name).toBe('memory'); + expect(result.commandToExecute?.name).toBe('config'); expect(result.args).toBe('unknownsub some args'); - expect(result.canonicalPath).toEqual(['memory']); + expect(result.canonicalPath).toEqual(['config']); }); it('should handle extra whitespace', () => { const result = parseSlashCommand( - ' /memory add some data ', + ' /config set theme dark ', mockCommands, ); - expect(result.commandToExecute?.name).toBe('add'); - expect(result.args).toBe('some data'); - expect(result.canonicalPath).toEqual(['memory', 'add']); + expect(result.commandToExecute?.name).toBe('set'); + expect(result.args).toBe('theme dark'); + expect(result.canonicalPath).toEqual(['config', 'set']); }); it('should return undefined if query does not start with a slash', () => { diff --git a/packages/cli/src/utils/commands.ts b/packages/cli/src/utils/commands.ts index c96c8c6ef7f..2e3315e3bc4 100644 --- a/packages/cli/src/utils/commands.ts +++ b/packages/cli/src/utils/commands.ts @@ -16,7 +16,7 @@ export type ParsedSlashCommand = { * Parses a raw slash command string into its command, arguments, and canonical path. * If no valid command is found, the `commandToExecute` property will be `undefined`. * - * @param query The raw input string, e.g., "/memory add some data" or "/help". + * @param query The raw input string, e.g., "/config set theme dark" or "/help". * @param commands The list of available top-level slash commands. * @returns An object containing the resolved command, its arguments, and its canonical path. */ diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 49ed2f75d98..1ebc1699f39 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -578,11 +578,11 @@ describe('Server Config (config.ts)', () => { await config.refreshHierarchicalMemory(); expect(config.getUserMemory()).toContain('Project rules'); - expect(config.getUserMemory()).toContain('## Managed Auto-Memory'); + expect(config.getUserMemory()).toContain('# auto memory'); expect(config.getUserMemory()).toContain('[Project Memory](project.md)'); }); - it('refreshHierarchicalMemory should preserve legacy behavior when no managed auto-memory index exists', async () => { + it('refreshHierarchicalMemory should include empty memory prompt when no managed auto-memory index exists', async () => { const config = new Config(baseParams); vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ @@ -593,9 +593,9 @@ describe('Server Config (config.ts)', () => { await config.refreshHierarchicalMemory(); - expect(config.getUserMemory()).toBe( - '--- Context from: QWEN.md ---\nProject rules', - ); + expect(config.getUserMemory()).toContain('Project rules'); + expect(config.getUserMemory()).toContain('# auto memory'); + expect(config.getUserMemory()).toContain('MEMORY.md is currently empty'); }); it('Config constructor should call setGeminiMdFilename with contextFileName if provided', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ea244863316..1587860e0db 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -129,6 +129,7 @@ import { setDebugLogSession, type DebugLogger, } from '../utils/debugLogger.js'; +import { getAutoMemoryRoot } from '../memory/paths.js'; import { readAutoMemoryIndex } from '../memory/store.js'; import { appendManagedAutoMemoryToUserMemory } from '../memory/prompt.js'; @@ -422,6 +423,8 @@ export interface ConfigParameters { agents?: AgentsCollabSettings; /** Enable hook system for lifecycle events */ enableHooks?: boolean; + /** Enable managed auto-memory background extraction and dream. Defaults to true. */ + enableManagedAutoMemory?: boolean; /** Hooks configuration from settings */ hooks?: Record; /** Hooks config settings (enabled, disabled list) */ @@ -596,6 +599,7 @@ export class Config { private readonly channel: string | undefined; private readonly defaultFileEncoding: FileEncodingType | undefined; private readonly enableHooks: boolean; + private readonly enableManagedAutoMemory: boolean; private readonly hooks?: Record; private readonly hooksConfig?: Record; private hookSystem?: HookSystem; @@ -762,6 +766,7 @@ export class Config { isWorkspaceTrusted: this.isTrustedFolder(), }); this.enableHooks = params.enableHooks ?? false; + this.enableManagedAutoMemory = params.enableManagedAutoMemory ?? true; this.hooks = params.hooks; this.hooksConfig = params.hooksConfig; } @@ -999,6 +1004,7 @@ export class Config { this.setUserMemory( appendManagedAutoMemoryToUserMemory( memoryContent, + getAutoMemoryRoot(this.getProjectRoot()), managedAutoMemoryIndex, ), ); @@ -1795,6 +1801,10 @@ export class Config { return this.enableHooks; } + getManagedAutoMemoryEnabled(): boolean { + return this.enableManagedAutoMemory; + } + /** * Get the message bus instance. * Returns undefined if not set. @@ -2197,7 +2207,14 @@ export class Config { await registerCoreTool(EditTool, this); await registerCoreTool(WriteFileTool, this); await registerCoreTool(ShellTool, this); - await registerCoreTool(MemoryTool); + // When managed auto-memory is enabled, the model writes per-entry files + // directly (as instructed by buildManagedAutoMemoryPrompt). The legacy + // save_memory tool writes to a single QWEN.md file and conflicts with that + // model. Claude Code solves this the same way: no save_memory tool exists + // when the file-based memory system is active. + if (!this.getManagedAutoMemoryEnabled()) { + await registerCoreTool(MemoryTool); + } await registerCoreTool(TodoWriteTool, this); await registerCoreTool(AskUserQuestionTool, this); !this.sdkMode && (await registerCoreTool(ExitPlanModeTool, this)); diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index b4e147d3f84..fe2ba75eaad 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -125,7 +125,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -347,7 +346,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -579,7 +577,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -796,7 +793,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1013,7 +1009,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1230,7 +1225,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1447,7 +1441,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1664,7 +1657,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1881,7 +1873,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -2098,7 +2089,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -2338,7 +2328,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -2641,7 +2630,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -2881,7 +2869,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -3180,7 +3167,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -3397,7 +3383,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index ab5100a8099..2c1a1522604 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -398,6 +398,7 @@ describe('Gemini Client (client.ts)', () => { getResumedSessionData: vi.fn().mockReturnValue(undefined), getArenaAgentClient: vi.fn().mockReturnValue(null), getEnableHooks: vi.fn().mockReturnValue(false), + getManagedAutoMemoryEnabled: vi.fn().mockReturnValue(true), getArenaManager: vi.fn().mockReturnValue(null), getMessageBus: vi.fn().mockReturnValue(undefined), hasHooksForEvent: vi.fn().mockReturnValue(false), @@ -1331,14 +1332,17 @@ hello it('should prepend relevant managed auto-memory prompt when recall returns content', async () => { vi.mocked(resolveRelevantAutoMemoryPromptForQuery).mockResolvedValue({ - prompt: '## Relevant Managed Auto-Memory\n\n- User prefers terse responses.', + prompt: '## Relevant memory\n\nUser prefers terse responses.', selectedDocs: [ { type: 'user', filePath: '/test/project/root/.qwen/memory/user.md', + relativePath: 'user.md', + filename: 'user.md', title: 'User Memory', description: 'User preferences', body: '- User prefers terse responses.', + mtimeMs: 1, }, ], strategy: 'model', @@ -1376,7 +1380,7 @@ hello expect(mockTurnRunFn).toHaveBeenCalledWith( 'test-model', expect.arrayContaining([ - '## Relevant Managed Auto-Memory\n\n- User prefers terse responses.', + '## Relevant memory\n\nUser prefers terse responses.', 'Please answer tersely', ]), expect.any(AbortSignal), @@ -1386,14 +1390,17 @@ hello it('should track surfaced managed memory paths across user queries', async () => { vi.mocked(resolveRelevantAutoMemoryPromptForQuery) .mockResolvedValueOnce({ - prompt: '## Relevant Managed Auto-Memory\n\n- User prefers terse responses.', + prompt: '## Relevant memory\n\nUser prefers terse responses.', selectedDocs: [ { type: 'user', filePath: '/test/project/root/.qwen/memory/user.md', + relativePath: 'user.md', + filename: 'user.md', title: 'User Memory', description: 'User preferences', body: '- User prefers terse responses.', + mtimeMs: 1, }, ], strategy: 'model', @@ -1492,7 +1499,7 @@ hello sessionId: 'test-session-id', config: mockConfig, }); - expect(events).toContainEqual({ + expect(events).not.toContainEqual({ type: GeminiEventType.HookSystemMessage, value: 'Managed auto-memory updated: user.md', }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index ed95cb44833..a69f5e84c55 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -49,7 +49,10 @@ import { LoopDetectionService } from '../services/loopDetectionService.js'; import { AgentTool } from '../tools/agent.js'; import { scheduleAutoMemoryExtract } from '../memory/extract.js'; import { scheduleManagedAutoMemoryDream } from '../memory/dreamScheduler.js'; -import { resolveRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; +import { + type RelevantAutoMemoryPromptResult, + resolveRelevantAutoMemoryPromptForQuery, +} from '../memory/recall.js'; // Telemetry import { @@ -101,6 +104,12 @@ export interface SendMessageOptions { type: SendMessageType; } +const EMPTY_RELEVANT_AUTO_MEMORY_RESULT: RelevantAutoMemoryPromptResult = { + prompt: '', + selectedDocs: [], + strategy: 'none', +}; + export class GeminiClient { private chat?: GeminiChat; private sessionTurnCount = 0; @@ -454,34 +463,40 @@ export class GeminiClient { } } - private async *runManagedAutoMemoryExtraction( + private runManagedAutoMemoryBackgroundTasks( messageType: SendMessageType, - ): AsyncGenerator { + ): void { if (messageType !== SendMessageType.UserQuery) { return; } - const result = await scheduleAutoMemoryExtract({ - projectRoot: this.config.getProjectRoot(), - sessionId: this.config.getSessionId(), - history: this.getHistory(), + if (!this.config.getManagedAutoMemoryEnabled()) { + return; + } + + const projectRoot = this.config.getProjectRoot(); + const sessionId = this.config.getSessionId(); + const history = this.getHistory(); + + void scheduleAutoMemoryExtract({ + projectRoot, + sessionId, + history, config: this.config, + }).catch((error) => { + debugLogger.warn( + 'Failed to schedule managed auto-memory extraction.', + error, + ); }); void scheduleManagedAutoMemoryDream({ - projectRoot: this.config.getProjectRoot(), - sessionId: this.config.getSessionId(), + projectRoot, + sessionId, config: this.config, }).catch((error) => { debugLogger.warn('Failed to schedule managed auto-memory dream.', error); }); - - if (result?.systemMessage) { - yield { - type: GeminiEventType.HookSystemMessage, - value: result.systemMessage, - }; - } } async *sendMessageStream( @@ -492,6 +507,11 @@ export class GeminiClient { turns: number = MAX_TURNS, ): AsyncGenerator { const messageType = options?.type ?? SendMessageType.UserQuery; + let relevantAutoMemoryPromise: + | Promise< + Awaited> + > + | undefined; if (messageType === SendMessageType.Retry) { this.stripOrphanedUserEntriesFromHistory(); @@ -551,6 +571,20 @@ export class GeminiClient { this.loopDetector.reset(prompt_id); this.lastPromptId = prompt_id; + if (this.config.getManagedAutoMemoryEnabled()) { + relevantAutoMemoryPromise = resolveRelevantAutoMemoryPromptForQuery( + this.config.getProjectRoot(), + partToString(request), + { + config: this.config, + excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, + }, + ).catch((error) => { + debugLogger.warn('Managed auto-memory recall prefetch failed.', error); + return EMPTY_RELEVANT_AUTO_MEMORY_RESULT; + }); + } + // record user message for session management this.config.getChatRecordingService()?.recordUserMessage(request); @@ -647,14 +681,9 @@ export class GeminiClient { let requestToSent = await flatMapTextParts(request, async (text) => [text]); if (messageType === SendMessageType.UserQuery) { const systemReminders = []; - const relevantAutoMemory = await resolveRelevantAutoMemoryPromptForQuery( - this.config.getProjectRoot(), - partToString(request), - { - config: this.config, - excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, - }, - ); + const relevantAutoMemory = relevantAutoMemoryPromise + ? await relevantAutoMemoryPromise + : EMPTY_RELEVANT_AUTO_MEMORY_RESULT; const relevantAutoMemoryPrompt = relevantAutoMemory.prompt; if (relevantAutoMemoryPrompt) { @@ -811,7 +840,7 @@ export class GeminiClient { if (!turn.pendingToolCalls.length && signal && !signal.aborted) { if (this.config.getSkipNextSpeakerCheck()) { - yield* this.runManagedAutoMemoryExtraction(messageType); + this.runManagedAutoMemoryBackgroundTasks(messageType); // Report completed before returning — agent has no more work to do if (arenaAgentClient) { await arenaAgentClient.reportCompleted(); @@ -846,7 +875,7 @@ export class GeminiClient { ); } - yield* this.runManagedAutoMemoryExtraction(messageType); + this.runManagedAutoMemoryBackgroundTasks(messageType); if (arenaAgentClient) { // No continuation needed — agent completed its task diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 37948b82aa6..e52aff2fcd0 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -267,7 +267,6 @@ IMPORTANT: Always use the ${ToolNames.TODO_WRITE} tool to plan and track tasks t - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the '${ToolNames.TODO_WRITE}' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the '${ToolNames.AGENT}' tool in order to reduce context usage. You should proactively use the '${ToolNames.AGENT}' tool with specialized agents when the task at hand matches the agent's description. -- **Remembering Facts:** Use the '${ToolNames.MEMORY}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 86a4b43d008..4ffd0a287cf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -127,9 +127,6 @@ export * from './memory/entries.js'; export * from './memory/indexer.js'; export * from './memory/prompt.js'; export * from './memory/state.js'; -export * from './memory/status.js'; -export * from './memory/forget.js'; -export * from './memory/governance.js'; export * from './memory/extractionAgentPlanner.js'; export * from './memory/extractionPlanner.js'; export * from './memory/extract.js'; @@ -140,6 +137,9 @@ export * from './memory/dreamScheduler.js'; export * from './memory/scan.js'; export * from './memory/relevanceSelector.js'; export * from './memory/recall.js'; +export * from './memory/forget.js'; +export * from './memory/governance.js'; +export * from './memory/status.js'; // ============================================================================ // IDE Support diff --git a/packages/core/src/memory/dream.test.ts b/packages/core/src/memory/dream.test.ts index 51668ad7671..da812dc9585 100644 --- a/packages/core/src/memory/dream.test.ts +++ b/packages/core/src/memory/dream.test.ts @@ -9,8 +9,9 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { getAutoMemoryIndexPath, getAutoMemoryTopicPath } from './paths.js'; +import { getAutoMemoryFilePath, getAutoMemoryIndexPath } from './paths.js'; import { runManagedAutoMemoryDream } from './dream.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; vi.mock('./dreamAgentPlanner.js', () => ({ @@ -41,121 +42,144 @@ describe('managed auto-memory dream', () => { }); it('deduplicates repeated bullet entries in topic files', async () => { + const firstPath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse.md')); + const duplicatePath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse-duplicate.md')); + await fs.mkdir(path.dirname(firstPath), { recursive: true }); await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'user'), + firstPath, [ '---', 'type: user', - 'title: User Memory', + 'name: User Memory', 'description: User profile', '---', '', - '# User Memory', + 'User prefers terse responses.', + ].join('\n'), + 'utf-8', + ); + await fs.writeFile( + duplicatePath, + [ + '---', + 'type: user', + 'name: User Memory Duplicate', + 'description: Duplicate terse preference', + '---', '', - '- User prefers terse responses.', - '- User prefers terse responses.', - '- User likes dark mode.', + 'User prefers terse responses.', ].join('\n'), 'utf-8', ); const result = await runManagedAutoMemoryDream(projectRoot); - const content = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - 'utf-8', - ); const index = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + const userDocs = docs.filter((doc) => doc.type === 'user'); expect(result.touchedTopics).toContain('user'); expect(result.dedupedEntries).toBe(1); - expect(content.match(/User prefers terse responses\./g)).toHaveLength(1); - expect(index.match(/User prefers terse responses\./g)).toHaveLength(1); + expect(userDocs).toHaveLength(1); + expect(userDocs[0]?.body).toContain('User prefers terse responses.'); + expect(index).toContain('(user/'); }); - it('preserves richer schema metadata when deduplicating entries', async () => { + it('preserves Claude-style why/apply metadata when deduplicating entries', async () => { + const firstPath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse.md')); + const duplicatePath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse-context.md')); + await fs.mkdir(path.dirname(firstPath), { recursive: true }); await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'user'), + firstPath, [ '---', 'type: user', - 'title: User Memory', + 'name: User Memory', 'description: User profile', '---', '', - '# User Memory', + 'User prefers terse responses.', '', - '- User prefers terse responses.', - ' - Why: They repeatedly ask for concise replies.', - '- User prefers terse responses.', - ' - Stability: stable', + 'Why: They repeatedly ask for concise replies.', ].join('\n'), 'utf-8', ); - - const contentBefore = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'user'), + await fs.writeFile( + duplicatePath, + [ + '---', + 'type: user', + 'name: User Memory Context', + 'description: Duplicate terse preference with apply guidance', + '---', + '', + 'User prefers terse responses.', + '', + 'How to apply: Lead with a short answer before details.', + ].join('\n'), 'utf-8', ); - expect(contentBefore).toContain('Stability: stable'); await runManagedAutoMemoryDream(projectRoot); - const content = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - 'utf-8', - ); + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + const content = docs.find((doc) => doc.type === 'user')?.body ?? ''; expect(content.match(/User prefers terse responses\./g)).toHaveLength(1); - expect(content).toContain(' - Why: They repeatedly ask for concise replies.'); - expect(content).toContain(' - Stability: stable'); + expect(content).toContain('Why: They repeatedly ask for concise replies.'); + expect(content).toContain('How to apply: Lead with a short answer before details.'); }); - it('restores the empty placeholder when no bullet entries remain', async () => { + it('leaves empty placeholder documents unchanged', async () => { + const projectPath = getAutoMemoryFilePath(projectRoot, path.join('project', 'empty.md')); + await fs.mkdir(path.dirname(projectPath), { recursive: true }); await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'project'), + projectPath, [ '---', 'type: project', - 'title: Project Memory', + 'name: Project Memory', 'description: Project facts', '---', '', - '# Project Memory', - '', + '_No entries yet._', ].join('\n'), 'utf-8', ); await runManagedAutoMemoryDream(projectRoot); - const content = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'project'), - 'utf-8', - ); + const content = await fs.readFile(projectPath, 'utf-8'); expect(content).toContain('_No entries yet._'); }); - it('prefers agent rewrites when config is provided', async () => { - vi.mocked(planManagedAutoMemoryDreamByAgent).mockResolvedValue([ - { - topic: 'user', - body: '# User Memory\n\n- User prefers terse responses.', - }, - ]); + it('falls back to mechanical dedupe when config is provided', async () => { + const firstPath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse.md')); + const duplicatePath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse-again.md')); + await fs.mkdir(path.dirname(firstPath), { recursive: true }); await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'user'), + firstPath, [ '---', 'type: user', - 'title: User Memory', + 'name: User Memory', 'description: User profile', '---', '', - '# User Memory', + 'User prefers terse responses.', + ].join('\n'), + 'utf-8', + ); + await fs.writeFile( + duplicatePath, + [ + '---', + 'type: user', + 'name: User Memory Duplicate', + 'description: Duplicate terse preference', + '---', '', - '- User prefers terse responses.', - '- User prefers terse responses.', + 'User prefers terse responses.', ].join('\n'), 'utf-8', ); @@ -168,14 +192,11 @@ describe('managed auto-memory dream', () => { getModel: vi.fn(), } as unknown as Config, ); - const content = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - 'utf-8', - ); + const docs = await scanAutoMemoryTopicDocuments(projectRoot); - expect(result.touchedTopics).toEqual(['user']); + expect(result.touchedTopics).toContain('user'); expect(result.dedupedEntries).toBe(1); - expect(content.match(/User prefers terse responses\./g)).toHaveLength(1); + expect(docs.filter((doc) => doc.type === 'user')).toHaveLength(1); }); it('falls back to mechanical dream when the agent planner fails', async () => { @@ -183,19 +204,33 @@ describe('managed auto-memory dream', () => { new Error('agent failed'), ); + const firstPath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse.md')); + const duplicatePath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse-failover.md')); + await fs.mkdir(path.dirname(firstPath), { recursive: true }); + await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'user'), + firstPath, [ '---', 'type: user', - 'title: User Memory', + 'name: User Memory', 'description: User profile', '---', '', - '# User Memory', + 'User prefers terse responses.', + ].join('\n'), + 'utf-8', + ); + await fs.writeFile( + duplicatePath, + [ + '---', + 'type: user', + 'name: User Memory Duplicate', + 'description: Duplicate terse preference', + '---', '', - '- User prefers terse responses.', - '- User prefers terse responses.', + 'User prefers terse responses.', ].join('\n'), 'utf-8', ); diff --git a/packages/core/src/memory/dream.ts b/packages/core/src/memory/dream.ts index 5e23a787d7b..836553a986a 100644 --- a/packages/core/src/memory/dream.ts +++ b/packages/core/src/memory/dream.ts @@ -7,21 +7,19 @@ import * as fs from 'node:fs/promises'; import type { Config } from '../config/config.js'; import { - getAutoMemoryBodyHeading, mergeAutoMemoryEntry, parseAutoMemoryEntries, renderAutoMemoryBody, } from './entries.js'; -import { getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; +import { getAutoMemoryMetadataPath } from './paths.js'; import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; -import { parseAutoMemoryTopicDocument } from './scan.js'; -import { ensureAutoMemoryScaffold } from './store.js'; import { - AUTO_MEMORY_TYPES, - type AutoMemoryMetadata, - type AutoMemoryType, -} from './types.js'; + scanAutoMemoryTopicDocuments, + type ScannedAutoMemoryDocument, +} from './scan.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { AUTO_MEMORY_TYPES, type AutoMemoryMetadata, type AutoMemoryType } from './types.js'; export interface AutoMemoryDreamResult { touchedTopics: AutoMemoryType[]; @@ -29,19 +27,8 @@ export interface AutoMemoryDreamResult { systemMessage?: string; } -function countDuplicateBullets(body: string): number { - const bullets = parseAutoMemoryEntries(body) - .map((entry) => entry.summary) - .filter((line) => line.length > 0); - - return Math.max( - 0, - bullets.length - new Set(bullets.map((line) => line.toLowerCase())).size, - ); -} function buildDreamedBody(body: string): { body: string; dedupedEntries: number } { - const heading = getAutoMemoryBodyHeading(body); const entries = parseAutoMemoryEntries(body); const mergedEntries = Array.from( entries.reduce((map, entry) => { @@ -55,7 +42,7 @@ function buildDreamedBody(body: string): { body: string; dedupedEntries: number .sort((a, b) => a.summary.localeCompare(b.summary)); return { - body: renderAutoMemoryBody(heading, mergedEntries), + body: renderAutoMemoryBody('', mergedEntries), dedupedEntries: Math.max(0, entries.length - mergedEntries.length), }; } @@ -77,46 +64,46 @@ async function runDreamByAgent( projectRoot: string, config: Config, ): Promise { - const rewrites = await planManagedAutoMemoryDreamByAgent(config, projectRoot); - if (rewrites.length === 0) { + const result = await planManagedAutoMemoryDreamByAgent(config, projectRoot); + if (result.filesTouched.length === 0) { return null; } + // Infer which topics were touched from the file paths const touchedTopics = new Set(); - let dedupedEntries = 0; - - for (const rewrite of rewrites) { - const topicPath = getAutoMemoryTopicPath(projectRoot, rewrite.topic); - const current = await fs.readFile(topicPath, 'utf-8'); - const parsed = parseAutoMemoryTopicDocument(topicPath, current); - if (!parsed) { - continue; - } - - const nextBody = rewrite.body.trim(); - dedupedEntries += Math.max( - 0, - countDuplicateBullets(parsed.body) - countDuplicateBullets(nextBody), - ); - if (nextBody === parsed.body.trim()) { - continue; + for (const filePath of result.filesTouched) { + const normalized = filePath.replace(/\\/g, '/'); + for (const type of AUTO_MEMORY_TYPES) { + if (normalized.includes(`/${type}/`)) { + touchedTopics.add(type); + } } - - const next = current.replace(parsed.body, nextBody); - await fs.writeFile(topicPath, next, 'utf-8'); - touchedTopics.add(rewrite.topic); } + const summary = result.finalText + ? result.finalText.trim().slice(0, 300) + : `updated ${result.filesTouched.length} file(s)`; + return { touchedTopics: [...touchedTopics], - dedupedEntries, - systemMessage: - touchedTopics.size > 0 - ? `Managed auto-memory dream updated: ${[...touchedTopics].map((topic) => `${topic}.md`).join(', ')}` - : undefined, + dedupedEntries: 0, + systemMessage: `Managed auto-memory dream (agent): ${summary}`, }; } +async function writeUpdatedBody( + doc: ScannedAutoMemoryDocument, + nextBody: string, +): Promise { + const current = await fs.readFile(doc.filePath, 'utf-8'); + const next = current.replace(doc.body, nextBody); + if (next === current) { + return false; + } + await fs.writeFile(doc.filePath, next, 'utf-8'); + return true; +} + export async function runManagedAutoMemoryDream( projectRoot: string, now = new Date(), @@ -140,26 +127,48 @@ export async function runManagedAutoMemoryDream( } } + const docs = await scanAutoMemoryTopicDocuments(projectRoot); const touchedTopics = new Set(); let dedupedEntries = 0; + const canonicalByKey = new Map(); + + for (const doc of docs) { + const dreamed = buildDreamedBody(doc.body); + if (dreamed.body !== doc.body.trim()) { + const wrote = await writeUpdatedBody(doc, dreamed.body); + if (wrote) { + touchedTopics.add(doc.type); + } + } - for (const topic of AUTO_MEMORY_TYPES) { - const topicPath = getAutoMemoryTopicPath(projectRoot, topic); - const current = await fs.readFile(topicPath, 'utf-8'); - const parsed = parseAutoMemoryTopicDocument(topicPath, current); - if (!parsed) { + const [entry] = parseAutoMemoryEntries(dreamed.body); + if (!entry) { continue; } - const dreamed = buildDreamedBody(parsed.body); dedupedEntries += dreamed.dedupedEntries; - if (dreamed.body === parsed.body.trim()) { + const dedupeKey = `${doc.type}:${entry.summary.toLowerCase()}`; + const canonical = canonicalByKey.get(dedupeKey); + + if (!canonical) { + canonicalByKey.set(dedupeKey, doc); continue; } - const next = current.replace(parsed.body, dreamed.body); - await fs.writeFile(topicPath, next, 'utf-8'); - touchedTopics.add(topic); + const [canonicalEntry] = parseAutoMemoryEntries(canonical.body); + const mergedEntry = mergeAutoMemoryEntry(canonicalEntry ?? entry, entry); + const mergedBody = renderAutoMemoryBody('', [mergedEntry]); + + if (mergedBody !== canonical.body.trim()) { + const wrote = await writeUpdatedBody(canonical, mergedBody); + if (wrote) { + touchedTopics.add(canonical.type); + } + } + + await fs.unlink(doc.filePath); + touchedTopics.add(doc.type); + dedupedEntries += 1; } if (touchedTopics.size > 0) { diff --git a/packages/core/src/memory/dreamAgentPlanner.test.ts b/packages/core/src/memory/dreamAgentPlanner.test.ts index 7894812432b..66521ee3481 100644 --- a/packages/core/src/memory/dreamAgentPlanner.test.ts +++ b/packages/core/src/memory/dreamAgentPlanner.test.ts @@ -9,7 +9,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { getAutoMemoryTopicPath } from './paths.js'; +import type { BackgroundAgentResult } from '../background/backgroundAgentRunner.js'; import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; import { ensureAutoMemoryScaffold } from './store.js'; @@ -38,84 +38,71 @@ describe('dreamAgentPlanner', () => { }); }); - it('returns validated rewrites from the background agent', async () => { - await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - [ - '---', - 'type: user', - 'title: User Memory', - 'description: User profile', - '---', - '', - '# User Memory', - '', - '- User prefers terse responses.', - '- User prefers terse responses.', - ].join('\n'), - 'utf-8', - ); + it('returns the background agent result from the runner', async () => { + const mockResult: BackgroundAgentResult = { + taskId: 'task-1', + status: 'completed', + finalText: 'Merged 2 duplicate Vim entries into prefers-vim.md.', + filesTouched: [path.join(projectRoot, '.qwen', 'memory', 'user', 'prefers-vim.md')], + }; const runner = { - run: vi.fn().mockResolvedValue({ - taskId: 'task-1', - status: 'completed', - finalText: JSON.stringify({ - rewrites: [ - { - topic: 'user', - body: '# User Memory\n\n- User prefers terse responses.', - }, - ], - }), - filesTouched: [], - }), + run: vi.fn().mockResolvedValue(mockResult), }; - const rewrites = await planManagedAutoMemoryDreamByAgent( - config, - projectRoot, - runner, - ); + const result = await planManagedAutoMemoryDreamByAgent(config, projectRoot, runner); - expect(rewrites).toEqual([ - { - topic: 'user', - body: '# User Memory\n\n- User prefers terse responses.', - }, - ]); + expect(result).toBe(mockResult); expect(runner.run).toHaveBeenCalledWith( expect.objectContaining({ projectRoot, sessionId: 'session-1', runConfig: expect.objectContaining({ - max_turns: 4, - max_time_minutes: 2, + max_turns: 8, + max_time_minutes: 5, }), - toolConfig: { tools: ['read_file'] }, + toolConfig: { + tools: [ + 'read_file', + 'write_file', + 'edit', + 'list_directory', + 'glob', + 'grep_search', + ], + }, }), ); }); - it('rejects invalid agent output', async () => { + it('throws when the agent fails', async () => { const runner = { run: vi.fn().mockResolvedValue({ taskId: 'task-2', - status: 'completed', - finalText: JSON.stringify({ - rewrites: [ - { - topic: 'user', - body: ' ', - }, - ], - }), + status: 'failed', + error: 'Model timed out', filesTouched: [], - }), + } satisfies BackgroundAgentResult), }; await expect( planManagedAutoMemoryDreamByAgent(config, projectRoot, runner), - ).rejects.toThrow('Invalid dream agent response: empty body'); + ).rejects.toThrow('Model timed out'); + }); + + it('returns cancelled result without throwing', async () => { + const mockResult: BackgroundAgentResult = { + taskId: 'task-3', + status: 'cancelled', + filesTouched: [], + }; + + const runner = { + run: vi.fn().mockResolvedValue(mockResult), + }; + + const result = await planManagedAutoMemoryDreamByAgent(config, projectRoot, runner); + expect(result.status).toBe('cancelled'); + expect(result.filesTouched).toHaveLength(0); }); }); diff --git a/packages/core/src/memory/dreamAgentPlanner.ts b/packages/core/src/memory/dreamAgentPlanner.ts index f2043e29890..d8ced166486 100644 --- a/packages/core/src/memory/dreamAgentPlanner.ts +++ b/packages/core/src/memory/dreamAgentPlanner.ts @@ -9,144 +9,93 @@ import { BackgroundAgentRunner, type BackgroundAgentResult, } from '../background/backgroundAgentRunner.js'; -import { SchemaValidator } from '../utils/schemaValidator.js'; -import { safeJsonParse } from '../utils/safeJsonParse.js'; -import { - AUTO_MEMORY_TYPES, - type AutoMemoryType, -} from './types.js'; -import { scanAutoMemoryTopicDocuments } from './scan.js'; +import { getProjectHash, QWEN_DIR } from '../utils/paths.js'; +import { AUTO_MEMORY_INDEX_FILENAME, getAutoMemoryRoot } from './paths.js'; -const MAX_TOPIC_BODY_CHARS = 2_000; +const MAX_TURNS = 8; +const MAX_TIME_MINUTES = 5; -const DREAM_AGENT_SYSTEM_PROMPT = `You are a background memory consolidation agent for an AI coding assistant. +const DREAM_AGENT_SYSTEM_PROMPT = `You are performing a managed memory dream — a reflective consolidation pass over durable memory files. -Your job is to consolidate managed memory topic documents into cleaner, deduplicated topic bodies. +Your job is to read the existing memory files, identify duplicates and inconsistencies, and merge them into a clean, well-organized set of memory files. Rules: -- Output JSON only. -- Follow the schema exactly. -- Only rewrite topics that benefit from consolidation. -- Preserve durable information. -- Remove duplicates and obvious clutter. -- Keep topic headings. -- If a topic has no durable entries, use the standard placeholder: _No entries yet._ -- You may use read-only tools to inspect topic files when the provided excerpts are insufficient.`; - -const DREAM_AGENT_RESPONSE_SCHEMA: Record = { - type: 'object', - properties: { - rewrites: { - type: 'array', - items: { - type: 'object', - properties: { - topic: { - type: 'string', - enum: [...AUTO_MEMORY_TYPES], - }, - body: { - type: 'string', - }, - }, - required: ['topic', 'body'], - }, - }, - }, - required: ['rewrites'], -}; - -export interface AutoMemoryDreamRewrite { - topic: AutoMemoryType; - body: string; +- Merge semantically duplicate entries — if the same fact appears in multiple files, consolidate into one file and delete the rest. +- Preserve all durable information; do not delete content that is still accurate. +- Fix contradicted or stale facts only when the evidence is clear from the existing memory content. +- Update the MEMORY.md index to accurately reflect surviving files. +- Keep the MEMORY.md index concise: one line per file in the format \`- [Title](relative/path.md) — one-line hook\`. +- If nothing needs consolidation, do nothing and say so.`; + +function getTranscriptDir(projectRoot: string): string { + const projectHash = getProjectHash(projectRoot); + return `${QWEN_DIR}/tmp/${projectHash}/chats`; } -interface DreamAgentResponse { - rewrites: AutoMemoryDreamRewrite[]; -} - -interface BackgroundAgentRunnerLike { - run(request: Parameters[0]): Promise; -} - -function truncate(text: string, maxChars: number): string { - const normalized = text.trim(); - if (normalized.length <= maxChars) { - return normalized; - } - return `${normalized.slice(0, maxChars).trimEnd()}…`; -} - -async function buildTopicDocumentBlock(projectRoot: string): Promise { - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - return docs - .map((doc) => - [ - `topic=${doc.type}`, - `path=${doc.filePath}`, - `title=${doc.title}`, - `description=${doc.description || '(none)'}`, - 'body:', - truncate(doc.body, MAX_TOPIC_BODY_CHARS), - ].join('\n'), - ) - .join('\n\n'); -} - -function buildTaskPrompt(topicBlock: string): string { +function buildConsolidationTaskPrompt( + memoryRoot: string, + transcriptDir: string, +): string { return [ - 'Return a JSON object matching this schema:', - JSON.stringify(DREAM_AGENT_RESPONSE_SCHEMA, null, 2), + `Memory directory: \`${memoryRoot}\``, + `Session transcripts: \`${transcriptDir}\` (large JSONL files — grep narrowly, don't read whole files)`, + '', + '## Phase 1 — Orient', + '', + '- List the memory directory to see what files exist', + `- Read \`${memoryRoot}/${AUTO_MEMORY_INDEX_FILENAME}\` to understand the current index`, + '- Skim topic subdirectories (`user/`, `project/`, `feedback/`, `reference/`)', '', - 'Managed memory topic documents:', - topicBlock || '(no topics found)', + '## Phase 2 — Gather recent signal', + '', + 'Look for new information worth persisting. Sources in rough priority order:', + '', + '1. Existing memories that drifted — facts that contradict what you now know from current memory files', + '2. Transcript search — if you need specific context, grep session transcripts for narrow terms:', + ` \`grep -rn "" ${transcriptDir}/ --include="*.jsonl" | tail -50\``, + '', + "Don't exhaustively read transcripts. Look only for things you already suspect matter.", + '', + '## Phase 3 — Consolidate', + '', + 'For each topic directory:', + '- Identify duplicate or near-duplicate `.md` files (same fact expressed differently)', + '- Merge duplicates: write the canonical version into one file, delete the redundant files', + '- Fix stale or contradicted facts when clear from the existing content', + '', + '## Phase 4 — Update index', + '', + `Update \`${memoryRoot}/${AUTO_MEMORY_INDEX_FILENAME}\` to reflect surviving files.`, + 'Each entry: `- [Title](relative/path.md) — one-line hook`', + 'Remove pointers to deleted files. Add pointers to any newly created files.', + '', + '---', + '', + 'Summarize what you merged or pruned. If nothing needed consolidation, say so briefly.', ].join('\n'); } -function validateDreamAgentResponse( - parsed: DreamAgentResponse, -): AutoMemoryDreamRewrite[] { - const schemaError = SchemaValidator.validate( - DREAM_AGENT_RESPONSE_SCHEMA, - parsed, - ); - if (schemaError) { - throw new Error(`Invalid dream agent response: ${schemaError}`); - } - - const seen = new Set(); - for (const rewrite of parsed.rewrites) { - if (!rewrite.body.trim()) { - throw new Error('Invalid dream agent response: empty body'); - } - if (seen.has(rewrite.topic)) { - throw new Error('Invalid dream agent response: duplicate topic rewrite'); - } - seen.add(rewrite.topic); - } - - return parsed.rewrites.map((rewrite) => ({ - topic: rewrite.topic, - body: rewrite.body.trim(), - })); +interface BackgroundAgentRunnerLike { + run(request: Parameters[0]): Promise; } export async function planManagedAutoMemoryDreamByAgent( config: Config, projectRoot: string, runner: BackgroundAgentRunnerLike = new BackgroundAgentRunner(), -): Promise { - const topicBlock = await buildTopicDocumentBlock(projectRoot); +): Promise { + const memoryRoot = getAutoMemoryRoot(projectRoot); + const transcriptDir = getTranscriptDir(projectRoot); const result = await runner.run({ taskType: 'managed-auto-memory-dream-agent', title: 'Managed auto-memory dream agent', - description: 'Consolidate managed memory topic files into cleaner summaries.', + description: 'Consolidate managed memory files into cleaner summaries.', projectRoot, sessionId: config.getSessionId(), dedupeKey: `managed-auto-memory-dream-agent:${projectRoot}`, name: 'managed-auto-memory-dreamer', runtimeContext: config, - taskPrompt: buildTaskPrompt(topicBlock), + taskPrompt: buildConsolidationTaskPrompt(memoryRoot, transcriptDir), promptConfig: { systemPrompt: DREAM_AGENT_SYSTEM_PROMPT, }, @@ -155,24 +104,28 @@ export async function planManagedAutoMemoryDreamByAgent( temp: 0, }, runConfig: { - max_turns: 4, - max_time_minutes: 2, + max_turns: MAX_TURNS, + max_time_minutes: MAX_TIME_MINUTES, }, toolConfig: { - tools: ['read_file'], + tools: [ + 'read_file', + 'write_file', + 'edit', + 'list_directory', + 'glob', + 'grep_search', + ], }, metadata: { planner: 'dream-agent', - stage: 'agent-b', + stage: 'consolidation', }, }); - if (result.status !== 'completed' || !result.finalText) { - throw new Error(result.error || 'Dream agent did not complete successfully'); + if (result.status === 'failed') { + throw new Error(result.error || 'Dream agent failed'); } - const parsed = safeJsonParse(result.finalText, { - rewrites: [], - }); - return validateDreamAgentResponse(parsed); + return result; } diff --git a/packages/core/src/memory/dreamScheduler.test.ts b/packages/core/src/memory/dreamScheduler.test.ts index 5610f0562c1..5f21a4957c8 100644 --- a/packages/core/src/memory/dreamScheduler.test.ts +++ b/packages/core/src/memory/dreamScheduler.test.ts @@ -8,7 +8,11 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getAutoMemoryConsolidationLockPath, getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; +import { + getAutoMemoryConsolidationLockPath, + getAutoMemoryFilePath, + getAutoMemoryMetadataPath, +} from './paths.js'; import { createManagedAutoMemoryDreamRuntimeForTests, DEFAULT_AUTO_DREAM_MIN_HOURS, @@ -119,19 +123,32 @@ describe('managed auto-memory dream scheduler', () => { it('runs the existing mechanical dream logic inside scheduled tasks', async () => { const runtime = createManagedAutoMemoryDreamRuntimeForTests(); + const firstPath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse.md')); + const duplicatePath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse-duplicate.md')); + await fs.mkdir(path.dirname(firstPath), { recursive: true }); await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'user'), + firstPath, [ '---', 'type: user', - 'title: User Memory', + 'name: User Memory', 'description: User profile', '---', '', - '# User Memory', + 'User prefers terse responses.', + ].join('\n'), + 'utf-8', + ); + await fs.writeFile( + duplicatePath, + [ + '---', + 'type: user', + 'name: User Memory Duplicate', + 'description: Duplicate terse preference', + '---', '', - '- User prefers terse responses.', - '- User prefers terse responses.', + 'User prefers terse responses.', ].join('\n'), 'utf-8', ); diff --git a/packages/core/src/memory/dreamScheduler.ts b/packages/core/src/memory/dreamScheduler.ts index 0fb8e12bf7e..d3f044a2fa1 100644 --- a/packages/core/src/memory/dreamScheduler.ts +++ b/packages/core/src/memory/dreamScheduler.ts @@ -25,6 +25,7 @@ import type { AutoMemoryMetadata } from './types.js'; export const DEFAULT_AUTO_DREAM_MIN_HOURS = 24; export const DEFAULT_AUTO_DREAM_MIN_SESSIONS = 5; +const DREAM_LOCK_STALE_MS = 2 * 60 * 60 * 1000; export interface ScheduleManagedAutoMemoryDreamParams { projectRoot: string; @@ -76,7 +77,13 @@ function hoursSince(lastDreamAt: string | undefined, now: Date): number | null { async function lockExists(projectRoot: string): Promise { try { - await fs.access(getAutoMemoryConsolidationLockPath(projectRoot)); + const lockPath = getAutoMemoryConsolidationLockPath(projectRoot); + const stats = await fs.stat(lockPath); + const ageMs = Date.now() - stats.mtimeMs; + if (ageMs > DREAM_LOCK_STALE_MS) { + await fs.rm(lockPath, { force: true }); + return false; + } return true; } catch { return false; diff --git a/packages/core/src/memory/entries.test.ts b/packages/core/src/memory/entries.test.ts index b936d0a05cd..d9836580c20 100644 --- a/packages/core/src/memory/entries.test.ts +++ b/packages/core/src/memory/entries.test.ts @@ -11,14 +11,13 @@ import { } from './entries.js'; describe('managed auto-memory entries', () => { - it('parses and renders richer schema fields', () => { + it('parses and renders Claude-style why/apply fields', () => { const body = [ '# User Memory', '', '- User prefers terse responses.', ' - Why: This reduces back-and-forth.', ' - How to apply: Prefer concise summaries first.', - ' - Stability: stable', ].join('\n'); const entries = parseAutoMemoryEntries(body); @@ -27,12 +26,12 @@ describe('managed auto-memory entries', () => { summary: 'User prefers terse responses.', why: 'This reduces back-and-forth.', howToApply: 'Prefer concise summaries first.', - stability: 'stable', }, ]); - expect(renderAutoMemoryBody('# User Memory', entries)).toContain( - ' - How to apply: Prefer concise summaries first.', - ); + const rendered = renderAutoMemoryBody('# User Memory', entries); + expect(rendered).toContain('User prefers terse responses.'); + expect(rendered).toContain('Why: This reduces back-and-forth.'); + expect(rendered).toContain('How to apply: Prefer concise summaries first.'); }); }); \ No newline at end of file diff --git a/packages/core/src/memory/entries.ts b/packages/core/src/memory/entries.ts index 6347379d6fa..32df459d45b 100644 --- a/packages/core/src/memory/entries.ts +++ b/packages/core/src/memory/entries.ts @@ -4,19 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -export type ManagedAutoMemoryEntryStability = 'stable' | 'working'; - export interface ManagedAutoMemoryEntry { summary: string; why?: string; howToApply?: string; - stability?: ManagedAutoMemoryEntryStability; } function normalizeText(text: string): string { return text.replace(/\s+/g, ' ').trim(); } +/** + * Returns the `# Heading` line from a body, or a default. + * Used when reading old-format multi-entry topic files. + */ export function getAutoMemoryBodyHeading(body: string): string { return ( body @@ -26,6 +27,31 @@ export function getAutoMemoryBodyHeading(body: string): string { ); } +/** + * Parses memory entries from a body string. + * + * Supports two formats: + * + * **New (per-entry file) format** — the body starts with the plain-text summary, + * followed by optional top-level `Why:` / `How to apply:` lines: + * ``` + * Use short responses when debugging + * + * Why: The user prefers brevity in debug sessions. + * How to apply: Keep replies to 3 sentences max. + * ``` + * + * **Legacy (multi-entry topic file) format** — each entry begins with a `- bullet` + * prefix; nested fields use 2-space indent: + * ``` + * # Feedback Memory + * + * - Use short responses when debugging + * - Why: The user prefers brevity in debug sessions. + * - Always use TypeScript strict mode + * - Why: Catches bugs early. + * ``` + */ export function parseAutoMemoryEntries(body: string): ManagedAutoMemoryEntry[] { const entries: ManagedAutoMemoryEntry[] = []; let current: ManagedAutoMemoryEntry | null = null; @@ -36,17 +62,37 @@ export function parseAutoMemoryEntries(body: string): ManagedAutoMemoryEntry[] { continue; } + // Indented nested field — legacy format: ` - Why: ...` or ` Why: ...` if (current) { - const nestedMatch = rawLine.match( - /^\s{2,}(?:[-*]\s+)?(Why|How to apply|How_to_apply|Stability):\s*(.+)$/i, + const indentedMatch = rawLine.match( + /^\s{2,}(?:[-*]\s+)?(Why|How to apply|How_to_apply):\s*(.+)$/i, ); - if (nestedMatch) { - const [, rawKey, rawValue] = nestedMatch; + if (indentedMatch) { + const [, rawKey, rawValue] = indentedMatch; const value = normalizeText(rawValue); - if (!value) { - continue; + if (value) { + switch (rawKey.toLowerCase()) { + case 'why': + current.why = value; + break; + case 'how to apply': + case 'how_to_apply': + current.howToApply = value; + break; + } } + continue; + } + } + // Top-level named field — new format: `Why: ...` or `**How to apply**: ...` + const topLevelMatch = trimmed.match( + /^(?:\*\*)?(Why|How to apply|How_to_apply)(?:\*\*)?:\s*(.+)$/i, + ); + if (topLevelMatch) { + const [, rawKey, rawValue] = topLevelMatch; + const value = normalizeText(rawValue); + if (value && current) { switch (rawKey.toLowerCase()) { case 'why': current.why = value; @@ -55,15 +101,12 @@ export function parseAutoMemoryEntries(body: string): ManagedAutoMemoryEntry[] { case 'how_to_apply': current.howToApply = value; break; - case 'stability': - current.stability = - value.toLowerCase() === 'stable' ? 'stable' : 'working'; - break; } - continue; } + continue; } + // Bullet prefix — legacy format: `- Summary text` if (/^[-*]\s+/.test(trimmed)) { if (current) { entries.push(current); @@ -73,38 +116,36 @@ export function parseAutoMemoryEntries(body: string): ManagedAutoMemoryEntry[] { }; continue; } + + // Plain text — new per-entry format: first non-special line is the summary + if (!current) { + current = { summary: normalizeText(trimmed) }; + } } if (current) { entries.push(current); } - return entries.filter((entry) => entry.summary.length > 0); + return entries; } export function renderAutoMemoryBody( - heading: string, + _heading: string, entries: ManagedAutoMemoryEntry[], ): string { - const normalizedHeading = heading.trim().startsWith('# ') - ? heading.trim() - : '# Memory'; - if (entries.length === 0) { - return [normalizedHeading, '', '_No entries yet._'].join('\n'); + return '_No entries yet._'; } - const lines = [normalizedHeading, '']; + const lines: string[] = []; for (const entry of entries) { - lines.push(`- ${normalizeText(entry.summary)}`); + lines.push(normalizeText(entry.summary)); if (entry.why) { - lines.push(` - Why: ${normalizeText(entry.why)}`); + lines.push('', `Why: ${normalizeText(entry.why)}`); } if (entry.howToApply) { - lines.push(` - How to apply: ${normalizeText(entry.howToApply)}`); - } - if (entry.stability) { - lines.push(` - Stability: ${entry.stability}`); + lines.push('', `How to apply: ${normalizeText(entry.howToApply)}`); } } @@ -119,17 +160,13 @@ export function mergeAutoMemoryEntry( summary: incoming.summary || current.summary, why: current.why ?? incoming.why, howToApply: current.howToApply ?? incoming.howToApply, - stability: - current.stability === 'stable' || incoming.stability === 'stable' - ? 'stable' - : (current.stability ?? incoming.stability), }; } export function buildAutoMemoryEntrySearchText( entry: ManagedAutoMemoryEntry, ): string { - return [entry.summary, entry.why, entry.howToApply, entry.stability] + return [entry.summary, entry.why, entry.howToApply] .filter((value): value is string => Boolean(value)) .join(' ') .toLowerCase(); diff --git a/packages/core/src/memory/extract.test.ts b/packages/core/src/memory/extract.test.ts index 13435519c01..15078ae7534 100644 --- a/packages/core/src/memory/extract.test.ts +++ b/packages/core/src/memory/extract.test.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getAutoMemoryExtractCursorPath, getAutoMemoryIndexPath, getAutoMemoryTopicPath } from './paths.js'; +import { getAutoMemoryExtractCursorPath, getAutoMemoryIndexPath } from './paths.js'; import { applyExtractedMemoryPatches, buildTranscriptMessages, @@ -16,6 +16,7 @@ import { loadUnprocessedTranscriptSlice, runAutoMemoryExtract, } from './extract.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { resetAutoMemoryStateForTests } from './state.js'; @@ -77,37 +78,34 @@ describe('auto-memory extraction', () => { const touched = await applyExtractedMemoryPatches(projectRoot, patches); expect(touched).toEqual(['user', 'reference']); - const userTopic = await fs.readFile(getAutoMemoryTopicPath(projectRoot, 'user'), 'utf-8'); - const referenceTopic = await fs.readFile(getAutoMemoryTopicPath(projectRoot, 'reference'), 'utf-8'); const index = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + const userDoc = docs.find((doc) => doc.type === 'user'); + const referenceDoc = docs.find((doc) => doc.type === 'reference'); - expect(userTopic).toContain('- I prefer terse responses.'); - expect(referenceTopic).toContain('grafana.internal/d/api-latency'); + expect(userDoc?.body).toContain('I prefer terse responses.'); + expect(referenceDoc?.body).toContain('grafana.internal/d/api-latency'); expect(index).toContain('I prefer terse responses.'); expect(index).toContain('grafana.internal/d/api-latency'); }); - it('writes richer schema fields when extraction patches include them', async () => { + it('writes why and how-to-apply fields when extraction patches include them', async () => { const touched = await applyExtractedMemoryPatches(projectRoot, [ { topic: 'user', summary: 'User prefers terse responses.', why: 'They explicitly asked for concise replies.', howToApply: 'Lead with a short answer before details.', - stability: 'stable', sourceOffset: 0, }, ]); - const userTopic = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - 'utf-8', - ); + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + const userDoc = docs.find((doc) => doc.type === 'user'); expect(touched).toEqual(['user']); - expect(userTopic).toContain(' - Why: They explicitly asked for concise replies.'); - expect(userTopic).toContain(' - How to apply: Lead with a short answer before details.'); - expect(userTopic).toContain(' - Stability: stable'); + expect(userDoc?.body).toContain('Why: They explicitly asked for concise replies.'); + expect(userDoc?.body).toContain('How to apply: Lead with a short answer before details.'); }); it('updates cursor and avoids duplicate writes for repeated extraction', async () => { diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts index c4e0f8ca4ca..e753a4992f7 100644 --- a/packages/core/src/memory/extract.ts +++ b/packages/core/src/memory/extract.ts @@ -5,20 +5,27 @@ */ import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { partToString } from '../utils/partUtils.js'; -import { getAutoMemoryExtractCursorPath, getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; +import { + getAutoMemoryExtractCursorPath, + getAutoMemoryFilePath, + getAutoMemoryMetadataPath, +} from './paths.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { - getAutoMemoryBodyHeading, mergeAutoMemoryEntry, parseAutoMemoryEntries, renderAutoMemoryBody, - type ManagedAutoMemoryEntryStability, } from './entries.js'; -import { parseAutoMemoryTopicDocument } from './scan.js'; +import { + parseAutoMemoryTopicDocument, + scanAutoMemoryTopicDocuments, + type ScannedAutoMemoryDocument, +} from './scan.js'; import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; import { scheduleManagedAutoMemoryExtract } from './extractScheduler.js'; @@ -43,7 +50,6 @@ export interface AutoMemoryExtractPatch { summary: string; why?: string; howToApply?: string; - stability?: ManagedAutoMemoryEntryStability; sourceOffset: number; } @@ -59,6 +65,24 @@ function normalizeSummary(text: string): string { return text.replace(/\s+/g, ' ').trim(); } +function slugify(text: string): string { + return ( + text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'memory' + ); +} + +function buildMemoryTitle(summary: string): string { + const trimmed = normalizeSummary(summary); + if (trimmed.length <= 72) { + return trimmed; + } + return `${trimmed.slice(0, 69).trimEnd()}...`; +} + function stripRememberLead(text: string): string { return text .replace(/^please\s+/i, '') @@ -208,10 +232,6 @@ function normalizeExtractPatch( howToApply: patch.howToApply ? normalizeSummary(patch.howToApply) : undefined, - stability: - patch.stability === 'stable' || patch.stability === 'working' - ? patch.stability - : undefined, sourceOffset: patch.sourceOffset, }; } @@ -343,7 +363,6 @@ function appendPatchToTopicContent( return null; } - const heading = getAutoMemoryBodyHeading(parsed.body); const entries = parseAutoMemoryEntries(parsed.body); const normalizedSummary = patch.summary.toLowerCase(); const existingIndex = entries.findIndex( @@ -355,30 +374,81 @@ function appendPatchToTopicContent( summary: patch.summary, why: patch.why, howToApply: patch.howToApply, - stability: patch.stability, }); const current = entries[existingIndex]; if ( current.summary === merged.summary && current.why === merged.why && - current.howToApply === merged.howToApply && - current.stability === merged.stability + current.howToApply === merged.howToApply ) { return null; } entries[existingIndex] = merged; - return content.replace(parsed.body, renderAutoMemoryBody(heading, entries)); + return content.replace(parsed.body, renderAutoMemoryBody('', entries)); } entries.push({ summary: patch.summary, why: patch.why, howToApply: patch.howToApply, - stability: patch.stability, }); - return content.replace(parsed.body, renderAutoMemoryBody(heading, entries)); + return content.replace(parsed.body, renderAutoMemoryBody('', entries)); +} + +function buildMemoryDocumentContent( + patch: AutoMemoryExtractPatch, + title = buildMemoryTitle(patch.summary), +): string { + return [ + '---', + `name: ${title}`, + `description: ${patch.summary}`, + `type: ${patch.topic}`, + '---', + '', + renderAutoMemoryBody('', [ + { + summary: patch.summary, + why: patch.why, + howToApply: patch.howToApply, + }, + ]), + '', + ].join('\n'); +} + +function findExistingMemoryDocument( + docs: ScannedAutoMemoryDocument[], + patch: AutoMemoryExtractPatch, +): ScannedAutoMemoryDocument | undefined { + const targetSummary = patch.summary.toLowerCase(); + return docs.find((doc) => { + if (doc.type !== patch.topic) { + return false; + } + const [entry] = parseAutoMemoryEntries(doc.body); + return entry?.summary.toLowerCase() === targetSummary; + }); +} + +function allocateMemoryRelativePath( + docs: ScannedAutoMemoryDocument[], + patch: AutoMemoryExtractPatch, +): string { + const baseSlug = slugify(patch.summary); + const used = new Set(docs.map((doc) => doc.relativePath)); + + for (let index = 0; index < 100; index += 1) { + const filename = index === 0 ? `${baseSlug}.md` : `${baseSlug}-${index + 1}.md`; + const relativePath = path.join(patch.topic, filename); + if (!used.has(relativePath)) { + return relativePath; + } + } + + return path.join(patch.topic, `${baseSlug}-${Date.now()}.md`); } export async function applyExtractedMemoryPatches( @@ -388,16 +458,49 @@ export async function applyExtractedMemoryPatches( sessionId?: string, ): Promise { const touchedTopics = new Set(); + const docs = await scanAutoMemoryTopicDocuments(projectRoot); for (const patch of patches) { - const topicPath = getAutoMemoryTopicPath(projectRoot, patch.topic); - const current = await fs.readFile(topicPath, 'utf-8'); - const next = appendPatchToTopicContent(current, patch); - if (!next) { + const existingDoc = findExistingMemoryDocument(docs, patch); + + if (existingDoc) { + const current = await fs.readFile(existingDoc.filePath, 'utf-8'); + const next = appendPatchToTopicContent(current, patch); + if (!next) { + continue; + } + + await fs.writeFile(existingDoc.filePath, next, 'utf-8'); + const updatedDoc = parseAutoMemoryTopicDocument( + existingDoc.filePath, + next, + 0, + existingDoc.relativePath, + ); + if (updatedDoc) { + const existingIndex = docs.findIndex((doc) => doc.filePath === existingDoc.filePath); + if (existingIndex >= 0) { + docs[existingIndex] = updatedDoc; + } + } + touchedTopics.add(patch.topic); continue; } - await fs.writeFile(topicPath, next, 'utf-8'); + const relativePath = allocateMemoryRelativePath(docs, patch); + const absolutePath = getAutoMemoryFilePath(projectRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + const content = buildMemoryDocumentContent(patch); + await fs.writeFile(absolutePath, content, 'utf-8'); + const createdDoc = parseAutoMemoryTopicDocument( + absolutePath, + content, + 0, + relativePath, + ); + if (createdDoc) { + docs.push(createdDoc); + } touchedTopics.add(patch.topic); } diff --git a/packages/core/src/memory/extractAgent.test.ts b/packages/core/src/memory/extractAgent.test.ts index 819c8b1eed5..f1b4a935bbb 100644 --- a/packages/core/src/memory/extractAgent.test.ts +++ b/packages/core/src/memory/extractAgent.test.ts @@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; import { runAutoMemoryExtract } from './extract.js'; -import { getAutoMemoryTopicPath } from './paths.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { resetAutoMemoryStateForTests } from './state.js'; @@ -70,10 +70,9 @@ describe('auto-memory extraction with agent planner', () => { expect.any(Array), ); - const userTopic = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - 'utf-8', + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + expect(docs.find((doc) => doc.type === 'user')?.body).toContain( + 'User prefers terse responses.', ); - expect(userTopic).toContain('- User prefers terse responses.'); }); }); diff --git a/packages/core/src/memory/extractModel.test.ts b/packages/core/src/memory/extractModel.test.ts index af8160ad689..62a8cd5c38c 100644 --- a/packages/core/src/memory/extractModel.test.ts +++ b/packages/core/src/memory/extractModel.test.ts @@ -12,7 +12,7 @@ import type { Config } from '../config/config.js'; import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; import { runAutoMemoryExtract } from './extract.js'; -import { getAutoMemoryTopicPath } from './paths.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { resetAutoMemoryStateForTests } from './state.js'; @@ -78,11 +78,10 @@ describe('auto-memory extraction with model planner', () => { expect.any(Array), ); - const referenceTopic = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'reference'), - 'utf-8', + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + expect(docs.find((doc) => doc.type === 'reference')?.body).toContain( + 'Latency dashboard: https://grafana.internal/d/api-latency', ); - expect(referenceTopic).toContain('Latency dashboard: https://grafana.internal/d/api-latency'); }); it('falls back to heuristic extraction when the model planner fails', async () => { @@ -106,10 +105,9 @@ describe('auto-memory extraction with model planner', () => { }); expect(result.touchedTopics).toEqual(['user']); - const userTopic = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - 'utf-8', + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + expect(docs.find((doc) => doc.type === 'user')?.body).toContain( + 'I prefer terse responses.', ); - expect(userTopic).toContain('- I prefer terse responses.'); }); }); diff --git a/packages/core/src/memory/extractScheduler.test.ts b/packages/core/src/memory/extractScheduler.test.ts index f8c7847bfe7..cf92c2bdabe 100644 --- a/packages/core/src/memory/extractScheduler.test.ts +++ b/packages/core/src/memory/extractScheduler.test.ts @@ -9,7 +9,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createManagedAutoMemoryExtractRuntimeForTests } from './extractScheduler.js'; -import { getAutoMemoryTopicPath } from './paths.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { markExtractRunning, resetAutoMemoryStateForTests } from './state.js'; @@ -66,11 +66,10 @@ describe('managed auto-memory extraction runtime', () => { const drained = await runtime.drain({ timeoutMs: 1_000 }); expect(drained).toBe(true); - const referenceTopic = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'reference'), - 'utf-8', + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + expect(docs.find((doc) => doc.type === 'reference')?.body).toContain( + 'grafana.example/d/api', ); - expect(referenceTopic).toContain('grafana.example/d/api'); const tasks = runtime.listTasks(projectRoot); expect(tasks.some((task) => task.status === 'completed')).toBe(true); diff --git a/packages/core/src/memory/extractScheduler.ts b/packages/core/src/memory/extractScheduler.ts index 3f2c615d461..3d4a5bcc1e0 100644 --- a/packages/core/src/memory/extractScheduler.ts +++ b/packages/core/src/memory/extractScheduler.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Content } from '@google/genai'; +import type { Content, Part } from '@google/genai'; import type { Config } from '../config/config.js'; import { BackgroundTaskDrainer, @@ -14,7 +14,6 @@ import { BackgroundTaskRegistry, type BackgroundTaskState, } from '../background/taskRegistry.js'; -import { partToString } from '../utils/partUtils.js'; import { type AutoMemoryExtractResult, runAutoMemoryExtract, @@ -53,14 +52,17 @@ function buildSkippedExtractResult( }; } +function partIsSaveMemoryCall(part: Part): boolean { + return ( + (part.functionCall?.name === 'save_memory') || + (part.functionResponse?.name === 'save_memory') + ); +} + function historySliceUsesMemoryTool(history: Content[]): boolean { - return history.some((message) => { - const rendered = partToString(message.parts ?? [], { verbose: true }); - return ( - rendered.includes('[Function Response: save_memory]') || - rendered.includes('[Function Call: save_memory]') - ); - }); + return history.some((message) => + (message.parts ?? []).some(partIsSaveMemoryCall), + ); } export class ManagedAutoMemoryExtractRuntime { diff --git a/packages/core/src/memory/extractionAgentPlanner.test.ts b/packages/core/src/memory/extractionAgentPlanner.test.ts index 8e9b39f22fb..6a1e706b623 100644 --- a/packages/core/src/memory/extractionAgentPlanner.test.ts +++ b/packages/core/src/memory/extractionAgentPlanner.test.ts @@ -29,9 +29,12 @@ describe('planAutoMemoryExtractionPatchesByAgent', () => { { type: 'user', filePath: '/tmp/user.md', + relativePath: 'user.md', + filename: 'user.md', title: 'User Memory', description: 'User preferences', body: '- Existing terse preference.', + mtimeMs: 1, }, ]); }); diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index a4687660071..5c3a141cf5b 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -11,6 +11,11 @@ import { } from '../background/backgroundAgentRunner.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { safeJsonParse } from '../utils/safeJsonParse.js'; +import { + MEMORY_FRONTMATTER_EXAMPLE, + TYPES_SECTION_INDIVIDUAL, + WHAT_NOT_TO_SAVE_SECTION, +} from './prompt.js'; import type { AutoMemoryType } from './types.js'; import type { AutoMemoryExtractPatch, @@ -20,19 +25,31 @@ import { scanAutoMemoryTopicDocuments } from './scan.js'; const MAX_TOPIC_SUMMARY_CHARS = 280; -const EXTRACTION_AGENT_SYSTEM_PROMPT = `You are a background memory extraction agent for an AI coding assistant. - -Your job is to read the provided transcript slice and current managed memory topic summaries, then return only durable memory patches worth saving long-term. - -Rules: -- Output JSON only. -- Follow the schema exactly. -- Extract only durable facts stated by the user. -- Ignore temporary, session-specific, speculative, or question content. -- Use one of the allowed topics: user, feedback, project, reference. -- Keep summaries concise and suitable for bullet points. -- Do not include leading bullet markers. -- You may use read-only tools to inspect topic files when the summaries seem insufficient.`; +const EXTRACTION_AGENT_SYSTEM_PROMPT = [ + 'You are now acting as the managed memory extraction subagent for an AI coding assistant.', + '', + 'Analyze the provided recent transcript slice and use it to update durable managed memory.', + '', + 'You will be given current managed memory topic summaries. Improve existing memory rather than creating duplicate facts.', + '', + 'Rules:', + '- Output JSON only.', + '- Follow the schema exactly.', + '- Extract only durable facts stated by the user.', + '- Ignore temporary, session-specific, speculative, or question content.', + '- If the user explicitly asks the assistant to remember something durable, preserve it.', + '- Use one of the allowed topics: user, feedback, project, reference.', + '- Keep summaries concise and suitable for bullet points.', + '- Do not include leading bullet markers.', + '- You may use read-only tools to inspect topic files when the provided summaries seem insufficient.', + '- Do not investigate the repository or verify the memory against unrelated code. Work only from the provided transcript slice and managed memory context.', + '', + ...TYPES_SECTION_INDIVIDUAL, + ...WHAT_NOT_TO_SAVE_SECTION, + '', + 'Memory file format reference:', + ...MEMORY_FRONTMATTER_EXAMPLE, +].join('\n'); const EXTRACTION_AGENT_RESPONSE_SCHEMA: Record = { type: 'object', @@ -55,10 +72,6 @@ const EXTRACTION_AGENT_RESPONSE_SCHEMA: Record = { howToApply: { type: 'string', }, - stability: { - type: 'string', - enum: ['stable', 'working'], - }, sourceOffset: { type: 'integer', }, @@ -158,7 +171,6 @@ function validateExtractionAgentResponse( summary: patch.summary.trim(), why: patch.why?.trim(), howToApply: patch.howToApply?.trim(), - stability: patch.stability, sourceOffset: patch.sourceOffset, })); } diff --git a/packages/core/src/memory/extractionPlanner.test.ts b/packages/core/src/memory/extractionPlanner.test.ts index 8dcc8383655..56f10cc4ac5 100644 --- a/packages/core/src/memory/extractionPlanner.test.ts +++ b/packages/core/src/memory/extractionPlanner.test.ts @@ -40,9 +40,12 @@ describe('planAutoMemoryExtractionPatchesByModel', () => { { type: 'user', filePath: '/tmp/user.md', + relativePath: 'user.md', + filename: 'user.md', title: 'User Memory', description: 'User preferences', body: '- Existing terse preference.', + mtimeMs: 1, }, ]); }); @@ -77,7 +80,9 @@ describe('planAutoMemoryExtractionPatchesByModel', () => { mockConfig, expect.objectContaining({ purpose: 'auto-memory-extract', - systemInstruction: expect.stringContaining('You maintain durable managed memory'), + systemInstruction: expect.stringContaining( + 'You are acting as the managed memory extraction planner', + ), }), ); }); diff --git a/packages/core/src/memory/extractionPlanner.ts b/packages/core/src/memory/extractionPlanner.ts index 1753156fa7f..90ecfe24f85 100644 --- a/packages/core/src/memory/extractionPlanner.ts +++ b/packages/core/src/memory/extractionPlanner.ts @@ -16,9 +16,11 @@ import type { const MAX_TOPIC_SUMMARY_CHARS = 280; -const SYSTEM_PROMPT = `You maintain durable managed memory for an AI coding assistant. +const SYSTEM_PROMPT = `You are acting as the managed memory extraction planner for an AI coding assistant. -Review the transcript slice and current topic summaries, then extract only durable memory worth keeping beyond the current task. +Analyze only the provided recent transcript slice and the existing managed memory topic summaries, then return durable memory patches worth keeping beyond the current task. + +Save only information that is likely to matter in future sessions. Allowed topics: - user: stable user preferences, habits, background, recurring requirements @@ -26,14 +28,19 @@ Allowed topics: - project: stable project constraints, environments, releases, architecture facts - reference: durable links, dashboards, tickets, docs, runbooks, identifiers +Extract only durable facts stated by the user. + Do not extract: - temporary task steps -- one-off requests for this turn only +- session-only instructions - speculative conclusions - questions - assistant-only plans not stated by the user +- content that only makes sense relative to “today”, “this task”, or “right now” + +If the user explicitly asks the assistant to remember something durable, prefer to keep it. -Return concise summaries suitable for bullet points. Do not include leading bullet markers.`; +Return concise summaries suitable for bullet points. Do not include leading bullet markers. Output must match the provided JSON schema exactly.`; const RESPONSE_SCHEMA: Record = { type: 'object', @@ -56,10 +63,6 @@ const RESPONSE_SCHEMA: Record = { howToApply: { type: 'string', }, - stability: { - type: 'string', - enum: ['stable', 'working'], - }, sourceOffset: { type: 'integer', }, @@ -172,7 +175,6 @@ export async function planAutoMemoryExtractionPatchesByModel( summary: patch.summary, why: patch.why, howToApply: patch.howToApply, - stability: patch.stability, sourceOffset: patch.sourceOffset, })); } diff --git a/packages/core/src/memory/forget.test.ts b/packages/core/src/memory/forget.test.ts deleted file mode 100644 index 7c4eccaa1f1..00000000000 --- a/packages/core/src/memory/forget.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - forgetManagedAutoMemoryEntries, - forgetManagedAutoMemoryMatches, - findManagedAutoMemoryForgetCandidates, - selectManagedAutoMemoryForgetCandidates, -} from './forget.js'; -import { getAutoMemoryIndexPath, getAutoMemoryTopicPath } from './paths.js'; -import { ensureAutoMemoryScaffold } from './store.js'; - -describe('managed auto-memory forget', () => { - let tempDir: string; - let projectRoot: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-forget-')); - projectRoot = path.join(tempDir, 'project'); - await fs.mkdir(projectRoot, { recursive: true }); - await ensureAutoMemoryScaffold(projectRoot); - }); - - afterEach(async () => { - await fs.rm(tempDir, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 10, - }); - }); - - it('finds matching forget candidates across topics', async () => { - await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - [ - '---', - 'type: user', - 'title: User Memory', - 'description: User profile', - '---', - '', - '# User Memory', - '', - '- User prefers terse responses.', - ' - How to apply: Keep the first paragraph short.', - ].join('\n'), - 'utf-8', - ); - - const matches = await findManagedAutoMemoryForgetCandidates(projectRoot, 'first paragraph short'); - expect(matches).toEqual([ - { - topic: 'user', - summary: 'User prefers terse responses.', - }, - ]); - }); - - it('removes matching topic entries and rewrites the index', async () => { - await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - [ - '---', - 'type: user', - 'title: User Memory', - 'description: User profile', - '---', - '', - '# User Memory', - '', - '- User prefers terse responses.', - '- User likes dark mode.', - ].join('\n'), - 'utf-8', - ); - - const result = await forgetManagedAutoMemoryEntries(projectRoot, 'terse'); - const topicContent = await fs.readFile(getAutoMemoryTopicPath(projectRoot, 'user'), 'utf-8'); - const indexContent = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); - - expect(result.removedEntries).toEqual([ - { - topic: 'user', - summary: 'User prefers terse responses.', - }, - ]); - expect(topicContent).not.toContain('terse responses'); - expect(topicContent).toContain('User likes dark mode.'); - expect(indexContent).not.toContain('terse responses'); - expect(indexContent).toContain('User likes dark mode.'); - }); - - it('restores the empty placeholder when all matching entries are removed', async () => { - await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'feedback'), - [ - '---', - 'type: feedback', - 'title: Feedback Memory', - 'description: Guidance', - '---', - '', - '# Feedback Memory', - '', - '- Always answer tersely.', - ].join('\n'), - 'utf-8', - ); - - await forgetManagedAutoMemoryEntries(projectRoot, 'tersely'); - const content = await fs.readFile(getAutoMemoryTopicPath(projectRoot, 'feedback'), 'utf-8'); - - expect(content).toContain('_No entries yet._'); - }); - - it('supports explicit candidate deletion after preview selection', async () => { - await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - [ - '---', - 'type: user', - 'title: User Memory', - 'description: User profile', - '---', - '', - '# User Memory', - '', - '- User prefers terse responses.', - '- User likes dark mode.', - ].join('\n'), - 'utf-8', - ); - - const selection = await selectManagedAutoMemoryForgetCandidates( - projectRoot, - 'dark mode', - ); - const result = await forgetManagedAutoMemoryMatches( - projectRoot, - selection.matches, - ); - const content = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - 'utf-8', - ); - - expect(selection.matches).toEqual([ - { - topic: 'user', - summary: 'User likes dark mode.', - }, - ]); - expect(result.removedEntries).toEqual(selection.matches); - expect(content).not.toContain('dark mode'); - expect(content).toContain('terse responses'); - }); -}); diff --git a/packages/core/src/memory/forget.ts b/packages/core/src/memory/forget.ts index b8ab026ab37..28fe048c4bd 100644 --- a/packages/core/src/memory/forget.ts +++ b/packages/core/src/memory/forget.ts @@ -10,21 +10,18 @@ import type { Config } from '../config/config.js'; import { runSideQuery } from '../auxiliary/sideQuery.js'; import { buildAutoMemoryEntrySearchText, - getAutoMemoryBodyHeading, parseAutoMemoryEntries, - renderAutoMemoryBody, - type ManagedAutoMemoryEntryStability, } from './entries.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; -import { getAutoMemoryMetadataPath, getAutoMemoryTopicPath } from './paths.js'; -import { parseAutoMemoryTopicDocument } from './scan.js'; +import { getAutoMemoryMetadataPath } from './paths.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import type { AutoMemoryMetadata, AutoMemoryType } from './types.js'; -import { AUTO_MEMORY_TYPES } from './types.js'; export interface AutoMemoryForgetMatch { topic: AutoMemoryType; summary: string; + filePath: string; } export interface AutoMemoryForgetResult { @@ -44,7 +41,6 @@ interface IndexedForgetCandidate extends AutoMemoryForgetMatch { id: string; why?: string; howToApply?: string; - stability?: ManagedAutoMemoryEntryStability; } const FORGET_SELECTION_RESPONSE_SCHEMA: Record = { @@ -69,32 +65,24 @@ interface ForgetSelectionResponse { async function listIndexedForgetCandidates( projectRoot: string, ): Promise { - const matches: IndexedForgetCandidate[] = []; - for (const topic of AUTO_MEMORY_TYPES) { - const topicPath = getAutoMemoryTopicPath(projectRoot, topic); - try { - const current = await fs.readFile(topicPath, 'utf-8'); - const parsed = parseAutoMemoryTopicDocument(topicPath, current); - if (!parsed) { - continue; - } - - for (const entry of parseAutoMemoryEntries(parsed.body)) { - matches.push({ - id: `${topic}:${entry.summary}`, - topic, - summary: entry.summary, - why: entry.why, - howToApply: entry.howToApply, - stability: entry.stability, - }); - } - } catch { - // Ignore missing or invalid topic files. + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + const candidates: IndexedForgetCandidate[] = []; + + for (const doc of docs) { + const entries = parseAutoMemoryEntries(doc.body); + for (const entry of entries) { + candidates.push({ + id: doc.relativePath, + topic: doc.type, + summary: entry.summary, + filePath: doc.filePath, + why: entry.why, + howToApply: entry.howToApply, + }); } } - return matches; + return candidates; } function buildForgetSelectionPrompt( @@ -119,77 +107,76 @@ function buildForgetSelectionPrompt( `summary: ${candidate.summary}`, `why: ${candidate.why ?? '(none)'}`, `howToApply: ${candidate.howToApply ?? '(none)'}`, - `stability: ${candidate.stability ?? '(none)'}`, ].join('\n'), ), ].join('\n'); } -function buildUpdatedBodyForMatches( - body: string, - summariesToRemove: Set, -): { body: string; removedEntries: string[] } { - const entries = parseAutoMemoryEntries(body); - const removedEntries: string[] = []; - const nextEntries = entries.filter((entry) => { - if (summariesToRemove.has(entry.summary.toLowerCase())) { - removedEntries.push(entry.summary); - return false; - } - return true; +async function selectByModel( + candidates: IndexedForgetCandidate[], + query: string, + config: Config, + limit: number, +): Promise { + const response = await runSideQuery(config, { + purpose: 'auto-memory-forget-selection', + contents: [ + { + role: 'user', + parts: [ + { + text: buildForgetSelectionPrompt(query, candidates, limit), + }, + ], + }, + ] as Content[], + schema: FORGET_SELECTION_RESPONSE_SCHEMA, + abortSignal: AbortSignal.timeout(8_000), + config: { + temperature: 0, + }, + validate: (value) => { + const candidateIds = new Set(candidates.map((c) => c.id)); + for (const id of value.selectedCandidateIds) { + if (!candidateIds.has(id)) { + return `Unknown candidate id: ${id}`; + } + } + return null; + }, }); + const selectedIds = new Set(response.selectedCandidateIds); + const matches = candidates + .filter((candidate) => selectedIds.has(candidate.id)) + .slice(0, limit) + .map(({ topic, summary, filePath }) => ({ topic, summary, filePath })); + return { - body: renderAutoMemoryBody(getAutoMemoryBodyHeading(body), nextEntries), - removedEntries, + matches, + strategy: matches.length > 0 ? 'model' : 'none', + reasoning: response.reasoning, }; } -async function bumpMetadata(projectRoot: string, now: Date): Promise { - try { - const content = await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'); - const metadata = JSON.parse(content) as AutoMemoryMetadata; - metadata.updatedAt = now.toISOString(); - await fs.writeFile( - getAutoMemoryMetadataPath(projectRoot), - `${JSON.stringify(metadata, null, 2)}\n`, - 'utf-8', - ); - } catch { - // Best-effort metadata update. - } -} - -export async function findManagedAutoMemoryForgetCandidates( - projectRoot: string, +function selectByHeuristic( + candidates: IndexedForgetCandidate[], query: string, -): Promise { - const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) { - return []; - } - - const matches: AutoMemoryForgetMatch[] = []; - for (const topic of AUTO_MEMORY_TYPES) { - const topicPath = getAutoMemoryTopicPath(projectRoot, topic); - try { - const current = await fs.readFile(topicPath, 'utf-8'); - const parsed = parseAutoMemoryTopicDocument(topicPath, current); - if (!parsed) { - continue; - } - - for (const entry of parseAutoMemoryEntries(parsed.body)) { - if (buildAutoMemoryEntrySearchText(entry).includes(normalizedQuery)) { - matches.push({ topic, summary: entry.summary }); - } - } - } catch { - // Ignore missing or invalid topic files. - } - } + limit: number, +): AutoMemoryForgetSelectionResult { + const normalizedQuery = query.replace(/\s+/g, ' ').trim(); + const queryLower = normalizedQuery.toLowerCase(); + const matches = candidates + .filter((candidate) => + buildAutoMemoryEntrySearchText(candidate).includes(queryLower), + ) + .slice(0, limit) + .map(({ topic, summary, filePath }) => ({ topic, summary, filePath })); - return matches; + return { + matches, + strategy: matches.length > 0 ? 'heuristic' : 'none', + }; } export async function selectManagedAutoMemoryForgetCandidates( @@ -200,74 +187,39 @@ export async function selectManagedAutoMemoryForgetCandidates( limit?: number; } = {}, ): Promise { - const normalizedQuery = query.trim(); - if (!normalizedQuery) { - return { matches: [], strategy: 'none' }; - } - + const limit = options.limit ?? 5; const candidates = await listIndexedForgetCandidates(projectRoot); if (candidates.length === 0) { return { matches: [], strategy: 'none' }; } - const limit = Math.max(1, Math.min(options.limit ?? 10, candidates.length)); if (options.config) { try { - const candidateIds = new Set(candidates.map((candidate) => candidate.id)); - const contents: Content[] = [ - { - role: 'user', - parts: [ - { - text: buildForgetSelectionPrompt(normalizedQuery, candidates, limit), - }, - ], - }, - ]; - const response = await runSideQuery(options.config, { - purpose: 'auto-memory-forget-select', - contents, - schema: FORGET_SELECTION_RESPONSE_SCHEMA, - abortSignal: AbortSignal.timeout(7_500), - config: { - temperature: 0, - }, - validate: (value) => { - if (value.selectedCandidateIds.length > limit) { - return 'Forget selector returned too many candidates'; - } - if (value.selectedCandidateIds.some((id) => !candidateIds.has(id))) { - return 'Forget selector returned an unknown candidate id'; - } - return null; - }, - }); - - const selectedIds = new Set(response.selectedCandidateIds); - return { - matches: candidates - .filter((candidate) => selectedIds.has(candidate.id)) - .map(({ topic, summary }) => ({ topic, summary })), - strategy: selectedIds.size > 0 ? 'model' : 'none', - reasoning: response.reasoning, - }; + return await selectByModel(candidates, query, options.config, limit); } catch { - // Fall back to heuristic matching. + // Fall through to heuristic. } } - const queryLower = normalizedQuery.toLowerCase(); - const matches = candidates - .filter((candidate) => - buildAutoMemoryEntrySearchText(candidate).includes(queryLower), - ) - .slice(0, limit) - .map(({ topic, summary }) => ({ topic, summary })); + return selectByHeuristic(candidates, query, limit); +} - return { - matches, - strategy: matches.length > 0 ? 'heuristic' : 'none', - }; +async function bumpMetadata(projectRoot: string, now: Date): Promise { + try { + const content = await fs.readFile( + getAutoMemoryMetadataPath(projectRoot), + 'utf-8', + ); + const metadata = JSON.parse(content) as AutoMemoryMetadata; + metadata.updatedAt = now.toISOString(); + await fs.writeFile( + getAutoMemoryMetadataPath(projectRoot), + `${JSON.stringify(metadata, null, 2)}\n`, + 'utf-8', + ); + } catch { + // Best-effort metadata bump. + } } export async function forgetManagedAutoMemoryMatches( @@ -277,39 +229,17 @@ export async function forgetManagedAutoMemoryMatches( ): Promise { await ensureAutoMemoryScaffold(projectRoot, now); - const removalsByTopic = new Map>(); - for (const match of matches) { - const existing = removalsByTopic.get(match.topic) ?? new Set(); - existing.add(match.summary.toLowerCase()); - removalsByTopic.set(match.topic, existing); - } - const removedEntries: AutoMemoryForgetMatch[] = []; const touchedTopics = new Set(); - for (const topic of AUTO_MEMORY_TYPES) { - const summariesToRemove = removalsByTopic.get(topic); - if (!summariesToRemove || summariesToRemove.size === 0) { - continue; - } - - const topicPath = getAutoMemoryTopicPath(projectRoot, topic); - const current = await fs.readFile(topicPath, 'utf-8'); - const parsed = parseAutoMemoryTopicDocument(topicPath, current); - if (!parsed) { - continue; - } - - const updated = buildUpdatedBodyForMatches(parsed.body, summariesToRemove); - if (updated.removedEntries.length === 0 || updated.body === parsed.body.trim()) { - continue; - } - - for (const summary of updated.removedEntries) { - removedEntries.push({ topic, summary }); + for (const match of matches) { + try { + await fs.unlink(match.filePath); + removedEntries.push(match); + touchedTopics.add(match.topic); + } catch { + // File may have already been removed; continue. } - await fs.writeFile(topicPath, current.replace(parsed.body, updated.body), 'utf-8'); - touchedTopics.add(topic); } if (touchedTopics.size > 0) { @@ -323,7 +253,7 @@ export async function forgetManagedAutoMemoryMatches( touchedTopics: [...touchedTopics], systemMessage: removedEntries.length > 0 - ? `Managed auto-memory forgot ${removedEntries.length} entr${removedEntries.length === 1 ? 'y' : 'ies'} from ${[...touchedTopics].map((topic) => `${topic}.md`).join(', ')}` + ? `Managed auto-memory forgot ${removedEntries.length} entr${removedEntries.length === 1 ? 'y' : 'ies'} from: ${[...touchedTopics].map((topic) => `${topic}/`).join(', ')}` : undefined, }; } @@ -331,27 +261,23 @@ export async function forgetManagedAutoMemoryMatches( export async function forgetManagedAutoMemoryEntries( projectRoot: string, query: string, + options: { config?: Config } = {}, now = new Date(), ): Promise { const trimmedQuery = query.trim(); if (!trimmedQuery) { - return { - query: trimmedQuery, - removedEntries: [], - touchedTopics: [], - }; + return { query: trimmedQuery, removedEntries: [], touchedTopics: [] }; } - const selection = await selectManagedAutoMemoryForgetCandidates(projectRoot, trimmedQuery, { - limit: Number.MAX_SAFE_INTEGER, - }); + const selection = await selectManagedAutoMemoryForgetCandidates( + projectRoot, + trimmedQuery, + { ...options, limit: Number.MAX_SAFE_INTEGER }, + ); const result = await forgetManagedAutoMemoryMatches( projectRoot, selection.matches, now, ); - return { - ...result, - query: trimmedQuery, - }; + return { ...result, query: trimmedQuery }; } diff --git a/packages/core/src/memory/governance.test.ts b/packages/core/src/memory/governance.test.ts deleted file mode 100644 index cce81077409..00000000000 --- a/packages/core/src/memory/governance.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { reviewManagedAutoMemoryGovernance } from './governance.js'; -import { getAutoMemoryTopicPath } from './paths.js'; -import { ensureAutoMemoryScaffold } from './store.js'; - -describe('managed auto-memory governance review', () => { - let tempDir: string; - let projectRoot: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-governance-')); - projectRoot = path.join(tempDir, 'project'); - await fs.mkdir(projectRoot, { recursive: true }); - await ensureAutoMemoryScaffold(projectRoot); - }); - - afterEach(async () => { - await fs.rm(tempDir, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 10, - }); - }); - - it('produces heuristic governance suggestions', async () => { - await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'project'), - [ - '---', - 'type: project', - 'title: Project Memory', - 'description: Project facts', - '---', - '', - '# Project Memory', - '', - '- Dashboard: https://grafana.example/d/api', - '- Dashboard: https://grafana.example/d/api', - '- This is only temporary for this task.', - ].join('\n'), - 'utf-8', - ); - - const review = await reviewManagedAutoMemoryGovernance(projectRoot); - - expect(review.strategy).toBe('heuristic'); - expect(review.suggestions.some((item) => item.type === 'duplicate')).toBe(true); - expect(review.suggestions.some((item) => item.type === 'migrate')).toBe(true); - expect(review.suggestions.some((item) => item.type === 'forget')).toBe(true); - }); -}); \ No newline at end of file diff --git a/packages/core/src/memory/governance.ts b/packages/core/src/memory/governance.ts index 3e7e5c1baa0..b8db1228a8e 100644 --- a/packages/core/src/memory/governance.ts +++ b/packages/core/src/memory/governance.ts @@ -8,11 +8,8 @@ import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; import { runSideQuery } from '../auxiliary/sideQuery.js'; import { parseAutoMemoryEntries } from './entries.js'; -import { getAutoMemoryTopicPath } from './paths.js'; -import { parseAutoMemoryTopicDocument } from './scan.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; import type { AutoMemoryType } from './types.js'; -import { AUTO_MEMORY_TYPES } from './types.js'; -import * as fs from 'node:fs/promises'; export type AutoMemoryGovernanceSuggestionType = | 'duplicate' @@ -38,12 +35,13 @@ export interface AutoMemoryGovernanceReview { } interface IndexedGovernanceEntry { + /** Relative path of the file (used as stable ID). */ id: string; + filePath: string; topic: AutoMemoryType; summary: string; why?: string; howToApply?: string; - stability?: 'stable' | 'working'; } const RESPONSE_SCHEMA: Record = { @@ -56,7 +54,14 @@ const RESPONSE_SCHEMA: Record = { properties: { type: { type: 'string', - enum: ['duplicate', 'conflict', 'outdated', 'promote', 'migrate', 'forget'], + enum: [ + 'duplicate', + 'conflict', + 'outdated', + 'promote', + 'migrate', + 'forget', + ], }, entryId: { type: 'string' }, relatedEntryId: { type: 'string' }, @@ -86,44 +91,53 @@ interface GovernanceResponse { async function listGovernanceEntries( projectRoot: string, ): Promise { + const docs = await scanAutoMemoryTopicDocuments(projectRoot); const entries: IndexedGovernanceEntry[] = []; - for (const topic of AUTO_MEMORY_TYPES) { - const topicPath = getAutoMemoryTopicPath(projectRoot, topic); - try { - const content = await fs.readFile(topicPath, 'utf-8'); - const parsed = parseAutoMemoryTopicDocument(topicPath, content); - if (!parsed) { - continue; - } - for (const entry of parseAutoMemoryEntries(parsed.body)) { - entries.push({ - id: `${topic}:${entry.summary}`, - topic, - summary: entry.summary, - why: entry.why, - howToApply: entry.howToApply, - stability: entry.stability, - }); - } - } catch { - // Ignore missing or invalid topic files. + for (const doc of docs) { + const docEntries = parseAutoMemoryEntries(doc.body); + for (const entry of docEntries) { + entries.push({ + id: doc.relativePath, + filePath: doc.filePath, + topic: doc.type, + summary: entry.summary, + why: entry.why, + howToApply: entry.howToApply, + }); } } + return entries; } function classifyExpectedTopic(summary: string): AutoMemoryType | null { - if (/https?:\/\/|\b(grafana|dashboard|runbook|ticket|docs?|wiki|notion|jira)\b/i.test(summary)) { + if ( + /https?:\/\/|\b(grafana|dashboard|runbook|ticket|docs?|wiki|notion|jira)\b/i.test( + summary, + ) + ) { return 'reference'; } - if (/\b(i|we)\s+(prefer|like|need|want)\b|\bmy\s+(preferred|favorite)\b/i.test(summary)) { + if ( + /\b(i|we)\s+(prefer|like|need|want)\b|\bmy\s+(preferred|favorite)\b/i.test( + summary, + ) + ) { return 'user'; } - if (/\b(please|always|never|avoid|respond|format|style|terse|concise|detailed)\b/i.test(summary)) { + if ( + /\b(please|always|never|avoid|respond|format|style|terse|concise|detailed)\b/i.test( + summary, + ) + ) { return 'feedback'; } - if (/\b(project|repo|repository|service|release|deadline|freeze|incident|environment|stack)\b/i.test(summary)) { + if ( + /\b(project|repo|repository|service|release|deadline|freeze|incident|environment|stack)\b/i.test( + summary, + ) + ) { return 'project'; } return null; @@ -144,7 +158,7 @@ function buildModelPrompt(entries: IndexedGovernanceEntry[]): string { return [ 'Review managed auto-memory entries and emit governance suggestions.', 'Only suggest duplicate, conflict, outdated, promote, migrate, or forget when the case is strong.', - 'Prefer promote suggestions for entries that are durable but still missing why/howToApply/stability context.', + 'Prefer promote suggestions for entries that are durable but still missing why/howToApply context.', '', 'Entries:', ...entries.map((entry, index) => @@ -155,9 +169,10 @@ function buildModelPrompt(entries: IndexedGovernanceEntry[]): string { `summary: ${entry.summary}`, `why: ${entry.why ?? '(none)'}`, `howToApply: ${entry.howToApply ?? '(none)'}`, - `stability: ${entry.stability ?? '(none)'}`, ].join('\n'), ), + '', + 'Return JSON matching the response schema.', ].join('\n'); } @@ -165,11 +180,12 @@ function buildHeuristicSuggestions( entries: IndexedGovernanceEntry[], ): AutoMemoryGovernanceSuggestion[] { const suggestions: AutoMemoryGovernanceSuggestion[] = []; - const seenBySummary = new Map(); + // Duplicate detection: same summary (case-insensitive) in same topic + const summaryByTopic = new Map(); for (const entry of entries) { - const key = entry.summary.toLowerCase(); - const existing = seenBySummary.get(key); + const key = `${entry.topic}:${entry.summary.toLowerCase()}`; + const existing = summaryByTopic.get(key); if (existing) { suggestions.push({ type: 'duplicate', @@ -177,14 +193,15 @@ function buildHeuristicSuggestions( summary: entry.summary, relatedTopic: existing.topic, relatedSummary: existing.summary, - rationale: 'This entry duplicates an existing durable memory summary.', + rationale: 'Two entries share the same summary text.', }); - continue; + } else { + summaryByTopic.set(key, entry); } - seenBySummary.set(key, entry); } for (const entry of entries) { + // Migration suggestion: entry may belong in a different topic const expectedTopic = classifyExpectedTopic(entry.summary); if (expectedTopic && expectedTopic !== entry.topic) { suggestions.push({ @@ -192,42 +209,53 @@ function buildHeuristicSuggestions( topic: entry.topic, summary: entry.summary, suggestedTargetTopic: expectedTopic, - rationale: 'The summary appears to belong in a different managed memory topic.', + rationale: `Entry heuristically belongs in '${expectedTopic}' rather than '${entry.topic}'.`, }); } - if (/\b(temporary|for this task|this session|currently)\b/i.test(entry.summary)) { + // Outdated markers + if ( + /\b(today|now|currently|for this task|this session|temporary|temporarily)\b/i.test( + entry.summary, + ) + ) { suggestions.push({ - type: 'forget', + type: 'outdated', topic: entry.topic, summary: entry.summary, rationale: 'The entry appears temporary rather than durable.', }); } - if (/\b(deprecated|obsolete|sunset|legacy|old)\b/i.test(entry.summary)) { + if ( + /\b(deprecated|obsolete|sunset|legacy|old)\b/i.test(entry.summary) + ) { suggestions.push({ type: 'outdated', topic: entry.topic, summary: entry.summary, - rationale: 'The entry contains wording that suggests it may be outdated.', + rationale: + 'The entry contains wording that suggests it may be outdated.', }); } - if (!entry.why || !entry.howToApply || !entry.stability) { + // Promote: durable entry missing why/howToApply metadata + if (!entry.why || !entry.howToApply) { suggestions.push({ type: 'promote', topic: entry.topic, summary: entry.summary, - rationale: 'This durable entry could be upgraded with why/howToApply/stability metadata.', + rationale: + 'This durable entry could be upgraded with why/howToApply metadata.', }); } } - for (let index = 0; index < entries.length; index += 1) { - for (let inner = index + 1; inner < entries.length; inner += 1) { - const left = entries[index]; - const right = entries[inner]; + // Conflict detection: entries in the same topic that contradict each other + for (let i = 0; i < entries.length; i += 1) { + for (let j = i + 1; j < entries.length; j += 1) { + const left = entries[i]; + const right = entries[j]; if (left.topic !== right.topic) { continue; } @@ -275,13 +303,18 @@ export async function reviewManagedAutoMemoryGovernance( temperature: 0, }, validate: (value) => { - if (value.suggestions.some((suggestion) => !entryById.has(suggestion.entryId))) { + if ( + value.suggestions.some( + (suggestion) => !entryById.has(suggestion.entryId), + ) + ) { return 'Governance reviewer returned an unknown entry id'; } if ( value.suggestions.some( (suggestion) => - suggestion.relatedEntryId && !entryById.has(suggestion.relatedEntryId), + suggestion.relatedEntryId && + !entryById.has(suggestion.relatedEntryId), ) ) { return 'Governance reviewer returned an unknown related entry id'; @@ -306,7 +339,8 @@ export async function reviewManagedAutoMemoryGovernance( suggestedTargetTopic: suggestion.suggestedTargetTopic, } satisfies AutoMemoryGovernanceSuggestion; }), - strategy: response.suggestions.length > 0 ? 'model' : 'none', + strategy: + response.suggestions.length > 0 ? 'model' : 'none', }; } catch { // Fall back to heuristics. @@ -318,4 +352,4 @@ export async function reviewManagedAutoMemoryGovernance( suggestions, strategy: suggestions.length > 0 ? 'heuristic' : 'none', }; -} \ No newline at end of file +} diff --git a/packages/core/src/memory/indexer.test.ts b/packages/core/src/memory/indexer.test.ts index fc432e2b03f..707b171cb26 100644 --- a/packages/core/src/memory/indexer.test.ts +++ b/packages/core/src/memory/indexer.test.ts @@ -8,9 +8,8 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getAutoMemoryIndexPath, getAutoMemoryTopicPath } from './paths.js'; +import { getAutoMemoryFilePath, getAutoMemoryIndexPath } from './paths.js'; import { - buildAutoMemoryTopicHooks, buildManagedAutoMemoryIndex, rebuildManagedAutoMemoryIndex, } from './indexer.js'; @@ -36,54 +35,41 @@ describe('managed auto-memory indexer', () => { }); }); - it('builds short hooks from unique topic bullets', () => { - expect( - buildAutoMemoryTopicHooks([ - '# User Memory', - '', - '- User prefers terse responses.', - '- User prefers terse responses.', - '- User likes dark mode.', - '- User uses pnpm.', - '- User writes tests first.', - ].join('\n')), - ).toEqual([ - 'User prefers terse responses.', - 'User likes dark mode.', - 'User uses pnpm.', - ]); - }); - - it('formats a compact managed index view', () => { + it('formats a compact file-based MEMORY.md index view', () => { const content = buildManagedAutoMemoryIndex([ { type: 'user', - filePath: 'user.md', + filePath: '/tmp/user/terse.md', + relativePath: 'user/terse.md', + filename: 'terse.md', title: 'User Memory', description: 'User profile', - body: '# User Memory\n\n- User prefers terse responses.', + body: 'User prefers terse responses.', + mtimeMs: 0, }, ]); - expect(content).toContain('Durable entries: 1'); - expect(content).toContain('[User Memory](user.md)'); - expect(content).toContain('User prefers terse responses.'); + expect(content).toBe( + '- [User Memory](user/terse.md) — User profile', + ); }); it('rewrites MEMORY.md from topic file contents', async () => { + const projectFile = getAutoMemoryFilePath( + projectRoot, + path.join('project', 'repo-workspaces.md'), + ); + await fs.mkdir(path.dirname(projectFile), { recursive: true }); await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'project'), + projectFile, [ '---', 'type: project', - 'title: Project Memory', - 'description: Project facts', + 'name: Project Memory', + 'description: The repo uses pnpm workspaces.', '---', '', - '# Project Memory', - '', - '- The repo uses pnpm workspaces.', - '- CI runs vitest and typecheck.', + 'The repo uses pnpm workspaces.', ].join('\n'), 'utf-8', ); @@ -91,8 +77,7 @@ describe('managed auto-memory indexer', () => { await rebuildManagedAutoMemoryIndex(projectRoot); const index = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); - expect(index).toContain('[Project Memory](project.md)'); + expect(index).toContain('[Project Memory](project/repo-workspaces.md)'); expect(index).toContain('The repo uses pnpm workspaces.'); - expect(index).toContain('CI runs vitest and typecheck.'); }); }); diff --git a/packages/core/src/memory/indexer.ts b/packages/core/src/memory/indexer.ts index 49e9661bdce..3b7cde10b9b 100644 --- a/packages/core/src/memory/indexer.ts +++ b/packages/core/src/memory/indexer.ts @@ -5,73 +5,47 @@ */ import * as fs from 'node:fs/promises'; -import { parseAutoMemoryEntries } from './entries.js'; import { getAutoMemoryIndexPath, getAutoMemoryMetadataPath } from './paths.js'; import { scanAutoMemoryTopicDocuments, type ScannedAutoMemoryDocument } from './scan.js'; import type { AutoMemoryMetadata } from './types.js'; -const MAX_TOPIC_HOOKS = 3; +const MAX_INDEX_LINE_CHARS = 150; +const MAX_INDEX_LINES = 200; +const MAX_INDEX_BYTES = 25_000; -function getBodyBulletLines(body: string): string[] { - return parseAutoMemoryEntries(body) - .map((entry) => entry.summary) - .filter((summary) => summary.length > 0); -} - -export function countAutoMemoryTopicEntries(body: string): number { - return getBodyBulletLines(body).length; -} - -export function buildAutoMemoryTopicHooks(body: string): string[] { - const hooks = getBodyBulletLines(body); - return Array.from( - new Map(hooks.map((hook) => [hook.toLowerCase(), hook])).values(), - ).slice(0, MAX_TOPIC_HOOKS); +function truncateIndexLine(text: string): string { + if (text.length <= MAX_INDEX_LINE_CHARS) { + return text; + } + return `${text.slice(0, MAX_INDEX_LINE_CHARS - 1).trimEnd()}…`; } export function buildManagedAutoMemoryIndex( docs: ScannedAutoMemoryDocument[], - metadata?: Pick, + _metadata?: Pick, ): string { - const totalEntries = docs.reduce( - (sum, doc) => sum + countAutoMemoryTopicEntries(doc.body), - 0, - ); + const raw = docs + .map((doc) => + truncateIndexLine( + `- [${doc.title}](${doc.relativePath}) — ${doc.description || doc.type}`, + ), + ) + .join('\n'); - const lines = [ - '# Managed Auto-Memory Index', - '', - 'This index is maintained by Qwen Code. It summarizes durable topic files and short hooks for recall and manual review.', - '', - `Topics: ${docs.length} | Durable entries: ${totalEntries}`, - ]; + const lines = raw.split('\n'); + const wasLineTruncated = lines.length > MAX_INDEX_LINES; + let truncated = wasLineTruncated ? lines.slice(0, MAX_INDEX_LINES).join('\n') : raw; - if (metadata?.updatedAt) { - lines.push(`Updated: ${metadata.updatedAt}`); - } - if (metadata?.lastDreamAt) { - lines.push(`Last dream: ${metadata.lastDreamAt}${metadata.lastDreamSessionId ? ` (session ${metadata.lastDreamSessionId})` : ''}`); + if (truncated.length > MAX_INDEX_BYTES) { + const cutAt = truncated.lastIndexOf('\n', MAX_INDEX_BYTES); + truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_INDEX_BYTES); } - lines.push('', '## Topics', ''); - - for (const doc of docs) { - const entryCount = countAutoMemoryTopicEntries(doc.body); - const hooks = buildAutoMemoryTopicHooks(doc.body); - lines.push( - `- [${doc.title}](${doc.type}.md) — ${doc.description} (${entryCount} durable ${entryCount === 1 ? 'entry' : 'entries'})`, - ); - if (hooks.length === 0) { - lines.push(' - Hook: empty'); - continue; - } - for (const hook of hooks) { - lines.push(` - ${hook}`); - } + if (!wasLineTruncated && truncated.length === raw.length) { + return truncated; } - lines.push(''); - return lines.join('\n'); + return `${truncated}\n\n> WARNING: MEMORY.md is too large; only part of it was written. Keep index entries concise and move detail into topic files.`; } async function readAutoMemoryMetadata( diff --git a/packages/core/src/memory/memoryAge.ts b/packages/core/src/memory/memoryAge.ts new file mode 100644 index 00000000000..8fc0a8a32a9 --- /dev/null +++ b/packages/core/src/memory/memoryAge.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Days elapsed since mtime. Floor-rounded — 0 for today, 1 for + * yesterday, 2+ for older. Negative inputs (future mtime, clock skew) + * clamp to 0. + */ +export function memoryAgeDays(mtimeMs: number): number { + return Math.max(0, Math.floor((Date.now() - mtimeMs) / 86_400_000)); +} + +/** + * Human-readable age string. Models are poor at date arithmetic — + * a raw ISO timestamp doesn't trigger staleness reasoning the way + * "47 days ago" does. + */ +export function memoryAge(mtimeMs: number): string { + const d = memoryAgeDays(mtimeMs); + if (d === 0) return 'today'; + if (d === 1) return 'yesterday'; + return `${d} days ago`; +} + +/** + * Plain-text staleness caveat for memories >1 day old. Returns '' + * for fresh (today/yesterday) memories — warning there is noise. + */ +export function memoryFreshnessText(mtimeMs: number): string { + const d = memoryAgeDays(mtimeMs); + if (d <= 1) return ''; + return ( + `This memory is ${d} days old. ` + + 'Memories are point-in-time observations, not live state — ' + + 'claims about code behavior or file:line citations may be outdated. ' + + 'Verify against current code before asserting as fact.' + ); +} + +/** + * Per-memory staleness note wrapped in tags. + * Returns '' for memories ≤ 1 day old. + */ +export function memoryFreshnessNote(mtimeMs: number): string { + const text = memoryFreshnessText(mtimeMs); + if (!text) return ''; + return `${text}\n`; +} diff --git a/packages/core/src/memory/memoryLifecycle.integration.test.ts b/packages/core/src/memory/memoryLifecycle.integration.test.ts index 0c7df4725dd..408b1409df5 100644 --- a/packages/core/src/memory/memoryLifecycle.integration.test.ts +++ b/packages/core/src/memory/memoryLifecycle.integration.test.ts @@ -15,15 +15,10 @@ import { scheduleManagedAutoMemoryExtract, } from './extractScheduler.js'; import { applyExtractedMemoryPatches } from './extract.js'; -import { - forgetManagedAutoMemoryMatches, - selectManagedAutoMemoryForgetCandidates, -} from './forget.js'; -import { reviewManagedAutoMemoryGovernance } from './governance.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; -import { getAutoMemoryIndexPath, getAutoMemoryTopicPath } from './paths.js'; +import { getAutoMemoryFilePath, getAutoMemoryIndexPath } from './paths.js'; import { resolveRelevantAutoMemoryPromptForQuery } from './recall.js'; -import { getManagedAutoMemoryStatus } from './status.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { resetAutoMemoryStateForTests } from './state.js'; @@ -49,7 +44,7 @@ describe('managed auto-memory lifecycle integration', () => { }); }); - it('supports a Claude-style durable memory lifecycle across extraction, recall, dream, governance, and forget', async () => { + it('supports a Claude-style durable memory lifecycle across extraction, recall, and dream', async () => { const firstExtraction = scheduleManagedAutoMemoryExtract({ projectRoot, sessionId: 'session-1', @@ -94,11 +89,26 @@ describe('managed auto-memory lifecycle integration', () => { }, ]); - const userPath = getAutoMemoryTopicPath(projectRoot, 'user'); - const duplicatedUserContent = `${( - await fs.readFile(userPath, 'utf-8') - ).trimEnd()}\n- I prefer terse responses.\n - Why: User repeatedly asks for concise replies.\n`; - await fs.writeFile(userPath, duplicatedUserContent, 'utf-8'); + const duplicateUserPath = getAutoMemoryFilePath( + projectRoot, + path.join('user', 'terse-duplicate.md'), + ); + await fs.mkdir(path.dirname(duplicateUserPath), { recursive: true }); + await fs.writeFile( + duplicateUserPath, + [ + '---', + 'type: user', + 'name: User Memory Duplicate', + 'description: Duplicate terse preference', + '---', + '', + 'I prefer terse responses.', + '', + 'Why: User repeatedly asks for concise replies.', + ].join('\n'), + 'utf-8', + ); await rebuildManagedAutoMemoryIndex(projectRoot); const dreamResult = await runManagedAutoMemoryDream( @@ -108,76 +118,28 @@ describe('managed auto-memory lifecycle integration', () => { expect(dreamResult.touchedTopics).toContain('user'); expect(dreamResult.dedupedEntries).toBeGreaterThan(0); - const userContent = await fs.readFile(userPath, 'utf-8'); - const projectContent = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'project'), - 'utf-8', - ); - const referenceContent = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'reference'), - 'utf-8', - ); const indexContent = await fs.readFile( getAutoMemoryIndexPath(projectRoot), 'utf-8', ); + const docs = await scanAutoMemoryTopicDocuments(projectRoot); + const userDoc = docs.find((doc) => doc.type === 'user'); + const projectDoc = docs.find((doc) => doc.type === 'project'); + const referenceDoc = docs.find((doc) => doc.type === 'reference'); - expect(userContent.match(/I prefer terse responses\./g)).toHaveLength(1); - expect(userContent).toContain(' - Why: User repeatedly asks for concise replies.'); - expect(referenceContent).toContain('grafana.example/d/api-latency'); - expect(projectContent).toContain('This is temporary for this task.'); - expect(indexContent).toContain('I prefer terse responses.'); + expect(userDoc?.body).toContain('I prefer terse responses.'); + expect(userDoc?.body).toContain('Why: User repeatedly asks for concise replies.'); + expect(referenceDoc?.body).toContain('grafana.example/d/api-latency'); + expect(projectDoc?.body).toContain('This is temporary for this task.'); + expect(indexContent).toContain('user/'); const recall = await resolveRelevantAutoMemoryPromptForQuery( projectRoot, 'Check the latency dashboard and use a terse answer.', ); expect(recall.strategy).toBe('heuristic'); - expect(recall.prompt).toContain('## Relevant Managed Auto-Memory'); - expect(recall.prompt).toContain('user.md'); - expect(recall.prompt).toContain('reference.md'); - - const review = await reviewManagedAutoMemoryGovernance(projectRoot); - const suggestionTypes = new Set(review.suggestions.map((item) => item.type)); - expect(review.strategy).toBe('heuristic'); - expect(suggestionTypes).toContain('duplicate'); - expect(suggestionTypes).toContain('migrate'); - expect(suggestionTypes).toContain('forget'); - expect(suggestionTypes).toContain('promote'); - - const forgetSelection = await selectManagedAutoMemoryForgetCandidates( - projectRoot, - 'temporary for this task', - ); - expect(forgetSelection.strategy).toBe('heuristic'); - expect(forgetSelection.matches).toEqual([ - { - topic: 'project', - summary: 'This is temporary for this task.', - }, - ]); - - const forgetResult = await forgetManagedAutoMemoryMatches( - projectRoot, - forgetSelection.matches, - new Date('2026-04-01T04:00:00.000Z'), - ); - const projectContentAfterForget = await fs.readFile( - getAutoMemoryTopicPath(projectRoot, 'project'), - 'utf-8', - ); - const indexAfterForget = await fs.readFile( - getAutoMemoryIndexPath(projectRoot), - 'utf-8', - ); - const status = await getManagedAutoMemoryStatus(projectRoot); - - expect(forgetResult.removedEntries).toEqual(forgetSelection.matches); - expect(projectContentAfterForget).not.toContain('temporary for this task'); - expect(indexAfterForget).not.toContain('temporary for this task'); - expect(status.extractionTasks.length).toBeGreaterThan(0); - expect(status.topics.find((topic) => topic.topic === 'user')).toEqual( - expect.objectContaining({ entryCount: 1 }), - ); + expect(recall.prompt).toContain('## Relevant memory'); + expect(recall.prompt).toContain('user/'); + expect(recall.prompt).toContain('reference/'); }); }); \ No newline at end of file diff --git a/packages/core/src/memory/paths.ts b/packages/core/src/memory/paths.ts index 64aa296055d..99a9ba90580 100644 --- a/packages/core/src/memory/paths.ts +++ b/packages/core/src/memory/paths.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; -import { QWEN_DIR } from '../utils/paths.js'; +import { QWEN_DIR, sanitizeCwd } from '../utils/paths.js'; import type { AutoMemoryType } from './types.js'; export const AUTO_MEMORY_DIRNAME = 'memory'; @@ -14,8 +16,100 @@ export const AUTO_MEMORY_METADATA_FILENAME = 'meta.json'; export const AUTO_MEMORY_EXTRACT_CURSOR_FILENAME = 'extract-cursor.json'; export const AUTO_MEMORY_CONSOLIDATION_LOCK_FILENAME = 'consolidation.lock'; +function findGitRoot(startPath: string): string | null { + let current = path.resolve(startPath); + + while (true) { + const gitPath = path.join(current, '.git'); + if (fs.existsSync(gitPath)) { + return current; + } + + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function findCanonicalGitRoot(startPath: string): string | null { + const gitRoot = findGitRoot(startPath); + if (!gitRoot) { + return null; + } + + try { + const gitContent = fs.readFileSync(path.join(gitRoot, '.git'), 'utf-8').trim(); + if (!gitContent.startsWith('gitdir:')) { + return gitRoot; + } + + const worktreeGitDir = path.resolve( + gitRoot, + gitContent.slice('gitdir:'.length).trim(), + ); + const commonDir = path.resolve( + worktreeGitDir, + fs.readFileSync(path.join(worktreeGitDir, 'commondir'), 'utf-8').trim(), + ); + + if ( + path.resolve(path.dirname(worktreeGitDir)) !== + path.join(commonDir, 'worktrees') + ) { + return gitRoot; + } + + const backlink = fs.realpathSync( + fs.readFileSync(path.join(worktreeGitDir, 'gitdir'), 'utf-8').trim(), + ); + if (backlink !== path.join(fs.realpathSync(gitRoot), '.git')) { + return gitRoot; + } + + if (path.basename(commonDir) !== '.git') { + return commonDir.normalize('NFC'); + } + return path.dirname(commonDir).normalize('NFC'); + } catch { + return gitRoot; + } +} + +/** + * Returns the base directory for all auto-memory storage. + * Defaults to `~/.qwen`; overridable via QWEN_CODE_MEMORY_BASE_DIR for tests. + */ +export function getMemoryBaseDir(): string { + if (process.env.QWEN_CODE_MEMORY_BASE_DIR) { + return process.env.QWEN_CODE_MEMORY_BASE_DIR; + } + return path.join(os.homedir(), QWEN_DIR); +} + export function getAutoMemoryRoot(projectRoot: string): string { - return path.join(projectRoot, QWEN_DIR, AUTO_MEMORY_DIRNAME); + if (process.env.QWEN_CODE_MEMORY_LOCAL === '1') { + return path.join(projectRoot, QWEN_DIR, AUTO_MEMORY_DIRNAME); + } + + const canonicalRoot = findCanonicalGitRoot(projectRoot) ?? path.resolve(projectRoot); + return path.join( + getMemoryBaseDir(), + 'projects', + sanitizeCwd(canonicalRoot), + AUTO_MEMORY_DIRNAME, + ); +} + +/** + * Returns true if the given absolute path is inside the auto-memory root for + * the given project. The path is normalized to prevent path-traversal tricks. + */ +export function isAutoMemPath(absolutePath: string, projectRoot: string): boolean { + const normalizedPath = path.normalize(absolutePath); + const memRoot = path.normalize(getAutoMemoryRoot(projectRoot)); + return normalizedPath.startsWith(memRoot + path.sep) || normalizedPath === memRoot; } export function getAutoMemoryIndexPath(projectRoot: string): string { @@ -54,4 +148,11 @@ export function getAutoMemoryTopicPath( type: AutoMemoryType, ): string { return path.join(getAutoMemoryRoot(projectRoot), getAutoMemoryTopicFilename(type)); +} + +export function getAutoMemoryFilePath( + projectRoot: string, + relativePath: string, +): string { + return path.join(getAutoMemoryRoot(projectRoot), relativePath); } \ No newline at end of file diff --git a/packages/core/src/memory/prompt.test.ts b/packages/core/src/memory/prompt.test.ts index 0accd642a69..e984a35213b 100644 --- a/packages/core/src/memory/prompt.test.ts +++ b/packages/core/src/memory/prompt.test.ts @@ -8,50 +8,64 @@ import { describe, expect, it } from 'vitest'; import { appendManagedAutoMemoryToUserMemory, buildManagedAutoMemoryPrompt, - MANAGED_AUTO_MEMORY_HEADER, - MAX_MANAGED_AUTO_MEMORY_CHARS, + MAX_MANAGED_AUTO_MEMORY_INDEX_LINES, } from './prompt.js'; describe('managed auto-memory prompt helpers', () => { - it('returns empty string when no managed index content exists', () => { - expect(buildManagedAutoMemoryPrompt()).toBe(''); - expect(buildManagedAutoMemoryPrompt(' \n\n ')).toBe(''); + it('builds the Claude-style memory mechanics prompt even when MEMORY.md is empty', () => { + const prompt = buildManagedAutoMemoryPrompt('/tmp/project/.qwen/memory'); + + expect(prompt).toContain('# auto memory'); + expect(prompt).toContain('persistent, file-based memory system'); + expect(prompt).toContain('/tmp/project/.qwen/memory'); + expect(prompt).toContain('Your MEMORY.md is currently empty'); }); - it('builds a managed auto-memory prompt block from the index content', () => { - const prompt = buildManagedAutoMemoryPrompt('# Managed Auto-Memory Index'); + it('embeds the current MEMORY.md index content', () => { + const prompt = buildManagedAutoMemoryPrompt( + '/tmp/project/.qwen/memory', + '- [User Memory](user/terse.md) — User prefers terse responses.', + ); - expect(prompt).toContain(MANAGED_AUTO_MEMORY_HEADER); - expect(prompt).toContain('# Managed Auto-Memory Index'); - expect(prompt).toContain('durable project memory'); + expect(prompt).toContain('## MEMORY.md'); + expect(prompt).toContain('[User Memory](user/terse.md)'); + expect(prompt).toContain('User prefers terse responses.'); }); it('appends managed auto-memory after existing hierarchical memory', () => { const result = appendManagedAutoMemoryToUserMemory( '--- Context from: QWEN.md ---\nProject rules', - '# Managed Auto-Memory Index', + '/tmp/project/.qwen/memory', + '- [Project Memory](project/release-freeze.md) — Release freeze starts Friday.', ); expect(result).toContain('Project rules'); expect(result).toContain('\n\n---\n\n'); - expect(result).toContain(MANAGED_AUTO_MEMORY_HEADER); + expect(result).toContain('# auto memory'); }); it('returns only managed auto-memory when hierarchical memory is empty', () => { const result = appendManagedAutoMemoryToUserMemory( ' ', - '# Managed Auto-Memory Index', + '/tmp/project/.qwen/memory', + '- [Reference](reference/grafana.md) — Grafana dashboard link.', ); - expect(result).toContain(MANAGED_AUTO_MEMORY_HEADER); - expect(result.startsWith(MANAGED_AUTO_MEMORY_HEADER)).toBe(true); + expect(result).toContain('# auto memory'); + expect(result.startsWith('# auto memory')).toBe(true); }); it('truncates oversized managed auto-memory index content', () => { - const oversizedIndex = 'x'.repeat(MAX_MANAGED_AUTO_MEMORY_CHARS + 100); - const result = buildManagedAutoMemoryPrompt(oversizedIndex); + const oversizedIndex = Array.from( + { length: MAX_MANAGED_AUTO_MEMORY_INDEX_LINES + 50 }, + (_, index) => `- [Memory ${index}](memory-${index}.md) — hook ${index}`, + ).join('\n'); + const result = buildManagedAutoMemoryPrompt( + '/tmp/project/.qwen/memory', + oversizedIndex, + ); - expect(result.length).toBeLessThan(13_000); - expect(result).toContain('truncated for prompt budget'); + expect(result).toContain('WARNING: MEMORY.md is 250 lines (limit: 200). Only part of it was loaded.'); + expect(result.split('\n').length).toBeLessThan(400); }); }); \ No newline at end of file diff --git a/packages/core/src/memory/prompt.ts b/packages/core/src/memory/prompt.ts index ccf8733a9bc..68f50e5f2b2 100644 --- a/packages/core/src/memory/prompt.ts +++ b/packages/core/src/memory/prompt.ts @@ -4,39 +4,221 @@ * SPDX-License-Identifier: Apache-2.0 */ -const MANAGED_AUTO_MEMORY_HEADER = '## Managed Auto-Memory'; -const MAX_MANAGED_AUTO_MEMORY_CHARS = 12_000; +const MAX_MANAGED_AUTO_MEMORY_INDEX_LINES = 200; +const MAX_MANAGED_AUTO_MEMORY_INDEX_BYTES = 25_000; + +const DIR_EXISTS_GUIDANCE = + 'This directory already exists — write to it directly with the write_file tool (do not run mkdir or check for its existence).'; + +export const MEMORY_FRONTMATTER_EXAMPLE: readonly string[] = [ + '```markdown', + '---', + 'name: {{memory name}}', + 'description: {{one-line description — used to decide relevance in future conversations, so be specific}}', + 'type: {{user, feedback, project, reference}}', + '---', + '', + '{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}}', + '```', +]; + +export const TYPES_SECTION_INDIVIDUAL: readonly string[] = [ + '## Types of memory', + '', + 'There are several discrete types of memory that you can store in your memory system:', + '', + '', + '', + ' user', + " Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.", + " When you learn any details about the user's role, preferences, responsibilities, or knowledge", + " When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.", + ' ', + " user: I'm a data scientist investigating what logging we have in place", + ' assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]', + '', + " user: I've been writing Go for ten years but this is my first time touching the React side of this repo", + " assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]", + ' ', + '', + '', + ' feedback', + ' Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious.', + ' Any time the user corrects your approach ("no not that", "don\'t", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later.', + ' Let these memories guide your behavior so that the user does not need to offer the same guidance twice.', + ' Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.', + ' ', + " user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed", + ' assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]', + '', + ' user: stop summarizing what you just did at the end of every response, I can read the diff', + ' assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]', + '', + " user: yeah the single bundled PR was the right call here, splitting this one would've just been churn", + ' assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction]', + ' ', + '', + '', + ' project', + ' Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.', + ' When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.', + " Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.", + ' Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.', + ' ', + " user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch", + ' assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]', + '', + " user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements", + ' assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]', + ' ', + '', + '', + ' reference', + ' Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.', + ' When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.', + ' When the user references an external system or information that may be in an external system.', + ' ', + ' user: check the Linear project "INGEST" if you want context on these tickets, that\'s where we track all pipeline bugs', + ' assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]', + '', + " user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone", + ' assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]', + ' ', + '', + '', + '', +]; + +export const WHAT_NOT_TO_SAVE_SECTION: readonly string[] = [ + '## What NOT to save in memory', + '', + '- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.', + '- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.', + '- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.', + '- Anything already documented in QWEN.md or AGENTS.md files.', + '- Ephemeral task details: in-progress work, temporary state, current conversation context.', + '', + 'These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping.', +]; + +export const MEMORY_DRIFT_CAVEAT = + '- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it.'; + +export const WHEN_TO_ACCESS_SECTION: readonly string[] = [ + '## When to access memories', + '- When memories seem relevant, or the user references prior-conversation work.', + '- You MUST access memory when the user explicitly asks you to check, recall, or remember.', + '- If the user says to *ignore* or *not use* memory: proceed as if MEMORY.md were empty. Do not apply remembered facts, cite, compare against, or mention memory content.', + MEMORY_DRIFT_CAVEAT, +]; + +export const TRUSTING_RECALL_SECTION: readonly string[] = [ + '## Before recommending from memory', + '', + 'A memory that names a specific function, file, or flag is a claim that it existed when the memory was written. It may have been renamed, removed, or never merged. Before recommending it:', + '', + '- If the memory names a file path: check the file exists.', + '- If the memory names a function or flag: grep for it.', + '- If the user is about to act on your recommendation (not just asking about history), verify first.', + '', + '"The memory says X exists" is not the same as "X exists now."', + '', + 'A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot.', +]; function truncateManagedAutoMemoryIndex(indexContent: string): string { const trimmed = indexContent.trim(); - if (trimmed.length <= MAX_MANAGED_AUTO_MEMORY_CHARS) { + const lines = trimmed.split('\n'); + const lineCount = lines.length; + const byteCount = trimmed.length; + const wasLineTruncated = lineCount > MAX_MANAGED_AUTO_MEMORY_INDEX_LINES; + const wasByteTruncated = byteCount > MAX_MANAGED_AUTO_MEMORY_INDEX_BYTES; + + if (!wasLineTruncated && !wasByteTruncated) { return trimmed; } - const truncated = trimmed.slice(0, MAX_MANAGED_AUTO_MEMORY_CHARS).trimEnd(); - return `${truncated}\n\n> NOTE: Managed auto-memory index truncated for prompt budget.`; + let truncated = wasLineTruncated + ? lines.slice(0, MAX_MANAGED_AUTO_MEMORY_INDEX_LINES).join('\n') + : trimmed; + + if (truncated.length > MAX_MANAGED_AUTO_MEMORY_INDEX_BYTES) { + const cutAt = truncated.lastIndexOf('\n', MAX_MANAGED_AUTO_MEMORY_INDEX_BYTES); + truncated = truncated.slice( + 0, + cutAt > 0 ? cutAt : MAX_MANAGED_AUTO_MEMORY_INDEX_BYTES, + ); + } + + const reason = + wasByteTruncated && !wasLineTruncated + ? `${(byteCount / 1024).toFixed(1)} KB (limit: ${(MAX_MANAGED_AUTO_MEMORY_INDEX_BYTES / 1024).toFixed(1)} KB) — index entries are too long` + : wasLineTruncated && !wasByteTruncated + ? `${lineCount} lines (limit: ${MAX_MANAGED_AUTO_MEMORY_INDEX_LINES})` + : `${lineCount} lines and ${(byteCount / 1024).toFixed(1)} KB`; + + return `${truncated}\n\n> WARNING: MEMORY.md is ${reason}. Only part of it was loaded. Keep index entries to one line under ~200 chars; move detail into topic files.`; } -export function buildManagedAutoMemoryPrompt(indexContent?: string | null): string { +export function buildManagedAutoMemoryPrompt( + memoryDir: string, + indexContent?: string | null, +): string { const trimmed = indexContent?.trim(); - if (!trimmed) { - return ''; - } - return [ - MANAGED_AUTO_MEMORY_HEADER, + const lines = [ + '# auto memory', '', - 'Use this as durable project memory when relevant. The detailed topic files remain on disk; this block is the loaded index.', + `You have a persistent, file-based memory system at \`${memoryDir}\`. ${DIR_EXISTS_GUIDANCE}`, '', - truncateManagedAutoMemoryIndex(trimmed), - ].join('\n'); + "You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.", + '', + 'If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.', + '', + ...TYPES_SECTION_INDIVIDUAL, + ...WHAT_NOT_TO_SAVE_SECTION, + '', + '## How to save memories', + '', + 'Saving a memory is a two-step process:', + '', + '**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:', + '', + ...MEMORY_FRONTMATTER_EXAMPLE, + '', + `**Step 2** — add a pointer to that file in \`MEMORY.md\`. \`MEMORY.md\` is an index, not a memory — each entry should be one line, under ~150 characters: \`- [Title](file.md) — one-line hook\`. It has no frontmatter. Never write memory content directly into \`MEMORY.md\`.`, + '', + `- \`MEMORY.md\` is always loaded into your conversation context — lines after ${MAX_MANAGED_AUTO_MEMORY_INDEX_LINES} will be truncated, so keep the index concise`, + '- Keep the name, description, and type fields in memory files up-to-date with the content', + '- Organize memory semantically by topic, not chronologically.', + '- Update or remove memories that turn out to be wrong or outdated.', + '- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.', + '', + ...WHEN_TO_ACCESS_SECTION, + '', + ...TRUSTING_RECALL_SECTION, + '', + '## Memory and other forms of persistence', + 'Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.', + '- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.', + '- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.', + '', + '## MEMORY.md', + '', + trimmed + ? truncateManagedAutoMemoryIndex(trimmed) + : 'Your MEMORY.md is currently empty. When you save new memories, they will appear here.', + ]; + + return lines.join('\n'); } export function appendManagedAutoMemoryToUserMemory( userMemory: string, + memoryDir: string, indexContent?: string | null, ): string { - const managedPrompt = buildManagedAutoMemoryPrompt(indexContent); + const managedPrompt = buildManagedAutoMemoryPrompt(memoryDir, indexContent); const trimmedUserMemory = userMemory.trim(); if (!managedPrompt) { @@ -50,6 +232,5 @@ export function appendManagedAutoMemoryToUserMemory( } export { - MANAGED_AUTO_MEMORY_HEADER, - MAX_MANAGED_AUTO_MEMORY_CHARS, + MAX_MANAGED_AUTO_MEMORY_INDEX_LINES, }; \ No newline at end of file diff --git a/packages/core/src/memory/recall.test.ts b/packages/core/src/memory/recall.test.ts index 5a92ffb43fb..05d0750dc14 100644 --- a/packages/core/src/memory/recall.test.ts +++ b/packages/core/src/memory/recall.test.ts @@ -31,23 +31,32 @@ const docs: ScannedAutoMemoryDocument[] = [ { type: 'reference', filePath: '/tmp/reference.md', + relativePath: 'reference.md', + filename: 'reference.md', title: 'Reference Memory', description: 'Dashboards and external docs', body: '# Reference Memory\n\n- Grafana dashboard: grafana.internal/d/api-latency', + mtimeMs: 3, }, { type: 'project', filePath: '/tmp/project.md', + relativePath: 'project.md', + filename: 'project.md', title: 'Project Memory', description: 'Project constraints and release context', body: '# Project Memory\n\n- Release freeze starts Friday.', + mtimeMs: 2, }, { type: 'user', filePath: '/tmp/user.md', + relativePath: 'user.md', + filename: 'user.md', title: 'User Memory', description: 'User preferences', body: '# User Memory\n\n- User prefers terse responses.', + mtimeMs: 1, }, ]; @@ -73,7 +82,7 @@ describe('auto-memory relevant recall', () => { it('formats selected documents as a prompt block', () => { const prompt = buildRelevantAutoMemoryPrompt([docs[0], docs[2]]); - expect(prompt).toContain('## Relevant Managed Auto-Memory'); + expect(prompt).toContain('## Relevant memory'); expect(prompt).toContain('Reference Memory (reference.md)'); expect(prompt).toContain('User Memory (user.md)'); }); diff --git a/packages/core/src/memory/recall.ts b/packages/core/src/memory/recall.ts index 97eca42a020..60bcb3e49e9 100644 --- a/packages/core/src/memory/recall.ts +++ b/packages/core/src/memory/recall.ts @@ -11,9 +11,10 @@ import { scanAutoMemoryTopicDocuments, type ScannedAutoMemoryDocument, } from './scan.js'; +import { memoryAge, memoryFreshnessText } from './memoryAge.js'; import { selectRelevantAutoMemoryDocumentsByModel } from './relevanceSelector.js'; -const MAX_RELEVANT_DOCS = 3; +const MAX_RELEVANT_DOCS = 5; const MAX_DOC_BODY_CHARS = 1_200; const debugLogger = createDebugLogger('AUTO_MEMORY_RECALL'); @@ -104,17 +105,20 @@ export function buildRelevantAutoMemoryPrompt( } return [ - '## Relevant Managed Auto-Memory', + '## Relevant memory', '', - 'Use the following project memory only when it is directly relevant to the current request.', + 'Use the following memories only when they are directly relevant to the current request. Verify file/function claims before relying on them.', '', ...docs.flatMap((doc) => { const body = truncateBody(doc.body); + const staleness = memoryFreshnessText(doc.mtimeMs); return [ - `### ${doc.title} (${path.basename(doc.filePath)})`, + `### ${doc.title} (${doc.relativePath || path.basename(doc.filePath)})`, + `Saved ${memoryAge(doc.mtimeMs)}.`, doc.description, '', body || '_No detailed entries yet._', + ...(staleness ? ['', `> NOTE: ${staleness}`] : []), '', ]; }), @@ -125,6 +129,7 @@ export interface ResolveRelevantAutoMemoryPromptOptions { config?: Config; excludedFilePaths?: Iterable; limit?: number; + recentTools?: readonly string[]; } export interface RelevantAutoMemoryPromptResult { @@ -175,6 +180,7 @@ export async function resolveRelevantAutoMemoryPromptForQuery( query, docs, limit, + options.recentTools ?? [], ); return { prompt: buildRelevantAutoMemoryPrompt(selectedDocs), diff --git a/packages/core/src/memory/relevanceSelector.test.ts b/packages/core/src/memory/relevanceSelector.test.ts index 733e493527f..774afa98b63 100644 --- a/packages/core/src/memory/relevanceSelector.test.ts +++ b/packages/core/src/memory/relevanceSelector.test.ts @@ -18,16 +18,22 @@ const docs: ScannedAutoMemoryDocument[] = [ { type: 'user', filePath: '/tmp/user.md', + relativePath: 'user.md', + filename: 'user.md', title: 'User Memory', description: 'User preferences', body: '- User prefers terse responses.', + mtimeMs: 1, }, { type: 'reference', filePath: '/tmp/reference.md', + relativePath: 'reference.md', + filename: 'reference.md', title: 'Reference Memory', description: 'Operational references', body: '- Grafana dashboard: https://grafana.internal/d/api-latency', + mtimeMs: 2, }, ]; @@ -40,8 +46,7 @@ describe('selectRelevantAutoMemoryDocumentsByModel', () => { it('returns documents chosen by the side-query selector', async () => { vi.mocked(runSideQuery).mockResolvedValue({ - relevantFilePaths: ['/tmp/reference.md'], - reasoning: 'The request asks for dashboard information.', + selected_memories: ['reference.md'], }); const selected = await selectRelevantAutoMemoryDocumentsByModel( @@ -71,15 +76,15 @@ describe('selectRelevantAutoMemoryDocumentsByModel', () => { expect(runSideQuery).not.toHaveBeenCalled(); }); - it('throws when selector returns unknown file paths', async () => { + it('throws when selector returns unknown relative paths', async () => { vi.mocked(runSideQuery).mockImplementation(async (_config, options) => { const error = options.validate?.({ - relevantFilePaths: ['/tmp/unknown.md'], + selected_memories: ['unknown.md'], }); if (error) { throw new Error(error); } - return { relevantFilePaths: [] }; + return { selected_memories: [] }; }); await expect( @@ -89,6 +94,6 @@ describe('selectRelevantAutoMemoryDocumentsByModel', () => { docs, 2, ), - ).rejects.toThrow('Recall selector returned unknown file path'); + ).rejects.toThrow('Recall selector returned unknown relative path'); }); }); diff --git a/packages/core/src/memory/relevanceSelector.ts b/packages/core/src/memory/relevanceSelector.ts index 18a05a8da5f..67422242c55 100644 --- a/packages/core/src/memory/relevanceSelector.ts +++ b/packages/core/src/memory/relevanceSelector.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2026 Qwen Team + * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -9,67 +9,50 @@ import type { Config } from '../config/config.js'; import { runSideQuery } from '../auxiliary/sideQuery.js'; import type { ScannedAutoMemoryDocument } from './scan.js'; -const MAX_SELECTOR_EXCERPT_CHARS = 240; +/** + * System prompt for the selector side-query. Mirrors Claude Code's + * SELECT_MEMORIES_SYSTEM_PROMPT — including the recentTools instruction — + * so selection behaviour stays consistent. + */ +const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to an AI coding assistant as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions. + +Return a list of filenames for the memories that will clearly be useful to the assistant as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description. +- If you are unsure if a memory will be useful in processing the user's query, then do not include it in your list. Be selective and discerning. +- If there are no memories in the list that would clearly be useful, feel free to return an empty list. +- If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools (the assistant is already exercising them). DO still select memories containing warnings, gotchas, or known issues about those tools — active use is exactly when those matter.`; const RESPONSE_SCHEMA: Record = { type: 'object', properties: { - relevantFilePaths: { + selected_memories: { type: 'array', items: { type: 'string' }, - description: - 'Exact file paths of the managed memory topic documents that are directly relevant to the request.', - }, - reasoning: { - type: 'string', - description: 'Short explanation for the selection.', }, }, - required: ['relevantFilePaths'], + required: ['selected_memories'], + additionalProperties: false, }; interface RecallSelectorResponse { - relevantFilePaths: string[]; - reasoning?: string; + selected_memories: string[]; } -function truncateExcerpt(body: string): string { - const normalized = body.replace(/\s+/g, ' ').trim(); - if (normalized.length <= MAX_SELECTOR_EXCERPT_CHARS) { - return normalized; - } - return `${normalized.slice(0, MAX_SELECTOR_EXCERPT_CHARS).trimEnd()}…`; -} - -function buildSelectorPrompt( - query: string, - docs: ScannedAutoMemoryDocument[], - limit: number, -): string { - const candidateBlock = docs - .map( - (doc, index) => - [ - `Candidate ${index + 1}`, - `filePath: ${doc.filePath}`, - `type: ${doc.type}`, - `title: ${doc.title}`, - `description: ${doc.description || '(none)'}`, - `excerpt: ${truncateExcerpt(doc.body) || '(empty)'}`, - ].join('\n'), - ) - .join('\n\n'); - - return [ - 'Select the managed memory topic files that are directly relevant to the current user request.', - `Return at most ${limit} file paths.`, - 'If none are clearly relevant, return an empty array.', - 'Only return file paths from the provided candidates.', - '', - `User request:\n${query.trim()}`, - '', - `Candidates:\n${candidateBlock}`, - ].join('\n'); +/** + * Format memory headers as a text manifest: one line per file with + * [type] relativePath (ISO-timestamp): description. + * Mirrors Claude Code's formatMemoryManifest — selector sees only + * the header (type, path, age, description), not the body content. + */ +function formatMemoryManifest(docs: ScannedAutoMemoryDocument[]): string { + return docs + .map((doc) => { + const tag = `[${doc.type}] `; + const ts = new Date(doc.mtimeMs).toISOString(); + return doc.description + ? `- ${tag}${doc.relativePath} (${ts}): ${doc.description}` + : `- ${tag}${doc.relativePath} (${ts})`; + }) + .join('\n'); } export async function selectRelevantAutoMemoryDocumentsByModel( @@ -77,41 +60,64 @@ export async function selectRelevantAutoMemoryDocumentsByModel( query: string, docs: ScannedAutoMemoryDocument[], limit: number, + recentTools: readonly string[] = [], ): Promise { if (docs.length === 0 || limit <= 0 || query.trim().length === 0) { return []; } + const manifest = formatMemoryManifest(docs); + + // When the assistant is actively using a tool, surfacing that tool's + // reference docs is noise. Pass the tool list so the selector can skip them. + const toolsSection = + recentTools.length > 0 + ? `\n\nRecently used tools: ${recentTools.join(', ')}` + : ''; + const contents: Content[] = [ { role: 'user', - parts: [{ text: buildSelectorPrompt(query, docs, limit) }], + parts: [ + { + text: `Query: ${query.trim()}\n\nAvailable memories:\n${manifest}${toolsSection}`, + }, + ], }, ]; - const allowedPaths = new Set(docs.map((doc) => doc.filePath)); + const validRelativePaths = new Set(docs.map((doc) => doc.relativePath)); + const byRelativePath = new Map(docs.map((doc) => [doc.relativePath, doc])); + const response = await runSideQuery(config, { purpose: 'auto-memory-recall', contents, schema: RESPONSE_SCHEMA, abortSignal: AbortSignal.timeout(5_000), + systemInstruction: SELECT_MEMORIES_SYSTEM_PROMPT, config: { temperature: 0, }, validate: (value) => { - if (!Array.isArray(value.relevantFilePaths)) { - return 'Recall selector must return relevantFilePaths array'; + if (!Array.isArray(value.selected_memories)) { + return 'Recall selector must return selected_memories array'; } - if (value.relevantFilePaths.length > limit) { - return `Recall selector returned too many documents: ${value.relevantFilePaths.length}`; + if (value.selected_memories.length > limit) { + return `Recall selector returned too many documents: ${value.selected_memories.length}`; } - if (value.relevantFilePaths.some((filePath) => !allowedPaths.has(filePath))) { - return 'Recall selector returned unknown file path'; + if ( + value.selected_memories.some( + (relativePath) => !validRelativePaths.has(relativePath), + ) + ) { + return 'Recall selector returned unknown relative path'; } return null; }, }); - const selectedPathSet = new Set(response.relevantFilePaths); - return docs.filter((doc) => selectedPathSet.has(doc.filePath)).slice(0, limit); + return response.selected_memories + .map((relativePath) => byRelativePath.get(relativePath)) + .filter((doc): doc is ScannedAutoMemoryDocument => doc !== undefined) + .slice(0, limit); } diff --git a/packages/core/src/memory/scan.test.ts b/packages/core/src/memory/scan.test.ts index 207f803db9c..95e6066f52f 100644 --- a/packages/core/src/memory/scan.test.ts +++ b/packages/core/src/memory/scan.test.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getAutoMemoryTopicPath } from './paths.js'; +import { getAutoMemoryFilePath } from './paths.js'; import { parseAutoMemoryTopicDocument, scanAutoMemoryTopicDocuments, @@ -54,25 +54,31 @@ describe('auto-memory topic scanning', () => { expect(parsed).toEqual({ type: 'project', filePath: '/tmp/project.md', + relativePath: 'project.md', + filename: 'project.md', title: 'Project Memory', description: 'Project context', body: '# Project Memory\n\n- Release freeze starts Friday.', + mtimeMs: 0, }); }); - it('scans existing auto-memory topic files from the project scaffold', async () => { + it('scans existing auto-memory files from nested topic folders', async () => { + const referencePath = getAutoMemoryFilePath( + projectRoot, + path.join('reference', 'grafana.md'), + ); + await fs.mkdir(path.dirname(referencePath), { recursive: true }); await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'reference'), + referencePath, [ '---', 'type: reference', - 'title: Reference Memory', + 'name: Reference Memory', 'description: External references', '---', '', - '# Reference Memory', - '', - '- Oncall dashboard: grafana.internal/d/api-latency', + 'Oncall dashboard: grafana.internal/d/api-latency', ].join('\n'), 'utf-8', ); @@ -81,6 +87,7 @@ describe('auto-memory topic scanning', () => { const referenceDoc = docs.find((doc) => doc.type === 'reference'); expect(referenceDoc?.description).toBe('External references'); + expect(referenceDoc?.relativePath).toBe(path.join('reference', 'grafana.md')); expect(referenceDoc?.body).toContain('grafana.internal/d/api-latency'); }); }); \ No newline at end of file diff --git a/packages/core/src/memory/scan.ts b/packages/core/src/memory/scan.ts index 2f0863ca3ea..661192c4a63 100644 --- a/packages/core/src/memory/scan.ts +++ b/packages/core/src/memory/scan.ts @@ -5,15 +5,21 @@ */ import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { AUTO_MEMORY_TYPES, type AutoMemoryType } from './types.js'; -import { getAutoMemoryTopicPath } from './paths.js'; +import { AUTO_MEMORY_INDEX_FILENAME, getAutoMemoryRoot } from './paths.js'; + +const MAX_SCANNED_MEMORY_FILES = 200; export interface ScannedAutoMemoryDocument { type: AutoMemoryType; filePath: string; + relativePath: string; + filename: string; title: string; description: string; body: string; + mtimeMs: number; } function parseFrontmatterValue( @@ -27,6 +33,8 @@ function parseFrontmatterValue( export function parseAutoMemoryTopicDocument( filePath: string, content: string, + mtimeMs = 0, + relativePath = path.basename(filePath), ): ScannedAutoMemoryDocument | null { const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!frontmatterMatch) { @@ -42,30 +50,62 @@ export function parseAutoMemoryTopicDocument( return { type: rawType as AutoMemoryType, filePath, - title: parseFrontmatterValue(frontmatter, 'title') ?? rawType, + relativePath, + filename: path.basename(filePath), + title: + parseFrontmatterValue(frontmatter, 'name') ?? + parseFrontmatterValue(frontmatter, 'title') ?? + rawType, description: parseFrontmatterValue(frontmatter, 'description') ?? '', body: bodyContent.trim(), + mtimeMs, }; } +async function listMarkdownFiles(root: string): Promise { + try { + const entries = await fs.readdir(root, { recursive: true }); + return entries + .filter( + (entry): entry is string => + typeof entry === 'string' && + entry.endsWith('.md') && + path.basename(entry) !== AUTO_MEMORY_INDEX_FILENAME, + ) + .sort(); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + return []; + } + throw error; + } +} + export async function scanAutoMemoryTopicDocuments( projectRoot: string, ): Promise { + const root = getAutoMemoryRoot(projectRoot); + const relativePaths = await listMarkdownFiles(root); const docs = await Promise.all( - AUTO_MEMORY_TYPES.map(async (type) => { - const filePath = getAutoMemoryTopicPath(projectRoot, type); - try { - const content = await fs.readFile(filePath, 'utf-8'); - return parseAutoMemoryTopicDocument(filePath, content); - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === 'ENOENT') { - return null; - } - throw error; - } + relativePaths.map(async (relativePath) => { + const filePath = path.join(root, relativePath); + const [content, stats] = await Promise.all([ + fs.readFile(filePath, 'utf-8'), + fs.stat(filePath), + ]); + return parseAutoMemoryTopicDocument( + filePath, + content, + stats.mtimeMs, + relativePath, + ); }), ); - return docs.filter((doc): doc is ScannedAutoMemoryDocument => doc !== null); + return docs + .filter((doc): doc is ScannedAutoMemoryDocument => doc !== null) + .filter((doc) => AUTO_MEMORY_TYPES.includes(doc.type)) + .sort((a, b) => b.mtimeMs - a.mtimeMs || a.filename.localeCompare(b.filename)) + .slice(0, MAX_SCANNED_MEMORY_FILES); } \ No newline at end of file diff --git a/packages/core/src/memory/status.test.ts b/packages/core/src/memory/status.test.ts deleted file mode 100644 index 393fe67ce39..00000000000 --- a/packages/core/src/memory/status.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getManagedAutoMemoryDreamTaskRegistry } from './dreamScheduler.js'; -import { getManagedAutoMemoryStatus } from './status.js'; -import { getAutoMemoryTopicPath } from './paths.js'; -import { ensureAutoMemoryScaffold } from './store.js'; -import { markExtractRunning, resetAutoMemoryStateForTests } from './state.js'; - -describe('managed auto-memory status', () => { - let tempDir: string; - let projectRoot: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-status-')); - projectRoot = path.join(tempDir, 'project'); - await fs.mkdir(projectRoot, { recursive: true }); - await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T00:00:00.000Z')); - }); - - afterEach(async () => { - resetAutoMemoryStateForTests(); - await fs.rm(tempDir, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 10, - }); - }); - - it('aggregates cursor, topics, extraction state, and dream tasks', async () => { - await fs.writeFile( - getAutoMemoryTopicPath(projectRoot, 'user'), - [ - '---', - 'type: user', - 'title: User Memory', - 'description: User profile', - '---', - '', - '# User Memory', - '', - '- User prefers terse responses.', - ].join('\n'), - 'utf-8', - ); - - markExtractRunning(projectRoot); - getManagedAutoMemoryDreamTaskRegistry().register({ - taskType: 'managed-auto-memory-dream', - title: 'Managed auto-memory dream', - projectRoot, - }); - - const status = await getManagedAutoMemoryStatus(projectRoot); - - expect(status.extractionRunning).toBe(true); - expect(status.topics.find((topic) => topic.topic === 'user')).toEqual( - expect.objectContaining({ - entryCount: 1, - hooks: ['User prefers terse responses.'], - }), - ); - expect(status.dreamTasks).toHaveLength(1); - expect(status.indexContent).toContain('# Managed Auto-Memory Index'); - }); -}); diff --git a/packages/core/src/memory/status.ts b/packages/core/src/memory/status.ts index 3f86a020077..e2bfe64e694 100644 --- a/packages/core/src/memory/status.ts +++ b/packages/core/src/memory/status.ts @@ -7,15 +7,13 @@ import * as fs from 'node:fs/promises'; import { getManagedAutoMemoryExtractTaskRegistry } from './extractScheduler.js'; import { getManagedAutoMemoryDreamTaskRegistry } from './dreamScheduler.js'; -import { buildAutoMemoryTopicHooks, countAutoMemoryTopicEntries } from './indexer.js'; import { getAutoMemoryExtractCursorPath, getAutoMemoryIndexPath, getAutoMemoryMetadataPath, getAutoMemoryRoot, - getAutoMemoryTopicPath, } from './paths.js'; -import { parseAutoMemoryTopicDocument } from './scan.js'; +import { scanAutoMemoryTopicDocuments } from './scan.js'; import { isExtractRunning } from './state.js'; import type { AutoMemoryExtractCursor, @@ -27,10 +25,8 @@ import type { BackgroundTaskState } from '../background/taskRegistry.js'; export interface ManagedAutoMemoryTopicStatus { topic: AutoMemoryType; - title: string; entryCount: number; - hooks: string[]; - filePath: string; + filePaths: string[]; } export interface ManagedAutoMemoryStatus { @@ -59,46 +55,30 @@ export async function getManagedAutoMemoryStatus( ): Promise { const root = getAutoMemoryRoot(projectRoot); const indexPath = getAutoMemoryIndexPath(projectRoot); - const [indexContent, cursor, metadata, topics] = await Promise.all([ - fs.readFile(indexPath, 'utf-8').catch(() => ''), - readJsonFile(getAutoMemoryExtractCursorPath(projectRoot)), - readJsonFile(getAutoMemoryMetadataPath(projectRoot)), - Promise.all( - AUTO_MEMORY_TYPES.map(async (topic) => { - const filePath = getAutoMemoryTopicPath(projectRoot, topic); - try { - const content = await fs.readFile(filePath, 'utf-8'); - const parsed = parseAutoMemoryTopicDocument(filePath, content); - if (!parsed) { - return { - topic, - title: topic, - entryCount: 0, - hooks: [], - filePath, - }; - } - return { - topic, - title: parsed.title, - entryCount: countAutoMemoryTopicEntries(parsed.body), - hooks: buildAutoMemoryTopicHooks(parsed.body), - filePath, - } satisfies ManagedAutoMemoryTopicStatus; - } catch { - return { - topic, - title: topic, - entryCount: 0, - hooks: [], - filePath, - } satisfies ManagedAutoMemoryTopicStatus; - } - }), + const [indexContent, cursor, metadata, docs] = await Promise.all([ + fs.readFile(indexPath, 'utf-8').catch(() => ''), + readJsonFile( + getAutoMemoryExtractCursorPath(projectRoot), ), + readJsonFile(getAutoMemoryMetadataPath(projectRoot)), + scanAutoMemoryTopicDocuments(projectRoot), ]); + // Aggregate per-entry files by topic + const byTopic = new Map(); + for (const doc of docs) { + const list = byTopic.get(doc.type) ?? []; + list.push(doc.filePath); + byTopic.set(doc.type, list); + } + + const topics = AUTO_MEMORY_TYPES.map((topic) => ({ + topic, + entryCount: byTopic.get(topic)?.length ?? 0, + filePaths: byTopic.get(topic) ?? [], + })); + return { root, indexPath, @@ -110,6 +90,8 @@ export async function getManagedAutoMemoryStatus( extractionTasks: getManagedAutoMemoryExtractTaskRegistry() .list(projectRoot) .slice(0, 8), - dreamTasks: getManagedAutoMemoryDreamTaskRegistry().list(projectRoot).slice(0, 5), + dreamTasks: getManagedAutoMemoryDreamTaskRegistry() + .list(projectRoot) + .slice(0, 5), }; } diff --git a/packages/core/src/memory/store.test.ts b/packages/core/src/memory/store.test.ts index 10804227f0c..c7502d04742 100644 --- a/packages/core/src/memory/store.test.ts +++ b/packages/core/src/memory/store.test.ts @@ -19,11 +19,9 @@ import { import { createDefaultAutoMemoryIndex, createDefaultAutoMemoryMetadata, - createDefaultAutoMemoryTopic, ensureAutoMemoryScaffold, readAutoMemoryIndex, } from './store.js'; -import { AUTO_MEMORY_TYPES } from './types.js'; describe('auto-memory storage scaffold', () => { let tempDir: string; @@ -84,11 +82,8 @@ describe('auto-memory storage scaffold', () => { updatedAt: '2026-04-01T08:00:00.000Z', }); - for (const type of AUTO_MEMORY_TYPES) { - await expect(fs.readFile(getAutoMemoryTopicPath(projectRoot, type), 'utf-8')).resolves.toBe( - createDefaultAutoMemoryTopic(type), - ); - } + await expect(fs.stat(getAutoMemoryRoot(projectRoot))).resolves.toBeDefined(); + await expect(fs.access(getAutoMemoryTopicPath(projectRoot, 'user'))).rejects.toThrow(); }); it('is idempotent and preserves existing index content', async () => { @@ -109,8 +104,6 @@ describe('auto-memory storage scaffold', () => { it('reads the managed auto-memory index after scaffold creation', async () => { await ensureAutoMemoryScaffold(projectRoot); - await expect(readAutoMemoryIndex(projectRoot)).resolves.toContain( - '# Managed Auto-Memory Index', - ); + await expect(readAutoMemoryIndex(projectRoot)).resolves.toBe(''); }); }); \ No newline at end of file diff --git a/packages/core/src/memory/store.ts b/packages/core/src/memory/store.ts index f6b4a52822a..eb966d6744a 100644 --- a/packages/core/src/memory/store.ts +++ b/packages/core/src/memory/store.ts @@ -11,39 +11,12 @@ import { getAutoMemoryIndexPath, getAutoMemoryMetadataPath, getAutoMemoryRoot, - getAutoMemoryTopicPath, } from './paths.js'; import { AUTO_MEMORY_SCHEMA_VERSION, - AUTO_MEMORY_TYPES, type AutoMemoryExtractCursor, type AutoMemoryMetadata, - type AutoMemoryType, } from './types.js'; -import { buildManagedAutoMemoryIndex } from './indexer.js'; - -const TOPIC_DESCRIPTIONS: Record = { - user: 'User profile, preferences, background, and stable collaboration context.', - feedback: - 'Corrections and validated guidance about how the assistant should work with this user/project.', - project: - 'Non-derivable project facts, goals, constraints, incidents, and coordination context.', - reference: - 'Pointers to durable external systems, dashboards, tickets, and reference resources.', -}; - -function buildTopicTitle(type: AutoMemoryType): string { - switch (type) { - case 'user': - return 'User Memory'; - case 'feedback': - return 'Feedback Memory'; - case 'project': - return 'Project Memory'; - case 'reference': - return 'Reference Memory'; - } -} export function createDefaultAutoMemoryMetadata( now = new Date(), @@ -65,31 +38,7 @@ export function createDefaultAutoMemoryExtractCursor( } export function createDefaultAutoMemoryIndex(): string { - return buildManagedAutoMemoryIndex( - AUTO_MEMORY_TYPES.map((type) => ({ - type, - filePath: `${type}.md`, - title: buildTopicTitle(type), - description: TOPIC_DESCRIPTIONS[type], - body: '_No entries yet._', - })), - ); -} - -export function createDefaultAutoMemoryTopic(type: AutoMemoryType): string { - const title = buildTopicTitle(type); - return [ - '---', - `type: ${type}`, - `title: ${title}`, - `description: ${TOPIC_DESCRIPTIONS[type]}`, - '---', - '', - `# ${title}`, - '', - '_No entries yet._', - '', - ].join('\n'); + return ''; } async function writeFileIfMissing( @@ -128,15 +77,6 @@ export async function ensureAutoMemoryScaffold( getAutoMemoryExtractCursorPath(projectRoot), JSON.stringify(createDefaultAutoMemoryExtractCursor(now), null, 2) + '\n', ); - - await Promise.all( - AUTO_MEMORY_TYPES.map((type) => - writeFileIfMissing( - getAutoMemoryTopicPath(projectRoot, type), - createDefaultAutoMemoryTopic(type), - ), - ), - ); } export async function readAutoMemoryIndex( diff --git a/packages/core/src/memory/types.ts b/packages/core/src/memory/types.ts index 0f821db2b7d..6e05930f6e0 100644 --- a/packages/core/src/memory/types.ts +++ b/packages/core/src/memory/types.ts @@ -17,29 +17,10 @@ export const AUTO_MEMORY_SCHEMA_VERSION = 1; export interface AutoMemorySourceRef { sessionId?: string; - transcriptPath?: string; recordedAt: string; messageIds?: string[]; } -export interface AutoMemoryEntry { - id: string; - type: AutoMemoryType; - title: string; - summary: string; - tags: string[]; - lastUpdated: string; - stability: 'working' | 'stable'; - sources: AutoMemorySourceRef[]; -} - -export interface AutoMemoryTopicDocument { - type: AutoMemoryType; - title: string; - description: string; - entries: AutoMemoryEntry[]; -} - export interface AutoMemoryMetadata { version: typeof AUTO_MEMORY_SCHEMA_VERSION; createdAt: string; @@ -57,7 +38,6 @@ export interface AutoMemoryMetadata { export interface AutoMemoryExtractCursor { sessionId?: string; - transcriptPath?: string; processedOffset?: number; updatedAt: string; } \ No newline at end of file diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 7296594aca3..95763f254ac 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -6,6 +6,7 @@ import os from 'node:os'; import path from 'node:path'; +import fs from 'node:fs/promises'; import { makeRelative, shortenPath } from '../utils/paths.js'; import type { ToolInvocation, ToolLocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; @@ -24,6 +25,8 @@ import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { isSubpaths, isSubpath } from '../utils/paths.js'; import { Storage } from '../config/storage.js'; +import { isAutoMemPath, getMemoryBaseDir } from '../memory/paths.js'; +import { memoryFreshnessNote } from '../memory/memoryAge.js'; /** * Parameters for the ReadFile tool @@ -92,13 +95,17 @@ class ReadFileToolInvocation extends BaseToolInvocation< const userExtensionsDir = Storage.getUserExtensionsDir(); const osTempDir = os.tmpdir(); + // Auto-allow reads of any file under the global memory base dir — models + // legitimately read their own memory files without needing user approval. + const memoryBaseDir = getMemoryBaseDir(); if ( workspaceContext.isPathWithinWorkspace(filePath) || isSubpath(projectTempDir, filePath) || isSubpath(globalTempDir, filePath) || isSubpath(osTempDir, filePath) || isSubpaths(userSkillsDirs, filePath) || - isSubpath(userExtensionsDir, filePath) + isSubpath(userExtensionsDir, filePath) || + isSubpath(memoryBaseDir, filePath) ) { return 'allow'; } @@ -133,6 +140,26 @@ class ReadFileToolInvocation extends BaseToolInvocation< llmContent = result.llmContent || ''; } + // For memory files, prepend a per-file staleness caveat so the model knows + // the content is a point-in-time snapshot and may be stale. + const projectRoot = this.config.getTargetDir(); + if ( + typeof llmContent === 'string' && + isAutoMemPath(path.resolve(this.params.file_path), projectRoot) + ) { + // Only compute mtime when we actually need the note (avoids extra stat on + // every non-memory file read). + try { + const stat = await fs.stat(path.resolve(this.params.file_path)); + const note = memoryFreshnessNote(stat.mtimeMs); + if (note) { + llmContent = note + llmContent; + } + } catch { + // Best-effort — if stat fails, omit the note silently. + } + } + const lines = typeof result.llmContent === 'string' ? result.llmContent.split('\n').length diff --git a/packages/core/test-setup.ts b/packages/core/test-setup.ts index 8d2e7f74ac7..df4d79a0461 100644 --- a/packages/core/test-setup.ts +++ b/packages/core/test-setup.ts @@ -20,6 +20,11 @@ if (process.env['QWEN_DEBUG_LOG_FILE'] === undefined) { // Disable 429 simulation globally for all tests setSimulate429(false); +// Keep managed auto-memory test fixtures under per-test temp project roots. +if (process.env['QWEN_CODE_MEMORY_LOCAL'] === undefined) { + process.env['QWEN_CODE_MEMORY_LOCAL'] = '1'; +} + // Some dependencies (e.g., undici) expect a global File constructor in Node. // Provide a minimal shim for test environment if missing. if (typeof (globalThis as unknown as { File?: unknown }).File === 'undefined') { From bd2cbb88c7a3b72639dcfc3ea204b2d45024ceae Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 8 Apr 2026 12:40:41 +0800 Subject: [PATCH 22/56] feat(memory-ui): add memory saved notification and memory count badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 3 - Memory Saved Notification: - Add HistoryItemMemorySaved type to types.ts - Create MemorySavedMessage component for rendering '● Saved/Updated N memories' - In useGeminiStream: detect in-turn memory writes via mapToDisplay's memoryWriteCount field and emit 'memory_saved' history item after turn - In client.ts: capture background dream/extract promises and expose via consumePendingMemoryTaskPromises(); useGeminiStream listens post-turn and emits 'Updated N memories' notification for background tasks Feature 4 - Memory Count Badge: - Add isMemoryOp field to IndividualToolCallDisplay - Add memoryWriteCount/memoryReadCount to HistoryItemToolGroup - Add detectMemoryOp() in useReactToolScheduler using isAutoMemPath - ToolGroupMessage renders '● Recalled N memories, Wrote N memories' badge at the top of tool groups that touch memory files Fix: process.env bracket-access in paths.ts (noPropertyAccessFromIndexSignature) Fix: MemoryDialog.test.tsx mock useSettings to satisfy SettingsProvider requirement --- packages/cli/src/config/config.ts | 2 + packages/cli/src/config/settingsSchema.ts | 32 +++ .../src/ui/components/HistoryItemDisplay.tsx | 6 + .../src/ui/components/MemoryDialog.test.tsx | 9 + .../cli/src/ui/components/MemoryDialog.tsx | 198 ++++++++++++------ .../messages/MemorySavedMessage.tsx | 38 ++++ .../components/messages/ToolGroupMessage.tsx | 25 ++- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 1 + packages/cli/src/ui/hooks/useGeminiStream.ts | 47 ++++- .../cli/src/ui/hooks/useReactToolScheduler.ts | 33 +++ packages/cli/src/ui/types.ts | 21 +- packages/core/src/config/config.ts | 8 + packages/core/src/core/client.ts | 36 +++- packages/core/src/memory/dreamScheduler.ts | 7 + packages/core/src/memory/paths.ts | 6 +- 15 files changed, 395 insertions(+), 74 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/MemorySavedMessage.tsx diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fcc33f76ad2..81f0a9a0255 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1122,6 +1122,8 @@ export async function loadCliConfig( }, hooks: settings.hooks, hooksConfig: settings.hooksConfig, + enableManagedAutoMemory: settings.memory?.enableManagedAutoMemory ?? true, + enableManagedAutoDream: settings.memory?.enableManagedAutoDream ?? true, enableHooks: argv.experimentalHooks === true || settings.hooksConfig?.enabled === true, channel: argv.channel, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d2cf5081c76..cd3f0ed28f0 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -864,6 +864,38 @@ const SETTINGS_SCHEMA = { }, }, + memory: { + type: 'object', + label: 'Memory', + category: 'Memory', + requiresRestart: false, + default: {}, + description: 'Settings for managed auto-memory.', + showInDialog: false, + properties: { + enableManagedAutoMemory: { + type: 'boolean', + label: 'Enable Managed Auto-Memory', + category: 'Memory', + requiresRestart: false, + default: true, + description: + 'Enable background extraction of memories from conversations.', + showInDialog: false, + }, + enableManagedAutoDream: { + type: 'boolean', + label: 'Enable Managed Auto-Dream', + category: 'Memory', + requiresRestart: false, + default: true, + description: + 'Enable automatic consolidation (dream) of collected memories.', + showInDialog: false, + }, + }, + }, + permissions: { type: 'object', label: 'Permissions', diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 12a46380e70..b5939fd6556 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -43,6 +43,7 @@ import { ContextUsage } from './views/ContextUsage.js'; import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js'; import { InsightProgressMessage } from './messages/InsightProgressMessage.js'; import { BtwMessage } from './messages/BtwMessage.js'; +import { MemorySavedMessage } from './messages/MemorySavedMessage.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -178,6 +179,8 @@ const HistoryItemDisplayComponent: React.FC = ({ isFocused={isFocused} activeShellPtyId={activeShellPtyId} embeddedShellFocused={embeddedShellFocused} + memoryWriteCount={itemForDisplay.memoryWriteCount} + memoryReadCount={itemForDisplay.memoryReadCount} /> )} {itemForDisplay.type === 'compression' && ( @@ -230,6 +233,9 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'btw' && itemForDisplay.btw && ( )} + {itemForDisplay.type === 'memory_saved' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/MemoryDialog.test.tsx b/packages/cli/src/ui/components/MemoryDialog.test.tsx index 5d7012b79c2..e37f158f2fb 100644 --- a/packages/cli/src/ui/components/MemoryDialog.test.tsx +++ b/packages/cli/src/ui/components/MemoryDialog.test.tsx @@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render } from 'ink-testing-library'; import { MemoryDialog } from './MemoryDialog.js'; import { useConfig } from '../contexts/ConfigContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; import { useLaunchEditor } from '../hooks/useLaunchEditor.js'; import { useKeypress } from '../hooks/useKeypress.js'; @@ -16,6 +17,10 @@ vi.mock('../contexts/ConfigContext.js', () => ({ useConfig: vi.fn(), })); +vi.mock('../contexts/SettingsContext.js', () => ({ + useSettings: vi.fn(), +})); + vi.mock('../hooks/useLaunchEditor.js', () => ({ useLaunchEditor: vi.fn(), })); @@ -25,6 +30,7 @@ vi.mock('../hooks/useKeypress.js', () => ({ })); const mockedUseConfig = vi.mocked(useConfig); +const mockedUseSettings = vi.mocked(useSettings); const mockedUseLaunchEditor = vi.mocked(useLaunchEditor); const mockedUseKeypress = vi.mocked(useKeypress); @@ -35,8 +41,11 @@ describe('MemoryDialog', () => { mockedUseConfig.mockReturnValue({ getWorkingDir: vi.fn(() => '/tmp/project'), getProjectRoot: vi.fn(() => '/tmp/project'), + getManagedAutoMemoryEnabled: vi.fn(() => false), + getManagedAutoDreamEnabled: vi.fn(() => false), } as never); + mockedUseSettings.mockReturnValue({ setValue: vi.fn() } as never); mockedUseLaunchEditor.mockReturnValue(vi.fn()); }); diff --git a/packages/cli/src/ui/components/MemoryDialog.tsx b/packages/cli/src/ui/components/MemoryDialog.tsx index 46622468b13..dd7fab733da 100644 --- a/packages/cli/src/ui/components/MemoryDialog.tsx +++ b/packages/cli/src/ui/components/MemoryDialog.tsx @@ -13,11 +13,15 @@ import { spawnSync } from 'node:child_process'; import { getAllGeminiMdFilenames, QWEN_DIR, + getAutoMemoryRoot, } from '@qwen-code/qwen-code-core'; import { useConfig } from '../contexts/ConfigContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { SettingScope } from '../../config/settings.js'; import { useLaunchEditor } from '../hooks/useLaunchEditor.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; +import { formatRelativeTime } from '../utils/formatters.js'; import { t } from '../../i18n/index.js'; type MemoryDialogTarget = 'project' | 'global' | 'managed'; @@ -29,12 +33,7 @@ interface MemoryDialogProps { interface DialogItem { label: string; value: MemoryDialogTarget; - description: string; -} - -interface MemoryStatusState { - lastExtractionAt?: string; - lastDreamAt?: string; + description?: string; } async function resolvePreferredMemoryFile( @@ -103,36 +102,43 @@ function formatDisplayPath(filePath: string): string { return filePath; } -function formatStatusTime(iso?: string): string { - if (!iso) { - return t('never'); - } - - const date = new Date(iso); - if (Number.isNaN(date.getTime())) { - return t('never'); - } - - return date.toLocaleString(); -} - export function MemoryDialog({ onClose }: MemoryDialogProps) { const config = useConfig(); + const loadedSettings = useSettings(); const launchEditor = useLaunchEditor(); const [error, setError] = useState(null); - const [status, setStatus] = useState({}); const [highlightedIndex, setHighlightedIndex] = useState(0); + // 'autoMemory' | 'autoDream' = focus on that toggle row; 'list' = focus on the file list + const [focusedSection, setFocusedSection] = useState< + 'autoMemory' | 'autoDream' | 'list' + >('list'); + const [autoMemoryOn, setAutoMemoryOn] = useState(() => + config.getManagedAutoMemoryEnabled(), + ); + const [autoDreamOn, setAutoDreamOn] = useState(() => + config.getManagedAutoDreamEnabled(), + ); + const [lastDreamAt, setLastDreamAt] = useState(null); const globalMemoryPath = useMemo( - () => path.join(os.homedir(), QWEN_DIR, getAllGeminiMdFilenames()[0] ?? 'QWEN.md'), + () => + path.join( + os.homedir(), + QWEN_DIR, + getAllGeminiMdFilenames()[0] ?? 'QWEN.md', + ), [], ); const projectMemoryPath = useMemo( - () => path.join(config.getWorkingDir(), getAllGeminiMdFilenames()[0] ?? 'QWEN.md'), + () => + path.join( + config.getWorkingDir(), + getAllGeminiMdFilenames()[0] ?? 'QWEN.md', + ), [config], ); const managedMemoryPath = useMemo( - () => path.join(config.getProjectRoot(), '.qwen', 'memory'), + () => getAutoMemoryRoot(config.getProjectRoot()), [config], ); @@ -148,45 +154,51 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { { label: t('Project memory'), value: 'project', - description: t('Checked in at {{path}}', { - path: path.relative(config.getWorkingDir(), projectMemoryPath) || path.basename(projectMemoryPath), + description: t('Saved in {{path}}', { + path: + path.relative(config.getWorkingDir(), projectMemoryPath) || + path.basename(projectMemoryPath), }), }, { label: t('Open auto-memory folder'), value: 'managed', - description: t('Browse indexed memory files in {{path}}', { - path: path.relative(config.getWorkingDir(), managedMemoryPath) || '.qwen/memory', - }), }, ], - [config, globalMemoryPath, managedMemoryPath, projectMemoryPath], + [config, globalMemoryPath, projectMemoryPath], ); + // Load lastDreamAt from meta.json useEffect(() => { let cancelled = false; - async function loadStatus() { + async function loadMeta() { try { const metadataPath = path.join(managedMemoryPath, 'meta.json'); const content = await fs.readFile(metadataPath, 'utf-8'); - const parsed = JSON.parse(content) as MemoryStatusState; - if (!cancelled) { - setStatus(parsed); + const parsed = JSON.parse(content) as { lastDreamAt?: string }; + if (!cancelled && parsed.lastDreamAt) { + const ts = new Date(parsed.lastDreamAt).getTime(); + if (!Number.isNaN(ts)) { + setLastDreamAt(ts); + } } } catch { - if (!cancelled) { - setStatus({}); - } + // meta.json not found or invalid — keep null } } - void loadStatus(); + void loadMeta(); return () => { cancelled = true; }; }, [managedMemoryPath]); + const dreamStatusText = useMemo(() => { + if (lastDreamAt !== null) return formatRelativeTime(lastDreamAt); + return t('never'); + }, [lastDreamAt]); + const resolveTargetPath = useCallback( async (target: MemoryDialogTarget): Promise => { switch (target) { @@ -201,14 +213,12 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { getAllGeminiMdFilenames()[0] ?? 'QWEN.md', ); case 'managed': - return path.join( - config.getProjectRoot(), - '.qwen', - 'memory', - ); + return managedMemoryPath; + default: + return managedMemoryPath; } }, - [config], + [config, managedMemoryPath], ); const handleSelect = useCallback( @@ -235,6 +245,26 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { [launchEditor, onClose, resolveTargetPath], ); + const handleToggleAutoMemory = useCallback(() => { + const newValue = !autoMemoryOn; + loadedSettings.setValue( + SettingScope.Workspace, + 'memory.enableManagedAutoMemory', + newValue, + ); + setAutoMemoryOn(newValue); + }, [autoMemoryOn, loadedSettings]); + + const handleToggleAutoDream = useCallback(() => { + const newValue = !autoDreamOn; + loadedSettings.setValue( + SettingScope.Workspace, + 'memory.enableManagedAutoDream', + newValue, + ); + setAutoDreamOn(newValue); + }, [autoDreamOn, loadedSettings]); + useKeypress( (key) => { if (key.name === 'escape') { @@ -242,10 +272,42 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { return; } + if (focusedSection === 'autoMemory') { + if (key.name === 'down') { + setFocusedSection('autoDream'); + return; + } + if (key.name === 'return') { + handleToggleAutoMemory(); + return; + } + return; + } + + if (focusedSection === 'autoDream') { + if (key.name === 'up') { + setFocusedSection('autoMemory'); + return; + } + if (key.name === 'down') { + setFocusedSection('list'); + setHighlightedIndex(0); + return; + } + if (key.name === 'return') { + handleToggleAutoDream(); + return; + } + return; + } + + // focusedSection === 'list' if (key.name === 'up') { - setHighlightedIndex((current) => - current === 0 ? items.length - 1 : current - 1, - ); + if (highlightedIndex === 0) { + setFocusedSection('autoDream'); + } else { + setHighlightedIndex((current) => current - 1); + } return; } @@ -279,16 +341,29 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { width="100%" > {t('Memory')} + - - {t('Auto-memory: on · Last write {{time}}', { - time: formatStatusTime(status.lastExtractionAt), + + {focusedSection === 'autoMemory' ? '› ' : ' '} + {t('Auto-memory: {{status}}', { + status: autoMemoryOn ? t('on') : t('off'), })} - - {t('Auto-dream: on · Last run {{time}}', { - time: formatStatusTime(status.lastDreamAt), - })} + + {focusedSection === 'autoDream' ? '› ' : ' '} + {`Auto-dream: ${autoDreamOn ? 'on' : 'off'} · ${dreamStatusText} · /dream to run`} @@ -300,20 +375,23 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { {items.map((item, index) => { - const isSelected = index === highlightedIndex; + const isSelected = + focusedSection === 'list' && index === highlightedIndex; return ( - + - {`${isSelected ? '›' : ' '} ${index + 1}. ${item.label}`} + {isSelected ? '› ' : ' '} + {index + 1}. {item.label} - - {item.description} - + {item.description ? ( + {` ${item.description}`} + ) : null} ); })} - + + {t('Enter to confirm · Esc to cancel')} diff --git a/packages/cli/src/ui/components/messages/MemorySavedMessage.tsx b/packages/cli/src/ui/components/messages/MemorySavedMessage.tsx new file mode 100644 index 00000000000..7975cdb53e6 --- /dev/null +++ b/packages/cli/src/ui/components/messages/MemorySavedMessage.tsx @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import type { HistoryItemMemorySaved } from '../../types.js'; + +interface MemorySavedMessageProps { + item: HistoryItemMemorySaved; +} + +/** + * Displays a post-turn notification that managed-auto-memory files were written. + * Shown when: + * - The model directly wrote to memory files in-turn (via write_file / edit_file). + * - The background dream / extraction pipeline completed and touched memory files. + */ +export const MemorySavedMessage: React.FC = ({ + item, +}) => { + const verb = item.verb ?? 'Saved'; + const n = item.writtenCount; + const label = n === 1 ? 'memory' : 'memories'; + + return ( + + + + + + {verb} {n} {label} + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index a5931119b54..325d52963f7 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -6,7 +6,7 @@ import type React from 'react'; import { useMemo } from 'react'; -import { Box } from 'ink'; +import { Box, Text } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; @@ -24,6 +24,10 @@ interface ToolGroupMessageProps { activeShellPtyId?: number | null; embeddedShellFocused?: boolean; onShellInputSubmit?: (input: string) => void; + /** Pre-computed count of write ops to managed-auto-memory files. */ + memoryWriteCount?: number; + /** Pre-computed count of read ops from managed-auto-memory files. */ + memoryReadCount?: number; } // Main component renders the border and maps the tools using ToolMessage @@ -34,6 +38,8 @@ export const ToolGroupMessage: React.FC = ({ isFocused = true, activeShellPtyId, embeddedShellFocused, + memoryWriteCount, + memoryReadCount, }) => { const isEmbeddedShellFocused = embeddedShellFocused && @@ -102,6 +108,23 @@ export const ToolGroupMessage: React.FC = ({ borderColor={borderColor} gap={1} > + {/* Memory operation badge — shown when tool group contains memory reads/writes */} + {((memoryWriteCount ?? 0) > 0 || (memoryReadCount ?? 0) > 0) && (() => { + const parts: string[] = []; + if ((memoryReadCount ?? 0) > 0) { + const n = memoryReadCount!; + parts.push(`Recalled ${n} ${n === 1 ? 'memory' : 'memories'}`); + } + if ((memoryWriteCount ?? 0) > 0) { + const n = memoryWriteCount!; + parts.push(`Wrote ${n} ${n === 1 ? 'memory' : 'memories'}`); + } + return ( + + ● {parts.join(', ')} + + ); + })()} {toolCalls.map((tool) => { const isConfirming = toolAwaitingApproval?.callId === tool.callId; return ( diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index a9b0663c056..8e4385a3ea2 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -50,6 +50,7 @@ const MockedGeminiClientClass = vi.hoisted(() => this.startChat = mockStartChat; this.sendMessageStream = mockSendMessageStream; this.addHistory = vi.fn(); + this.consumePendingMemoryTaskPromises = vi.fn().mockReturnValue([]); this.getChatRecordingService = vi.fn().mockReturnValue({ recordThought: vi.fn(), initialize: vi.fn(), diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 5d39654b166..4717c65f5a1 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -216,13 +216,24 @@ export const useGeminiStream = ( async (completedToolCallsFromScheduler) => { // This onComplete is called when ALL scheduled tools for a given batch are done. if (completedToolCallsFromScheduler.length > 0) { + const projectRoot = config.getProjectRoot(); // Add the final state of these tools to the history for display. - addItem( - mapTrackedToolCallsToDisplay( - completedToolCallsFromScheduler as TrackedToolCall[], - ), - Date.now(), + const toolGroupDisplay = mapTrackedToolCallsToDisplay( + completedToolCallsFromScheduler as TrackedToolCall[], + projectRoot, ); + addItem(toolGroupDisplay, Date.now()); + + // If any in-turn tools wrote to managed-auto-memory files, emit a notification. + if (toolGroupDisplay.memoryWriteCount) { + addItem( + { + type: 'memory_saved', + writtenCount: toolGroupDisplay.memoryWriteCount, + } as HistoryItemWithoutId, + Date.now(), + ); + } // Handle tool response submission immediately when tools complete await handleCompletedTools( @@ -237,8 +248,10 @@ export const useGeminiStream = ( const pendingToolCallGroupDisplay = useMemo( () => - toolCalls.length ? mapTrackedToolCallsToDisplay(toolCalls) : undefined, - [toolCalls], + toolCalls.length + ? mapTrackedToolCallsToDisplay(toolCalls, config.getProjectRoot()) + : undefined, + [toolCalls, config], ); const activeToolPtyId = useMemo(() => { @@ -1255,6 +1268,26 @@ export const useGeminiStream = ( loopDetectedRef.current = false; handleLoopDetectedEvent(); } + + // After the turn completes, wire up notifications for any background + // dream / extraction tasks that were kicked off by the client. + if (geminiClient) { + const memoryTaskPromises = geminiClient.consumePendingMemoryTaskPromises(); + for (const p of memoryTaskPromises) { + void p.then((count) => { + if (count > 0) { + addItem( + { + type: 'memory_saved', + writtenCount: count, + verb: 'Updated', + } as HistoryItemWithoutId, + Date.now(), + ); + } + }); + } + } } catch (error: unknown) { if (error instanceof UnauthorizedError) { onAuthError('Session expired or is unauthorized.'); diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 966c6adff65..1791f96f055 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -23,7 +23,9 @@ import type { import { CoreToolScheduler, createDebugLogger, + isAutoMemPath, } from '@qwen-code/qwen-code-core'; +import * as path from 'node:path'; import { useCallback, useState, useMemo } from 'react'; import type { HistoryItemToolGroup, @@ -209,11 +211,32 @@ function mapCoreStatusToDisplayStatus(coreStatus: CoreStatus): ToolCallStatus { } } +/** + * Returns 'read' or 'write' if the tool call operates on a managed-auto-memory + * file; returns undefined otherwise. + */ +function detectMemoryOp( + toolName: string, + args: Record, + projectRoot: string, +): 'read' | 'write' | undefined { + const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'create_file']); + const READ_TOOLS = new Set(['read_file']); + const filePath = args?.['file_path'] as string | undefined; + if (!filePath) return undefined; + const resolved = path.resolve(filePath); + if (!isAutoMemPath(resolved, projectRoot)) return undefined; + if (WRITE_TOOLS.has(toolName)) return 'write'; + if (READ_TOOLS.has(toolName)) return 'read'; + return undefined; +} + /** * Transforms `TrackedToolCall` objects into `HistoryItemToolGroup` objects for UI display. */ export function mapToDisplay( toolOrTools: TrackedToolCall[] | TrackedToolCall, + projectRoot?: string, ): HistoryItemToolGroup { const toolCalls = Array.isArray(toolOrTools) ? toolOrTools : [toolOrTools]; @@ -243,6 +266,14 @@ export function mapToDisplay( name: displayName, description, renderOutputAsMarkdown, + isMemoryOp: + projectRoot && trackedCall.status !== 'error' + ? detectMemoryOp( + trackedCall.request.name, + trackedCall.request.args as Record, + projectRoot, + ) + : undefined, }; switch (trackedCall.status) { @@ -310,5 +341,7 @@ export function mapToDisplay( return { type: 'tool_group', tools: toolDisplays, + memoryWriteCount: toolDisplays.filter((t) => t.isMemoryOp === 'write').length || undefined, + memoryReadCount: toolDisplays.filter((t) => t.isMemoryOp === 'read').length || undefined, }; } diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 7f9b4c176cd..211f5c738ac 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -69,6 +69,8 @@ export interface IndividualToolCallDisplay { confirmationDetails: ToolCallConfirmationDetails | undefined; renderOutputAsMarkdown?: boolean; ptyId?: number; + /** If this tool call operated on a managed-auto-memory file, indicates whether it was a read or write. */ + isMemoryOp?: 'read' | 'write'; } export interface CompressionProps { @@ -182,9 +184,25 @@ export type HistoryItemQuit = HistoryItemBase & { duration: string; }; +/** + * Displayed after a turn when managed-auto-memory files were written + * (either in-turn by the model, or by the post-turn dream/extract pipeline). + */ +export type HistoryItemMemorySaved = HistoryItemBase & { + type: 'memory_saved'; + /** Number of memory files written / updated. */ + writtenCount: number; + /** Verb to display, e.g. 'Saved' or 'Updated'. Defaults to 'Saved'. */ + verb?: string; +}; + export type HistoryItemToolGroup = HistoryItemBase & { type: 'tool_group'; tools: IndividualToolCallDisplay[]; + /** Count of tool calls that wrote to managed-auto-memory files. Pre-computed for badge rendering. */ + memoryWriteCount?: number; + /** Count of tool calls that read from managed-auto-memory files. Pre-computed for badge rendering. */ + memoryReadCount?: number; }; export type HistoryItemUserShell = HistoryItemBase & { @@ -395,7 +413,8 @@ export type HistoryItemWithoutId = | HistoryItemArenaAgentComplete | HistoryItemArenaSessionComplete | HistoryItemInsightProgress - | HistoryItemBtw; + | HistoryItemBtw + | HistoryItemMemorySaved; export type HistoryItem = HistoryItemWithoutId & { id: number }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1587860e0db..bf9c5fec138 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -425,6 +425,8 @@ export interface ConfigParameters { enableHooks?: boolean; /** Enable managed auto-memory background extraction and dream. Defaults to true. */ enableManagedAutoMemory?: boolean; + /** Enable managed auto-dream consolidation separately from extraction. Defaults to true. */ + enableManagedAutoDream?: boolean; /** Hooks configuration from settings */ hooks?: Record; /** Hooks config settings (enabled, disabled list) */ @@ -600,6 +602,7 @@ export class Config { private readonly defaultFileEncoding: FileEncodingType | undefined; private readonly enableHooks: boolean; private readonly enableManagedAutoMemory: boolean; + private readonly enableManagedAutoDream: boolean; private readonly hooks?: Record; private readonly hooksConfig?: Record; private hookSystem?: HookSystem; @@ -767,6 +770,7 @@ export class Config { }); this.enableHooks = params.enableHooks ?? false; this.enableManagedAutoMemory = params.enableManagedAutoMemory ?? true; + this.enableManagedAutoDream = params.enableManagedAutoDream ?? true; this.hooks = params.hooks; this.hooksConfig = params.hooksConfig; } @@ -1805,6 +1809,10 @@ export class Config { return this.enableManagedAutoMemory; } + getManagedAutoDreamEnabled(): boolean { + return this.enableManagedAutoDream; + } + /** * Get the message bus instance. * Returns undefined if not set. diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index a69f5e84c55..bb9d7db54b3 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -126,6 +126,13 @@ export class GeminiClient { */ private hasFailedCompressionAttempt = false; + /** + * Promises for pending background memory tasks (dream / extract). + * Each promise resolves with a count of memory files touched (0 = nothing written). + * Consumed by the CLI via `consumePendingMemoryTaskPromises()`. + */ + private pendingMemoryTaskPromises: Array> = []; + constructor(private readonly config: Config) { this.loopDetector = new LoopDetectionService(config); } @@ -478,25 +485,50 @@ export class GeminiClient { const sessionId = this.config.getSessionId(); const history = this.getHistory(); - void scheduleAutoMemoryExtract({ + const extractPromise = scheduleAutoMemoryExtract({ projectRoot, sessionId, history, config: this.config, + }).then((result) => { + return result.touchedTopics.length; }).catch((error) => { debugLogger.warn( 'Failed to schedule managed auto-memory extraction.', error, ); + return 0; }); + this.pendingMemoryTaskPromises.push(extractPromise); - void scheduleManagedAutoMemoryDream({ + const dreamPromise = scheduleManagedAutoMemoryDream({ projectRoot, sessionId, config: this.config, + }).then((schedResult) => { + if (schedResult.status === 'scheduled' && schedResult.promise) { + return schedResult.promise.then((state) => { + const topics = state.metadata?.['touchedTopics'] as string[] | undefined; + return topics ? topics.length : 0; + }); + } + return 0; }).catch((error) => { debugLogger.warn('Failed to schedule managed auto-memory dream.', error); + return 0; }); + this.pendingMemoryTaskPromises.push(dreamPromise); + } + + /** + * Returns and clears the list of pending background memory task promises. + * Each promise resolves with the number of memory files touched (0 = nothing + * was written, caller should ignore). + */ + consumePendingMemoryTaskPromises(): Array> { + const promises = this.pendingMemoryTaskPromises; + this.pendingMemoryTaskPromises = []; + return promises; } async *sendMessageStream( diff --git a/packages/core/src/memory/dreamScheduler.ts b/packages/core/src/memory/dreamScheduler.ts index d3f044a2fa1..cf21dfecf59 100644 --- a/packages/core/src/memory/dreamScheduler.ts +++ b/packages/core/src/memory/dreamScheduler.ts @@ -40,6 +40,7 @@ export interface ManagedAutoMemoryDreamScheduleResult { status: 'scheduled' | 'skipped'; taskId?: string; skippedReason?: + | 'disabled' | 'same_session' | 'min_hours' | 'min_sessions' @@ -109,6 +110,12 @@ export class ManagedAutoMemoryDreamRuntime { async schedule( params: ScheduleManagedAutoMemoryDreamParams, ): Promise { + if (params.config && !params.config.getManagedAutoDreamEnabled()) { + return { + status: 'skipped', + skippedReason: 'disabled', + }; + } const now = params.now ?? new Date(); const minHoursBetweenDreams = params.minHoursBetweenDreams ?? DEFAULT_AUTO_DREAM_MIN_HOURS; diff --git a/packages/core/src/memory/paths.ts b/packages/core/src/memory/paths.ts index 99a9ba90580..5b6b7a00f4a 100644 --- a/packages/core/src/memory/paths.ts +++ b/packages/core/src/memory/paths.ts @@ -82,14 +82,14 @@ function findCanonicalGitRoot(startPath: string): string | null { * Defaults to `~/.qwen`; overridable via QWEN_CODE_MEMORY_BASE_DIR for tests. */ export function getMemoryBaseDir(): string { - if (process.env.QWEN_CODE_MEMORY_BASE_DIR) { - return process.env.QWEN_CODE_MEMORY_BASE_DIR; + if (process.env['QWEN_CODE_MEMORY_BASE_DIR']) { + return process.env['QWEN_CODE_MEMORY_BASE_DIR']; } return path.join(os.homedir(), QWEN_DIR); } export function getAutoMemoryRoot(projectRoot: string): string { - if (process.env.QWEN_CODE_MEMORY_LOCAL === '1') { + if (process.env['QWEN_CODE_MEMORY_LOCAL'] === '1') { return path.join(projectRoot, QWEN_DIR, AUTO_MEMORY_DIRNAME); } From 9987f05254f514ada631175f4bf1daa8d787f3ca Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 8 Apr 2026 15:16:01 +0800 Subject: [PATCH 23/56] fix(memory-ui): auto-approve memory writes, collapse memory tool groups, fix MEMORY.md path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem 1 - Auto-approve memory file operations: - write-file.ts: getDefaultPermission() checks isAutoMemPath; returns 'allow' for managed auto-memory files, 'ask' for all other files - edit.ts: same pattern Problem 2 - Feature 4 UX: collapse memory-only tool groups: - ToolGroupMessage: detect when all tool calls have isMemoryOp set (pure memory group) and all are complete; render compact '● Recalled/Wrote N memories (ctrl+o to expand)' instead of individual tool call rows - ctrl+o toggles expand/collapse when isFocused and group is memory-only - Mixed groups (memory + other tools) keep badge-at-top behaviour - Expanded state shows individual tool calls with '● Memory operations (ctrl+o to collapse)' header Problem 3 - MEMORY.md path mismatch: - prompt.ts: Step 2 now references full absolute path ${memoryDir}/MEMORY.md so the model writes to the correct location inside the memory directory, not to the parent project directory Fix tests: - write-file.test.ts: add getProjectRoot to mockConfigInternal - prompt.test.ts: update assertion to match full-path section header --- .../components/messages/ToolGroupMessage.tsx | 79 ++++++++++++++++++- packages/core/src/memory/prompt.test.ts | 2 +- packages/core/src/memory/prompt.ts | 6 +- packages/core/src/tools/edit.ts | 8 +- packages/core/src/tools/write-file.test.ts | 1 + packages/core/src/tools/write-file.ts | 8 +- .../schemas/settings.schema.json | 16 ++++ 7 files changed, 110 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 325d52963f7..8099e575fe0 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -5,8 +5,8 @@ */ import type React from 'react'; -import { useMemo } from 'react'; -import { Box, Text } from 'ink'; +import { useMemo, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; @@ -74,6 +74,34 @@ export const ToolGroupMessage: React.FC = ({ [toolCalls], ); + // Detect if this is a "memory-only" group (all tool calls are memory ops) + const isMemoryOnlyGroup = useMemo( + () => toolCalls.length > 0 && toolCalls.every((t) => t.isMemoryOp != null), + [toolCalls], + ); + + const allComplete = useMemo( + () => + toolCalls.every( + (t) => + t.status === ToolCallStatus.Success || + t.status === ToolCallStatus.Error, + ), + [toolCalls], + ); + + // Expand/collapse state for memory-only groups + const [isExpanded, setIsExpanded] = useState(false); + + useInput( + (_input, key) => { + if (key.ctrl && _input === 'o') { + setIsExpanded((prev) => !prev); + } + }, + { isActive: isFocused && isMemoryOnlyGroup && allComplete }, + ); + let countToolCallsWithResults = 0; for (const tool of toolCalls) { if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') { @@ -91,6 +119,43 @@ export const ToolGroupMessage: React.FC = ({ ) : undefined; + // For completed memory-only groups, show a compact summary instead of individual tool calls + if (isMemoryOnlyGroup && allComplete && !isExpanded) { + const readCount = memoryReadCount ?? 0; + const writeCount = memoryWriteCount ?? 0; + return ( + + {readCount > 0 && ( + + + {'● '} + + Recalled {readCount} {readCount === 1 ? 'memory' : 'memories'} + + (ctrl+o to expand) + + + )} + {writeCount > 0 && ( + + + {'● '} + + Wrote {writeCount} {writeCount === 1 ? 'memory' : 'memories'} + + (ctrl+o to expand) + + + )} + + ); + } + return ( = ({ borderColor={borderColor} gap={1} > - {/* Memory operation badge — shown when tool group contains memory reads/writes */} - {((memoryWriteCount ?? 0) > 0 || (memoryReadCount ?? 0) > 0) && (() => { + {/* Memory badge for mixed groups (some memory ops + other ops) */} + {!isMemoryOnlyGroup && ((memoryWriteCount ?? 0) > 0 || (memoryReadCount ?? 0) > 0) && (() => { const parts: string[] = []; if ((memoryReadCount ?? 0) > 0) { const n = memoryReadCount!; @@ -125,6 +190,12 @@ export const ToolGroupMessage: React.FC = ({ ); })()} + {/* Expanded memory-only group header */} + {isMemoryOnlyGroup && isExpanded && ( + + ● Memory operations (ctrl+o to collapse) + + )} {toolCalls.map((tool) => { const isConfirming = toolAwaitingApproval?.callId === tool.callId; return ( diff --git a/packages/core/src/memory/prompt.test.ts b/packages/core/src/memory/prompt.test.ts index e984a35213b..9f6c1e88225 100644 --- a/packages/core/src/memory/prompt.test.ts +++ b/packages/core/src/memory/prompt.test.ts @@ -27,7 +27,7 @@ describe('managed auto-memory prompt helpers', () => { '- [User Memory](user/terse.md) — User prefers terse responses.', ); - expect(prompt).toContain('## MEMORY.md'); + expect(prompt).toContain('## /tmp/project/.qwen/memory/MEMORY.md'); expect(prompt).toContain('[User Memory](user/terse.md)'); expect(prompt).toContain('User prefers terse responses.'); }); diff --git a/packages/core/src/memory/prompt.ts b/packages/core/src/memory/prompt.ts index 68f50e5f2b2..c584dafed8a 100644 --- a/packages/core/src/memory/prompt.ts +++ b/packages/core/src/memory/prompt.ts @@ -186,9 +186,9 @@ export function buildManagedAutoMemoryPrompt( '', ...MEMORY_FRONTMATTER_EXAMPLE, '', - `**Step 2** — add a pointer to that file in \`MEMORY.md\`. \`MEMORY.md\` is an index, not a memory — each entry should be one line, under ~150 characters: \`- [Title](file.md) — one-line hook\`. It has no frontmatter. Never write memory content directly into \`MEMORY.md\`.`, + `**Step 2** — add a pointer to that file in \`${memoryDir}/MEMORY.md\` (the full absolute path). This index file is an index, not a memory — each entry should be one line, under ~150 characters: \`- [Title](file.md) — one-line hook\`. It has no frontmatter. Never write memory content directly into \`${memoryDir}/MEMORY.md\`.`, '', - `- \`MEMORY.md\` is always loaded into your conversation context — lines after ${MAX_MANAGED_AUTO_MEMORY_INDEX_LINES} will be truncated, so keep the index concise`, + `- \`${memoryDir}/MEMORY.md\` is always loaded into your conversation context — lines after ${MAX_MANAGED_AUTO_MEMORY_INDEX_LINES} will be truncated, so keep the index concise`, '- Keep the name, description, and type fields in memory files up-to-date with the content', '- Organize memory semantically by topic, not chronologically.', '- Update or remove memories that turn out to be wrong or outdated.', @@ -203,7 +203,7 @@ export function buildManagedAutoMemoryPrompt( '- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.', '- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.', '', - '## MEMORY.md', + `## ${memoryDir}/MEMORY.md`, '', trimmed ? truncateManagedAutoMemoryIndex(trimmed) diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index e41ec3a552c..b3c6e3cc5fd 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -21,6 +21,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js'; import { isNodeError } from '../utils/errors.js'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; +import { isAutoMemPath } from '../memory/paths.js'; import { FileEncoding, needsUtf8Bom, @@ -272,9 +273,14 @@ class EditToolInvocation implements ToolInvocation { } /** - * Edit operations always need user confirmation (unless overridden by PM or ApprovalMode). + * Edit operations always need user confirmation, except for managed + * auto-memory files which are written autonomously by the model. */ async getDefaultPermission(): Promise { + const projectRoot = this.config.getProjectRoot(); + if (isAutoMemPath(path.resolve(this.params.file_path), projectRoot)) { + return 'allow'; + } return 'ask'; } diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 407d187b1b1..4faaa60c8fc 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -54,6 +54,7 @@ vi.mocked(IdeClient.getInstance).mockResolvedValue( const fsService = new StandardFileSystemService(); const mockConfigInternal = { getTargetDir: () => rootDir, + getProjectRoot: () => rootDir, getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), setApprovalMode: vi.fn(), getGeminiClient: vi.fn(), // Initialize as a plain mock function diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index caaa3aaa4a1..a6fcd947ba8 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -9,6 +9,7 @@ import path from 'node:path'; import * as Diff from 'diff'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; +import { isAutoMemPath } from '../memory/paths.js'; import type { FileDiff, ToolCallConfirmationDetails, @@ -101,9 +102,14 @@ class WriteFileToolInvocation extends BaseToolInvocation< } /** - * Write operations always need user confirmation. + * Write operations always need user confirmation, except for managed + * auto-memory files which are written autonomously by the model. */ override async getDefaultPermission(): Promise { + const projectRoot = this.config.getProjectRoot(); + if (isAutoMemPath(path.resolve(this.params.file_path), projectRoot)) { + return 'allow'; + } return 'ask'; } diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index c7f53048e1b..934624379ad 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -361,6 +361,22 @@ } } }, + "memory": { + "description": "Settings for managed auto-memory.", + "type": "object", + "properties": { + "enableManagedAutoMemory": { + "description": "Enable background extraction of memories from conversations.", + "type": "boolean", + "default": true + }, + "enableManagedAutoDream": { + "description": "Enable automatic consolidation (dream) of collected memories.", + "type": "boolean", + "default": true + } + } + }, "permissions": { "description": "Permission rules controlling tool usage. Rules are evaluated in priority order: deny > ask > allow.", "type": "object", From ece00d6ad05c6c5817605669680c4a05ea4d6890 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 8 Apr 2026 15:35:01 +0800 Subject: [PATCH 24/56] fix(memory-ui): fix duplicate notification, broken ctrl+o, and Edit tool detection - Remove duplicate 'Saved N memories' notification: the tool group badge already shows 'Wrote N memories'; the separate HistoryItemMemorySaved addItem after onComplete was double-counting. Keep only the background-task path (consumePendingMemoryTaskPromises). - Remove ctrl+o expand: Ink's Static area freezes items on first render and cannot respond to user input. useInput/useState(isExpanded) in a Static item is a no-op. Removed the dead code; memory-only groups now always render as the compact summary (no fake interactive hint). - Fix Edit tool detection: detectMemoryOp was checking for 'edit_file' but the real tool name constant is 'edit'. Also removed non-existent 'create_file' (write_file covers all writes). Now editing MEMORY.md is correctly identified as a memory write op, collapses to 'Wrote N memories', and is auto-approved. --- .../components/messages/ToolGroupMessage.tsx | 38 ++++--------------- packages/cli/src/ui/hooks/useGeminiStream.ts | 11 ------ .../cli/src/ui/hooks/useReactToolScheduler.ts | 2 +- 3 files changed, 8 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 8099e575fe0..dcb0eeeec3c 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -5,8 +5,8 @@ */ import type React from 'react'; -import { useMemo, useState } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { useMemo } from 'react'; +import { Box, Text } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; @@ -90,18 +90,6 @@ export const ToolGroupMessage: React.FC = ({ [toolCalls], ); - // Expand/collapse state for memory-only groups - const [isExpanded, setIsExpanded] = useState(false); - - useInput( - (_input, key) => { - if (key.ctrl && _input === 'o') { - setIsExpanded((prev) => !prev); - } - }, - { isActive: isFocused && isMemoryOnlyGroup && allComplete }, - ); - let countToolCallsWithResults = 0; for (const tool of toolCalls) { if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') { @@ -120,7 +108,7 @@ export const ToolGroupMessage: React.FC = ({ : undefined; // For completed memory-only groups, show a compact summary instead of individual tool calls - if (isMemoryOnlyGroup && allComplete && !isExpanded) { + if (isMemoryOnlyGroup && allComplete) { const readCount = memoryReadCount ?? 0; const writeCount = memoryWriteCount ?? 0; return ( @@ -132,23 +120,17 @@ export const ToolGroupMessage: React.FC = ({ > {readCount > 0 && ( - + {'● '} - - Recalled {readCount} {readCount === 1 ? 'memory' : 'memories'} - - (ctrl+o to expand) + Recalled {readCount} {readCount === 1 ? 'memory' : 'memories'} )} {writeCount > 0 && ( - + {'● '} - - Wrote {writeCount} {writeCount === 1 ? 'memory' : 'memories'} - - (ctrl+o to expand) + Wrote {writeCount} {writeCount === 1 ? 'memory' : 'memories'} )} @@ -190,12 +172,6 @@ export const ToolGroupMessage: React.FC = ({ ); })()} - {/* Expanded memory-only group header */} - {isMemoryOnlyGroup && isExpanded && ( - - ● Memory operations (ctrl+o to collapse) - - )} {toolCalls.map((tool) => { const isConfirming = toolAwaitingApproval?.callId === tool.callId; return ( diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 4717c65f5a1..9edee8b6c24 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -224,17 +224,6 @@ export const useGeminiStream = ( ); addItem(toolGroupDisplay, Date.now()); - // If any in-turn tools wrote to managed-auto-memory files, emit a notification. - if (toolGroupDisplay.memoryWriteCount) { - addItem( - { - type: 'memory_saved', - writtenCount: toolGroupDisplay.memoryWriteCount, - } as HistoryItemWithoutId, - Date.now(), - ); - } - // Handle tool response submission immediately when tools complete await handleCompletedTools( completedToolCallsFromScheduler as TrackedToolCall[], diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 1791f96f055..3991823d55d 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -220,7 +220,7 @@ function detectMemoryOp( args: Record, projectRoot: string, ): 'read' | 'write' | undefined { - const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'create_file']); + const WRITE_TOOLS = new Set(['write_file', 'edit']); const READ_TOOLS = new Set(['read_file']); const filePath = args?.['file_path'] as string | undefined; if (!filePath) return undefined; From 81d8d816bfea47fb0dd61a942624b1203cc38b34 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 8 Apr 2026 16:04:25 +0800 Subject: [PATCH 25/56] fix(dream): run /dream as a visible submit_prompt turn, not a silent background agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation ran an AgentHeadless background agent that could take 5+ minutes with zero UI feedback — user saw a blank screen for the entire duration and then at most one line of text. Fix: /dream now returns submit_prompt with the consolidation task prompt so it runs as a regular AI conversation turn. Tool calls (read_file, write_file, edit, grep_search, list_directory, glob) are immediately visible as collapsed tool groups as the model works through the memory files — identical UX to Claude Code. Also export buildConsolidationTaskPrompt from dreamAgentPlanner so dreamCommand can reuse the same detailed consolidation prompt that was already written. --- packages/cli/src/ui/commands/dreamCommand.ts | 28 ++++++++++--------- packages/core/src/memory/dreamAgentPlanner.ts | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/ui/commands/dreamCommand.ts b/packages/cli/src/ui/commands/dreamCommand.ts index 8c2360cad6b..179e9091a7e 100644 --- a/packages/cli/src/ui/commands/dreamCommand.ts +++ b/packages/cli/src/ui/commands/dreamCommand.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { runManagedAutoMemoryDream } from '@qwen-code/qwen-code-core'; +import { + getAutoMemoryRoot, + getProjectHash, + QWEN_DIR, + buildConsolidationTaskPrompt, +} from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; @@ -25,19 +30,16 @@ export const dreamCommand: SlashCommand = { }; } - const result = await runManagedAutoMemoryDream( - config.getProjectRoot(), - new Date(), - config, - ); + const projectRoot = config.getProjectRoot(); + const memoryRoot = getAutoMemoryRoot(projectRoot); + const projectHash = getProjectHash(projectRoot); + const transcriptDir = `${QWEN_DIR}/tmp/${projectHash}/chats`; + + const prompt = buildConsolidationTaskPrompt(memoryRoot, transcriptDir); + return { - type: 'message', - messageType: 'info', - content: result.systemMessage - ? `${result.systemMessage}\n${t('Deduplicated entries: {{count}}', { - count: String(result.dedupedEntries), - })}` - : t('Managed auto-memory dream found nothing to improve.'), + type: 'submit_prompt', + content: prompt, }; }, }; diff --git a/packages/core/src/memory/dreamAgentPlanner.ts b/packages/core/src/memory/dreamAgentPlanner.ts index d8ced166486..4e8b0a2174d 100644 --- a/packages/core/src/memory/dreamAgentPlanner.ts +++ b/packages/core/src/memory/dreamAgentPlanner.ts @@ -32,7 +32,7 @@ function getTranscriptDir(projectRoot: string): string { return `${QWEN_DIR}/tmp/${projectHash}/chats`; } -function buildConsolidationTaskPrompt( +export function buildConsolidationTaskPrompt( memoryRoot: string, transcriptDir: string, ): string { From 7898f597360b1000f0f832973ec35b74901e839b Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 8 Apr 2026 16:11:14 +0800 Subject: [PATCH 26/56] fix(memory): auto-allow ls/glob/grep on memory base directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add getMemoryBaseDir() to getDefaultPermission() allow list in ls.ts, glob.ts, and grep.ts — mirrors the existing pattern in read-file.ts. Without this, ListFiles/Glob/Grep on ~/.qwen/* would trigger an approval dialog, blocking /dream at its very first step. --- packages/core/src/tools/glob.ts | 8 ++++++-- packages/core/src/tools/grep.ts | 8 ++++++-- packages/core/src/tools/ls.ts | 4 +++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index ab6b6d80a1d..5e13e5b1a5d 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -10,7 +10,8 @@ import { glob, escape } from 'glob'; import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { ToolNames, ToolDisplayNames } from './tool-names.js'; -import { resolveAndValidatePath } from '../utils/paths.js'; +import { resolveAndValidatePath, isSubpath } from '../utils/paths.js'; +import { getMemoryBaseDir } from '../memory/paths.js'; import { type Config } from '../config/config.js'; import type { PermissionDecision } from '../permissions/types.js'; import { @@ -113,7 +114,10 @@ class GlobToolInvocation extends BaseToolInvocation< this.config.getTargetDir(), this.params.path, ); - if (workspaceContext.isPathWithinWorkspace(resolvedPath)) { + if ( + workspaceContext.isPathWithinWorkspace(resolvedPath) || + isSubpath(getMemoryBaseDir(), resolvedPath) + ) { return 'allow'; } return 'ask'; diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index 4f927a1675e..53500022f04 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -15,7 +15,8 @@ import { ToolNames, ToolDisplayNames } from './tool-names.js'; import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('GREP'); -import { resolveAndValidatePath } from '../utils/paths.js'; +import { resolveAndValidatePath, isSubpath } from '../utils/paths.js'; +import { getMemoryBaseDir } from '../memory/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { isGitRepository } from '../utils/gitUtils.js'; import type { Config } from '../config/config.js'; @@ -87,7 +88,10 @@ class GrepToolInvocation extends BaseToolInvocation< this.config.getTargetDir(), this.params.path, ); - if (workspaceContext.isPathWithinWorkspace(resolvedPath)) { + if ( + workspaceContext.isPathWithinWorkspace(resolvedPath) || + isSubpath(getMemoryBaseDir(), resolvedPath) + ) { return 'allow'; } return 'ask'; diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 2d228bff012..2c2c1eb1751 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -17,6 +17,7 @@ import { ToolErrorType } from './tool-error.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { Storage } from '../config/storage.js'; +import { getMemoryBaseDir } from '../memory/paths.js'; const debugLogger = createDebugLogger('LS'); @@ -132,7 +133,8 @@ class LSToolInvocation extends BaseToolInvocation { if ( workspaceContext.isPathWithinWorkspace(dirPath) || isSubpaths(userSkillsDirs, dirPath) || - isSubpath(userExtensionsDir, dirPath) + isSubpath(userExtensionsDir, dirPath) || + isSubpath(getMemoryBaseDir(), dirPath) ) { return 'allow'; } From 3ccba8a2a441eac1d9e4b9cd9f8eace14bf52e7b Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 8 Apr 2026 16:59:38 +0800 Subject: [PATCH 27/56] fix(background): prevent permission prompt hangs in background agents Match Claude Code's headless-agent intent: background memory agents must never block on interactive permission prompts. Wrap background runtime config so getApprovalMode() returns YOLO, ensuring any ask decision is auto-approved instead of hanging forever. Add regression test covering the wrapped approval mode. --- .../background/backgroundAgentRunner.test.ts | 39 ++++++++++++++++++- .../src/background/backgroundAgentRunner.ts | 19 ++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/core/src/background/backgroundAgentRunner.test.ts b/packages/core/src/background/backgroundAgentRunner.test.ts index 8791bba6924..023d75008ec 100644 --- a/packages/core/src/background/backgroundAgentRunner.test.ts +++ b/packages/core/src/background/backgroundAgentRunner.test.ts @@ -5,7 +5,8 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { AgentEventEmitter, AgentEventType, AgentTerminateMode } from '../agents/index.js'; +import { ApprovalMode } from '../config/config.js'; +import { AgentEventType, AgentTerminateMode, type AgentEventEmitter } from '../agents/index.js'; import { BackgroundAgentRunner } from './backgroundAgentRunner.js'; describe('BackgroundAgentRunner', () => { @@ -146,4 +147,40 @@ describe('BackgroundAgentRunner', () => { expect(result.status).toBe('cancelled'); expect(result.error).toContain('CANCELLED'); }); + + it('forces yolo approval mode for background agents so asks cannot hang', async () => { + const runtimeContext = { + getApprovalMode: vi.fn(() => 'default'), + } as never; + + const createMock = vi.fn().mockImplementation(async ( + _name, + wrappedRuntimeContext, + ) => { + expect(wrappedRuntimeContext).not.toBe(runtimeContext); + expect(wrappedRuntimeContext.getApprovalMode()).toBe(ApprovalMode.YOLO); + + return { + execute: vi.fn().mockResolvedValue(undefined), + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'Done', + }; + }); + + const runner = new BackgroundAgentRunner(undefined, undefined, undefined, createMock); + const result = await runner.run({ + taskType: 'background-agent', + title: 'Review code', + description: 'Run a background code review', + projectRoot: '/tmp/project', + name: 'code-reviewer', + runtimeContext, + taskPrompt: 'Review the recent code changes', + promptConfig: { systemPrompt: 'You are a reviewer.' }, + modelConfig: { model: 'qwen3-coder-plus' }, + runConfig: { max_turns: 3 }, + }); + + expect(result.status).toBe('completed'); + }); }); diff --git a/packages/core/src/background/backgroundAgentRunner.ts b/packages/core/src/background/backgroundAgentRunner.ts index c1378d07fae..992253408a0 100644 --- a/packages/core/src/background/backgroundAgentRunner.ts +++ b/packages/core/src/background/backgroundAgentRunner.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config } from '../config/config.js'; +import { ApprovalMode, type Config } from '../config/config.js'; import { AgentHeadless, AgentEventEmitter, @@ -71,6 +71,12 @@ type CreateAgentHeadlessFn = ( eventEmitter?: AgentEventEmitter, ) => Promise; +function createBackgroundConfig(config: Config): Config { + const backgroundConfig = Object.create(config) as Config; + backgroundConfig.getApprovalMode = () => ApprovalMode.YOLO; + return backgroundConfig; +} + export class BackgroundAgentRunner { readonly registry: BackgroundTaskRegistry; readonly drainer: BackgroundTaskDrainer; @@ -116,9 +122,18 @@ export class BackgroundAgentRunner { roundCount = Math.max(roundCount, nextRound); }); + // Background agents must never block on permission prompts — there is + // no user present to answer them. Wrap the config to force YOLO mode + // so any tool call that would return 'ask' is auto-approved instead of + // hanging the process indefinitely. This mirrors Claude Code's + // shouldAvoidPermissionPrompts: true pattern in createSubagentContext(). + // Safety boundary: toolConfig.tools already restricts the model to the + // declared tool set; prompt instructions constrain intended paths. + const backgroundConfig = createBackgroundConfig(request.runtimeContext); + const headless = await this.createAgentHeadless( request.name, - request.runtimeContext, + backgroundConfig, request.promptConfig, request.modelConfig, request.runConfig, From 37c8e6fcde4cbd7a3c87fdbb029cfdbc07fe2b06 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 8 Apr 2026 17:13:27 +0800 Subject: [PATCH 28/56] fix(memory): run auto extract through forked agent Make managed auto-memory extraction follow the Claude Code architecture: background extraction now uses a forked agent to read/write memory files directly, instead of planning patches and applying them with a separate filesystem pipeline. Keep the old patch/model path only as fallback if the forked agent fails. Add regression tests covering the new execution path and tool whitelist. --- packages/core/src/memory/extract.ts | 45 +++- packages/core/src/memory/extractAgent.test.ts | 50 ++++- .../src/memory/extractionAgentPlanner.test.ts | 87 +++++++- .../core/src/memory/extractionAgentPlanner.ts | 201 ++++++++++++++++++ 4 files changed, 371 insertions(+), 12 deletions(-) diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts index e753a4992f7..27aab90f4df 100644 --- a/packages/core/src/memory/extract.ts +++ b/packages/core/src/memory/extract.ts @@ -26,7 +26,10 @@ import { scanAutoMemoryTopicDocuments, type ScannedAutoMemoryDocument, } from './scan.js'; -import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; +import { + planAutoMemoryExtractionPatchesByAgent, + runAutoMemoryExtractionByAgent, +} from './extractionAgentPlanner.js'; import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; import { scheduleManagedAutoMemoryExtract } from './extractScheduler.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; @@ -534,6 +537,46 @@ export async function runAutoMemoryExtract(params: { transcript, currentCursor, ); + + if (params.config) { + try { + const agentResult = await runAutoMemoryExtractionByAgent( + params.config, + params.projectRoot, + slice.messages, + ); + + if (agentResult.touchedTopics.length > 0) { + await bumpMetadata( + params.projectRoot, + now, + params.sessionId, + agentResult.touchedTopics, + ); + await rebuildManagedAutoMemoryIndex(params.projectRoot); + } + + const cursor: AutoMemoryExtractCursor = { + sessionId: params.sessionId, + processedOffset: slice.nextProcessedOffset, + updatedAt: now.toISOString(), + }; + await writeExtractCursor(params.projectRoot, cursor); + + return { + patches: dedupeExtractPatches(agentResult.patches), + touchedTopics: agentResult.touchedTopics, + cursor, + systemMessage: agentResult.systemMessage, + }; + } catch (error) { + debugLogger.warn( + 'Forked-agent auto-memory extraction failed; falling back to patch-based extraction.', + error, + ); + } + } + const patches = await planAutoMemoryExtractPatches({ projectRoot: params.projectRoot, messages: slice.messages, diff --git a/packages/core/src/memory/extractAgent.test.ts b/packages/core/src/memory/extractAgent.test.ts index f1b4a935bbb..c848e0538cf 100644 --- a/packages/core/src/memory/extractAgent.test.ts +++ b/packages/core/src/memory/extractAgent.test.ts @@ -9,14 +9,19 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; +import { + planAutoMemoryExtractionPatchesByAgent, + runAutoMemoryExtractionByAgent, +} from './extractionAgentPlanner.js'; import { runAutoMemoryExtract } from './extract.js'; +import { getAutoMemoryRoot } from './paths.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { resetAutoMemoryStateForTests } from './state.js'; vi.mock('./extractionAgentPlanner.js', () => ({ planAutoMemoryExtractionPatchesByAgent: vi.fn(), + runAutoMemoryExtractionByAgent: vi.fn(), })); describe('auto-memory extraction with agent planner', () => { @@ -42,14 +47,38 @@ describe('auto-memory extraction with agent planner', () => { }); }); - it('applies agent-planned extraction patches when config is provided', async () => { - vi.mocked(planAutoMemoryExtractionPatchesByAgent).mockResolvedValue([ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 0, - }, - ]); + it('uses the forked-agent execution path when config is provided', async () => { + vi.mocked(runAutoMemoryExtractionByAgent).mockImplementation(async () => { + const memoryRoot = getAutoMemoryRoot(projectRoot); + const userPath = path.join(memoryRoot, 'user', 'terse-responses.md'); + await fs.mkdir(path.dirname(userPath), { recursive: true }); + await fs.writeFile( + userPath, + [ + '---', + 'name: Terse responses', + 'description: User prefers terse responses.', + 'type: user', + '---', + '', + '- User prefers terse responses.', + '', + ].join('\n'), + 'utf-8', + ); + + return { + patches: [ + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 0, + }, + ], + touchedTopics: ['user'], + systemMessage: 'Managed auto-memory updated: user.md', + }; + }); const result = await runAutoMemoryExtract({ projectRoot, @@ -64,11 +93,12 @@ describe('auto-memory extraction with agent planner', () => { }); expect(result.touchedTopics).toEqual(['user']); - expect(planAutoMemoryExtractionPatchesByAgent).toHaveBeenCalledWith( + expect(runAutoMemoryExtractionByAgent).toHaveBeenCalledWith( mockConfig, projectRoot, expect.any(Array), ); + expect(planAutoMemoryExtractionPatchesByAgent).not.toHaveBeenCalled(); const docs = await scanAutoMemoryTopicDocuments(projectRoot); expect(docs.find((doc) => doc.type === 'user')?.body).toContain( diff --git a/packages/core/src/memory/extractionAgentPlanner.test.ts b/packages/core/src/memory/extractionAgentPlanner.test.ts index 6a1e706b623..a2654776365 100644 --- a/packages/core/src/memory/extractionAgentPlanner.test.ts +++ b/packages/core/src/memory/extractionAgentPlanner.test.ts @@ -6,7 +6,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; +import { + planAutoMemoryExtractionPatchesByAgent, + runAutoMemoryExtractionByAgent, +} from './extractionAgentPlanner.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; vi.mock('./scan.js', async (importOriginal) => { @@ -125,3 +128,85 @@ describe('planAutoMemoryExtractionPatchesByAgent', () => { ).rejects.toThrow('Invalid extraction agent response: invalid sourceOffset'); }); }); + +describe('runAutoMemoryExtractionByAgent', () => { + const mockConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + getModel: vi.fn().mockReturnValue('qwen3-coder-plus'), + } as unknown as Config; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([ + { + type: 'user', + filePath: '/tmp/user.md', + relativePath: 'user.md', + filename: 'user.md', + title: 'User Memory', + description: 'User preferences', + body: '- Existing terse preference.', + mtimeMs: 1, + }, + ]); + }); + + it('returns parsed execution summary and enables write/edit tools', async () => { + const runner = { + run: vi.fn().mockResolvedValue({ + taskId: 'task-1', + status: 'completed', + finalText: JSON.stringify({ + patches: [ + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 0, + }, + ], + touchedTopics: ['user'], + }), + filesTouched: ['/tmp/user.md'], + }), + }; + + const result = await runAutoMemoryExtractionByAgent( + mockConfig, + '/tmp/project', + [{ offset: 0, role: 'user', text: 'I prefer terse responses.' }], + runner, + ); + + expect(result).toEqual({ + patches: [ + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 0, + why: undefined, + howToApply: undefined, + }, + ], + touchedTopics: ['user'], + systemMessage: 'Managed auto-memory updated: user.md', + }); + expect(runner.run).toHaveBeenCalledWith( + expect.objectContaining({ + toolConfig: { + tools: [ + 'read_file', + 'write_file', + 'edit', + 'list_directory', + 'glob', + 'grep_search', + ], + }, + runConfig: expect.objectContaining({ + max_turns: 5, + max_time_minutes: 2, + }), + }), + ); + }); +}); diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index 5c3a141cf5b..d95ad6cb03d 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -16,6 +16,7 @@ import { TYPES_SECTION_INDIVIDUAL, WHAT_NOT_TO_SAVE_SECTION, } from './prompt.js'; +import { AUTO_MEMORY_INDEX_FILENAME, getAutoMemoryRoot } from './paths.js'; import type { AutoMemoryType } from './types.js'; import type { AutoMemoryExtractPatch, @@ -83,10 +84,60 @@ const EXTRACTION_AGENT_RESPONSE_SCHEMA: Record = { required: ['patches'], }; +const EXTRACTION_AGENT_EXECUTION_RESPONSE_SCHEMA: Record = { + type: 'object', + properties: { + patches: { + type: 'array', + items: { + type: 'object', + properties: { + topic: { + type: 'string', + enum: ['user', 'feedback', 'project', 'reference'], + }, + summary: { + type: 'string', + }, + why: { + type: 'string', + }, + howToApply: { + type: 'string', + }, + sourceOffset: { + type: 'integer', + }, + }, + required: ['topic', 'summary', 'sourceOffset'], + }, + }, + touchedTopics: { + type: 'array', + items: { + type: 'string', + enum: ['user', 'feedback', 'project', 'reference'], + }, + }, + }, + required: ['patches', 'touchedTopics'], +}; + interface ExtractionAgentResponse { patches: AutoMemoryExtractPatch[]; } +interface ExtractionAgentExecutionResponse { + patches: AutoMemoryExtractPatch[]; + touchedTopics: AutoMemoryType[]; +} + +export interface AutoMemoryExtractionExecutionResult { + patches: AutoMemoryExtractPatch[]; + touchedTopics: AutoMemoryType[]; + systemMessage?: string; +} + interface BackgroundAgentRunnerLike { run(request: Parameters[0]): Promise; } @@ -143,6 +194,43 @@ function buildTaskPrompt( ].join('\n'); } +function buildExecutionTaskPrompt( + memoryRoot: string, + messages: AutoMemoryTranscriptMessage[], + topicSummaries: string, +): string { + return [ + `Managed memory directory: \`${memoryRoot}\``, + '', + 'You must update durable managed memory by directly using tools to read and write files inside the managed memory directory.', + '', + 'Available tools in this run: `read_file`, `list_directory`, `glob`, `grep_search`, `write_file`, `edit`.', + '- Do not use any other tools.', + '- Do not inspect repository code, git history, or unrelated files.', + '- Work only from the transcript slice below plus the current managed memory files.', + '- Prefer updating an existing memory file over creating a duplicate.', + '- If you create or delete a memory file, also update the managed memory index.', + `- The managed memory index is \`${memoryRoot}/${AUTO_MEMORY_INDEX_FILENAME}\`.`, + '- Keep one durable memory per file under `user/`, `feedback/`, `project/`, or `reference/`.', + '- If nothing durable should be saved, make no file changes.', + '', + 'Memory file format reference:', + ...MEMORY_FRONTMATTER_EXAMPLE, + '', + ...TYPES_SECTION_INDIVIDUAL, + ...WHAT_NOT_TO_SAVE_SECTION, + '', + 'After all tool work is complete, output JSON only matching this schema:', + JSON.stringify(EXTRACTION_AGENT_EXECUTION_RESPONSE_SCHEMA, null, 2), + '', + 'Transcript slice:', + buildTranscriptBlock(messages), + '', + 'Current topic summaries:', + topicSummaries || '(no topics found)', + ].join('\n'); +} + function validateExtractionAgentResponse( parsed: ExtractionAgentResponse, userOffsets: Set, @@ -175,6 +263,41 @@ function validateExtractionAgentResponse( })); } +function validateExtractionExecutionResponse( + parsed: ExtractionAgentExecutionResponse, + userOffsets: Set, +): AutoMemoryExtractionExecutionResult { + const schemaError = SchemaValidator.validate( + EXTRACTION_AGENT_EXECUTION_RESPONSE_SCHEMA, + parsed, + ); + if (schemaError) { + throw new Error(`Invalid extraction agent response: ${schemaError}`); + } + + const patches = validateExtractionAgentResponse(parsed, userOffsets); + const touchedTopics = Array.from( + new Set( + (parsed.touchedTopics ?? []).filter( + (topic): topic is AutoMemoryType => + topic === 'user' || + topic === 'feedback' || + topic === 'project' || + topic === 'reference', + ), + ), + ); + + return { + patches, + touchedTopics, + systemMessage: + touchedTopics.length > 0 + ? `Managed auto-memory updated: ${touchedTopics.map((topic) => `${topic}.md`).join(', ')}` + : undefined, + }; +} + export async function planAutoMemoryExtractionPatchesByAgent( config: Config, projectRoot: string, @@ -234,3 +357,81 @@ export async function planAutoMemoryExtractionPatchesByAgent( }); return validateExtractionAgentResponse(parsed, userOffsets); } + +export async function runAutoMemoryExtractionByAgent( + config: Config, + projectRoot: string, + messages: AutoMemoryTranscriptMessage[], + runner: BackgroundAgentRunnerLike = new BackgroundAgentRunner(), +): Promise { + if (messages.length === 0) { + return { + patches: [], + touchedTopics: [], + }; + } + + const userOffsets = new Set( + messages + .filter((message) => message.role === 'user') + .map((message) => message.offset), + ); + if (userOffsets.size === 0) { + return { + patches: [], + touchedTopics: [], + }; + } + + const topicSummaries = await buildTopicSummaryBlock(projectRoot); + const memoryRoot = getAutoMemoryRoot(projectRoot); + const result = await runner.run({ + taskType: 'managed-auto-memory-extraction-agent', + title: 'Managed auto-memory extraction agent', + description: 'Extract durable managed memory directly into topic files.', + projectRoot, + sessionId: config.getSessionId(), + dedupeKey: `managed-auto-memory-extraction-agent:${projectRoot}`, + name: 'managed-auto-memory-extractor', + runtimeContext: config, + taskPrompt: buildExecutionTaskPrompt(memoryRoot, messages, topicSummaries), + promptConfig: { + systemPrompt: EXTRACTION_AGENT_SYSTEM_PROMPT, + }, + modelConfig: { + model: config.getModel(), + temp: 0, + }, + runConfig: { + max_turns: 5, + max_time_minutes: 2, + }, + toolConfig: { + tools: [ + 'read_file', + 'write_file', + 'edit', + 'list_directory', + 'glob', + 'grep_search', + ], + }, + metadata: { + planner: 'extraction-agent', + stage: 'apply', + }, + }); + + if (result.status !== 'completed' || !result.finalText) { + throw new Error(result.error || 'Extraction agent did not complete successfully'); + } + + const parsed = safeJsonParse( + result.finalText, + { + patches: [], + touchedTopics: [], + }, + ); + return validateExtractionExecutionResponse(parsed, userOffsets); +} From 1737e2775d775b2e25d039cd0acac9fb85250681 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 8 Apr 2026 17:33:54 +0800 Subject: [PATCH 29/56] refactor(memory): remove legacy extract fallback pipeline Delete the old patch/model/heuristic extraction path entirely. Managed auto-memory extract now runs only through the forked-agent execution flow, with no planner/apply fallback stages remaining. Also remove obsolete exports/tests and update scheduler/integration coverage to use the forked-agent-only architecture. --- packages/core/src/index.ts | 1 - packages/core/src/memory/extract.test.ts | 102 ++-- packages/core/src/memory/extract.ts | 466 +++--------------- packages/core/src/memory/extractAgent.test.ts | 7 +- packages/core/src/memory/extractModel.test.ts | 113 ----- .../core/src/memory/extractScheduler.test.ts | 61 ++- .../src/memory/extractionAgentPlanner.test.ts | 114 +---- .../core/src/memory/extractionAgentPlanner.ts | 153 +----- .../core/src/memory/extractionPlanner.test.ts | 120 ----- packages/core/src/memory/extractionPlanner.ts | 180 ------- .../memoryLifecycle.integration.test.ts | 92 +++- 11 files changed, 271 insertions(+), 1138 deletions(-) delete mode 100644 packages/core/src/memory/extractModel.test.ts delete mode 100644 packages/core/src/memory/extractionPlanner.test.ts delete mode 100644 packages/core/src/memory/extractionPlanner.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4ffd0a287cf..38e3e57b8ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -128,7 +128,6 @@ export * from './memory/indexer.js'; export * from './memory/prompt.js'; export * from './memory/state.js'; export * from './memory/extractionAgentPlanner.js'; -export * from './memory/extractionPlanner.js'; export * from './memory/extract.js'; export * from './memory/extractScheduler.js'; export * from './memory/dreamAgentPlanner.js'; diff --git a/packages/core/src/memory/extract.test.ts b/packages/core/src/memory/extract.test.ts index 15078ae7534..357461a064c 100644 --- a/packages/core/src/memory/extract.test.ts +++ b/packages/core/src/memory/extract.test.ts @@ -7,28 +7,38 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; import { getAutoMemoryExtractCursorPath, getAutoMemoryIndexPath } from './paths.js'; import { - applyExtractedMemoryPatches, buildTranscriptMessages, - extractMemoryPatchesFromTranscript, loadUnprocessedTranscriptSlice, runAutoMemoryExtract, } from './extract.js'; +import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { resetAutoMemoryStateForTests } from './state.js'; +vi.mock('./extractionAgentPlanner.js', () => ({ + runAutoMemoryExtractionByAgent: vi.fn(), +})); + describe('auto-memory extraction', () => { let tempDir: string; let projectRoot: string; + let mockConfig: Config; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-extract-')); projectRoot = path.join(tempDir, 'project'); await fs.mkdir(projectRoot, { recursive: true }); await ensureAutoMemoryScaffold(projectRoot); + mockConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + getModel: vi.fn().mockReturnValue('qwen3-coder-plus'), + } as unknown as Config; + vi.clearAllMocks(); }); afterEach(async () => { @@ -63,52 +73,54 @@ describe('auto-memory extraction', () => { expect(slice.nextProcessedOffset).toBe(3); }); - it('extracts and applies durable memory patches', async () => { - const transcript = buildTranscriptMessages([ - { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, - { - role: 'user', - parts: [{ text: 'The latency dashboard is https://grafana.internal/d/api-latency' }], - }, - ]); - - const patches = extractMemoryPatchesFromTranscript(transcript); - expect(patches.map((patch) => patch.topic)).toEqual(['user', 'reference']); - - const touched = await applyExtractedMemoryPatches(projectRoot, patches); - expect(touched).toEqual(['user', 'reference']); - - const index = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - const userDoc = docs.find((doc) => doc.type === 'user'); - const referenceDoc = docs.find((doc) => doc.type === 'reference'); + it('dedupes agent-reported patches while preserving touched topics', async () => { + vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({ + patches: [ + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 0, + }, + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 0, + }, + ], + touchedTopics: ['user'], + systemMessage: 'Managed auto-memory updated: user.md', + }); - expect(userDoc?.body).toContain('I prefer terse responses.'); - expect(referenceDoc?.body).toContain('grafana.internal/d/api-latency'); - expect(index).toContain('I prefer terse responses.'); - expect(index).toContain('grafana.internal/d/api-latency'); - }); + const result = await runAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + config: mockConfig, + history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], + }); - it('writes why and how-to-apply fields when extraction patches include them', async () => { - const touched = await applyExtractedMemoryPatches(projectRoot, [ + expect(result.patches).toEqual([ { topic: 'user', summary: 'User prefers terse responses.', - why: 'They explicitly asked for concise replies.', - howToApply: 'Lead with a short answer before details.', sourceOffset: 0, }, ]); - - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - const userDoc = docs.find((doc) => doc.type === 'user'); - - expect(touched).toEqual(['user']); - expect(userDoc?.body).toContain('Why: They explicitly asked for concise replies.'); - expect(userDoc?.body).toContain('How to apply: Lead with a short answer before details.'); + expect(result.touchedTopics).toEqual(['user']); }); it('updates cursor and avoids duplicate writes for repeated extraction', async () => { + vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({ + patches: [ + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 0, + }, + ], + touchedTopics: [], + systemMessage: undefined, + }); + const history = [ { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, { role: 'model', parts: [{ text: 'Understood.' }] }, @@ -117,15 +129,17 @@ describe('auto-memory extraction', () => { const first = await runAutoMemoryExtract({ projectRoot, sessionId: 'session-1', + config: mockConfig, history: [...history], }); const second = await runAutoMemoryExtract({ projectRoot, sessionId: 'session-1', + config: mockConfig, history: [...history], }); - expect(first.touchedTopics).toEqual(['user']); + expect(first.touchedTopics).toEqual([]); expect(second.touchedTopics).toEqual([]); const cursor = JSON.parse( @@ -135,4 +149,14 @@ describe('auto-memory extraction', () => { expect(cursor.sessionId).toBe('session-1'); expect(cursor.processedOffset).toBe(2); }); + + it('throws when config is missing because heuristic fallback was removed', async () => { + await expect( + runAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], + }), + ).rejects.toThrow('Managed auto-memory extraction requires config'); + }); }); \ No newline at end of file diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts index 27aab90f4df..8a2170a63ee 100644 --- a/packages/core/src/memory/extract.ts +++ b/packages/core/src/memory/extract.ts @@ -5,32 +5,16 @@ */ import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { partToString } from '../utils/partUtils.js'; import { getAutoMemoryExtractCursorPath, - getAutoMemoryFilePath, getAutoMemoryMetadataPath, } from './paths.js'; import { ensureAutoMemoryScaffold } from './store.js'; -import { - mergeAutoMemoryEntry, - parseAutoMemoryEntries, - renderAutoMemoryBody, -} from './entries.js'; -import { - parseAutoMemoryTopicDocument, - scanAutoMemoryTopicDocuments, - type ScannedAutoMemoryDocument, -} from './scan.js'; -import { - planAutoMemoryExtractionPatchesByAgent, - runAutoMemoryExtractionByAgent, -} from './extractionAgentPlanner.js'; -import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; +import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { scheduleManagedAutoMemoryExtract } from './extractScheduler.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; import { @@ -68,152 +52,12 @@ function normalizeSummary(text: string): string { return text.replace(/\s+/g, ' ').trim(); } -function slugify(text: string): string { - return ( - text - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 80) || 'memory' - ); -} - -function buildMemoryTitle(summary: string): string { - const trimmed = normalizeSummary(summary); - if (trimmed.length <= 72) { - return trimmed; - } - return `${trimmed.slice(0, 69).trimEnd()}...`; -} - -function stripRememberLead(text: string): string { - return text - .replace(/^please\s+/i, '') - .replace(/^(remember|save|note)\s+(that\s+)?/i, '') - .replace(/^[:\-\s]+/, '') - .trim(); -} - function isTemporaryTask(text: string): boolean { return /\b(today|now|currently|for this task|this session|temporary|temporarily)\b/i.test( text, ); } -function classifyTopic(text: string): AutoMemoryType | null { - if (/https?:\/\/|\b(grafana|dashboard|runbook|ticket|docs?|wiki|notion|jira)\b/i.test(text)) { - return 'reference'; - } - if (/\b(i|we)\s+(prefer|like|need|want)\b|\bmy\s+(preferred|favorite)\b/i.test(text)) { - return 'user'; - } - if (/\b(please|always|never|avoid|respond|format|style|terse|concise|detailed)\b/i.test(text)) { - return 'feedback'; - } - if (/\b(project|repo|repository|service|release|deadline|freeze|incident|environment|stack)\b/i.test(text)) { - return 'project'; - } - return null; -} - -function extractCandidateSummary(text: string): string | null { - const trimmed = normalizeSummary(text); - if (trimmed.length < MIN_CANDIDATE_LENGTH || trimmed.endsWith('?')) { - return null; - } - - if (isTemporaryTask(trimmed)) { - return null; - } - - const explicitRemember = trimmed.match( - /^(?:please\s+)?(?:remember|save|note)\s+(?:that\s+)?(.+)$/i, - ); - if (explicitRemember?.[1]) { - return normalizeSummary(stripRememberLead(explicitRemember[1])); - } - - if ( - /\b(i|we)\s+(prefer|like|need|want)\b/i.test(trimmed) || - /\bmy\s+(preferred|favorite)\b/i.test(trimmed) || - /https?:\/\//i.test(trimmed) || - /\b(grafana|dashboard|runbook|ticket|docs?|wiki|notion|jira|release|deadline|freeze|incident)\b/i.test(trimmed) - ) { - return trimmed; - } - - if (/\b(please|always|never|avoid|respond)\b/i.test(trimmed)) { - return trimmed; - } - - return null; -} - -export function buildTranscriptMessages( - history: Content[], -): AutoMemoryTranscriptMessage[] { - return history - .map((message, index) => ({ - offset: index, - role: message.role, - text: normalizeSummary(partToString(message.parts ?? [])), - })) - .filter( - (message): message is AutoMemoryTranscriptMessage => - (message.role === 'user' || message.role === 'model') && - message.text.length > 0, - ); -} - -export function loadUnprocessedTranscriptSlice( - sessionId: string, - messages: AutoMemoryTranscriptMessage[], - cursor: AutoMemoryExtractCursor, -): { messages: AutoMemoryTranscriptMessage[]; nextProcessedOffset: number } { - const startOffset = cursor.sessionId === sessionId ? cursor.processedOffset ?? 0 : 0; - return { - messages: messages.filter((message) => message.offset >= startOffset), - nextProcessedOffset: messages.length, - }; -} - -export function extractMemoryPatchesFromTranscript( - messages: AutoMemoryTranscriptMessage[], -): AutoMemoryExtractPatch[] { - const seen = new Set(); - const patches: AutoMemoryExtractPatch[] = []; - - for (const message of messages) { - if (message.role !== 'user') { - continue; - } - - const summary = extractCandidateSummary(message.text); - if (!summary) { - continue; - } - - const topic = classifyTopic(summary); - if (!topic) { - continue; - } - - const dedupeKey = `${topic}:${summary.toLowerCase()}`; - if (seen.has(dedupeKey)) { - continue; - } - seen.add(dedupeKey); - - patches.push({ - topic, - summary, - sourceOffset: message.offset, - }); - } - - return patches; -} - function normalizeExtractPatch( patch: AutoMemoryExtractPatch, ): AutoMemoryExtractPatch | null { @@ -262,46 +106,33 @@ function dedupeExtractPatches( return deduped; } -async function planAutoMemoryExtractPatches(params: { - projectRoot: string; - messages: AutoMemoryTranscriptMessage[]; - config?: Config; -}): Promise { - if (params.messages.length === 0) { - return []; - } - - if (params.config) { - try { - const plannedPatches = await planAutoMemoryExtractionPatchesByAgent( - params.config, - params.projectRoot, - params.messages, - ); - return dedupeExtractPatches(plannedPatches); - } catch (error) { - debugLogger.warn( - 'Agent-driven auto-memory extraction failed; falling back to side-query extraction.', - error, - ); - } - - try { - const plannedPatches = await planAutoMemoryExtractionPatchesByModel( - params.config, - params.projectRoot, - params.messages, - ); - return dedupeExtractPatches(plannedPatches); - } catch (error) { - debugLogger.warn( - 'Model-driven auto-memory extraction failed; falling back to heuristic extraction.', - error, - ); - } - } +export function buildTranscriptMessages( + history: Content[], +): AutoMemoryTranscriptMessage[] { + return history + .map((message, index) => ({ + offset: index, + role: message.role, + text: normalizeSummary(partToString(message.parts ?? [])), + })) + .filter( + (message): message is AutoMemoryTranscriptMessage => + (message.role === 'user' || message.role === 'model') && + message.text.length > 0, + ); +} - return dedupeExtractPatches(extractMemoryPatchesFromTranscript(params.messages)); +export function loadUnprocessedTranscriptSlice( + sessionId: string, + messages: AutoMemoryTranscriptMessage[], + cursor: AutoMemoryExtractCursor, +): { messages: AutoMemoryTranscriptMessage[]; nextProcessedOffset: number } { + const startOffset = + cursor.sessionId === sessionId ? cursor.processedOffset ?? 0 : 0; + return { + messages: messages.filter((message) => message.offset >= startOffset), + nextProcessedOffset: messages.length, + }; } async function readExtractCursor( @@ -340,7 +171,10 @@ async function bumpMetadata( touchedTopics: AutoMemoryType[], ): Promise { try { - const content = await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'); + const content = await fs.readFile( + getAutoMemoryMetadataPath(projectRoot), + 'utf-8', + ); const metadata = JSON.parse(content) as AutoMemoryMetadata; metadata.updatedAt = now.toISOString(); metadata.lastExtractionAt = now.toISOString(); @@ -357,169 +191,6 @@ async function bumpMetadata( } } -function appendPatchToTopicContent( - content: string, - patch: AutoMemoryExtractPatch, -): string | null { - const parsed = parseAutoMemoryTopicDocument('/virtual/topic.md', content); - if (!parsed) { - return null; - } - - const entries = parseAutoMemoryEntries(parsed.body); - const normalizedSummary = patch.summary.toLowerCase(); - const existingIndex = entries.findIndex( - (entry) => entry.summary.toLowerCase() === normalizedSummary, - ); - - if (existingIndex >= 0) { - const merged = mergeAutoMemoryEntry(entries[existingIndex], { - summary: patch.summary, - why: patch.why, - howToApply: patch.howToApply, - }); - const current = entries[existingIndex]; - if ( - current.summary === merged.summary && - current.why === merged.why && - current.howToApply === merged.howToApply - ) { - return null; - } - - entries[existingIndex] = merged; - return content.replace(parsed.body, renderAutoMemoryBody('', entries)); - } - - entries.push({ - summary: patch.summary, - why: patch.why, - howToApply: patch.howToApply, - }); - - return content.replace(parsed.body, renderAutoMemoryBody('', entries)); -} - -function buildMemoryDocumentContent( - patch: AutoMemoryExtractPatch, - title = buildMemoryTitle(patch.summary), -): string { - return [ - '---', - `name: ${title}`, - `description: ${patch.summary}`, - `type: ${patch.topic}`, - '---', - '', - renderAutoMemoryBody('', [ - { - summary: patch.summary, - why: patch.why, - howToApply: patch.howToApply, - }, - ]), - '', - ].join('\n'); -} - -function findExistingMemoryDocument( - docs: ScannedAutoMemoryDocument[], - patch: AutoMemoryExtractPatch, -): ScannedAutoMemoryDocument | undefined { - const targetSummary = patch.summary.toLowerCase(); - return docs.find((doc) => { - if (doc.type !== patch.topic) { - return false; - } - const [entry] = parseAutoMemoryEntries(doc.body); - return entry?.summary.toLowerCase() === targetSummary; - }); -} - -function allocateMemoryRelativePath( - docs: ScannedAutoMemoryDocument[], - patch: AutoMemoryExtractPatch, -): string { - const baseSlug = slugify(patch.summary); - const used = new Set(docs.map((doc) => doc.relativePath)); - - for (let index = 0; index < 100; index += 1) { - const filename = index === 0 ? `${baseSlug}.md` : `${baseSlug}-${index + 1}.md`; - const relativePath = path.join(patch.topic, filename); - if (!used.has(relativePath)) { - return relativePath; - } - } - - return path.join(patch.topic, `${baseSlug}-${Date.now()}.md`); -} - -export async function applyExtractedMemoryPatches( - projectRoot: string, - patches: AutoMemoryExtractPatch[], - now = new Date(), - sessionId?: string, -): Promise { - const touchedTopics = new Set(); - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - - for (const patch of patches) { - const existingDoc = findExistingMemoryDocument(docs, patch); - - if (existingDoc) { - const current = await fs.readFile(existingDoc.filePath, 'utf-8'); - const next = appendPatchToTopicContent(current, patch); - if (!next) { - continue; - } - - await fs.writeFile(existingDoc.filePath, next, 'utf-8'); - const updatedDoc = parseAutoMemoryTopicDocument( - existingDoc.filePath, - next, - 0, - existingDoc.relativePath, - ); - if (updatedDoc) { - const existingIndex = docs.findIndex((doc) => doc.filePath === existingDoc.filePath); - if (existingIndex >= 0) { - docs[existingIndex] = updatedDoc; - } - } - touchedTopics.add(patch.topic); - continue; - } - - const relativePath = allocateMemoryRelativePath(docs, patch); - const absolutePath = getAutoMemoryFilePath(projectRoot, relativePath); - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); - const content = buildMemoryDocumentContent(patch); - await fs.writeFile(absolutePath, content, 'utf-8'); - const createdDoc = parseAutoMemoryTopicDocument( - absolutePath, - content, - 0, - relativePath, - ); - if (createdDoc) { - docs.push(createdDoc); - } - touchedTopics.add(patch.topic); - } - - if (sessionId) { - await bumpMetadata(projectRoot, now, sessionId, [...touchedTopics]); - } else if (touchedTopics.size > 0) { - await bumpMetadata(projectRoot, now, 'unknown', [...touchedTopics]); - } - - if (touchedTopics.size > 0) { - await rebuildManagedAutoMemoryIndex(projectRoot); - } - - return [...touchedTopics]; -} - export async function runAutoMemoryExtract(params: { projectRoot: string; sessionId: string; @@ -538,56 +209,28 @@ export async function runAutoMemoryExtract(params: { currentCursor, ); - if (params.config) { - try { - const agentResult = await runAutoMemoryExtractionByAgent( - params.config, - params.projectRoot, - slice.messages, - ); - - if (agentResult.touchedTopics.length > 0) { - await bumpMetadata( - params.projectRoot, - now, - params.sessionId, - agentResult.touchedTopics, - ); - await rebuildManagedAutoMemoryIndex(params.projectRoot); - } - - const cursor: AutoMemoryExtractCursor = { - sessionId: params.sessionId, - processedOffset: slice.nextProcessedOffset, - updatedAt: now.toISOString(), - }; - await writeExtractCursor(params.projectRoot, cursor); - - return { - patches: dedupeExtractPatches(agentResult.patches), - touchedTopics: agentResult.touchedTopics, - cursor, - systemMessage: agentResult.systemMessage, - }; - } catch (error) { - debugLogger.warn( - 'Forked-agent auto-memory extraction failed; falling back to patch-based extraction.', - error, - ); - } + if (!params.config) { + throw new Error( + 'Managed auto-memory extraction requires config for forked-agent execution.', + ); } - const patches = await planAutoMemoryExtractPatches({ - projectRoot: params.projectRoot, - messages: slice.messages, - config: params.config, - }); - const touchedTopics = await applyExtractedMemoryPatches( + const agentResult = await runAutoMemoryExtractionByAgent( + params.config, params.projectRoot, - patches, - now, - params.sessionId, + slice.messages, ); + const patches = dedupeExtractPatches(agentResult.patches); + + if (agentResult.touchedTopics.length > 0) { + await bumpMetadata( + params.projectRoot, + now, + params.sessionId, + agentResult.touchedTopics, + ); + await rebuildManagedAutoMemoryIndex(params.projectRoot); + } const cursor: AutoMemoryExtractCursor = { sessionId: params.sessionId, @@ -596,14 +239,15 @@ export async function runAutoMemoryExtract(params: { }; await writeExtractCursor(params.projectRoot, cursor); + debugLogger.debug( + `Managed auto-memory extract completed with ${patches.length} patch(es) and ${agentResult.touchedTopics.length} touched topic(s).`, + ); + return { patches, - touchedTopics, + touchedTopics: agentResult.touchedTopics, cursor, - systemMessage: - touchedTopics.length > 0 - ? `Managed auto-memory updated: ${touchedTopics.map((topic) => `${topic}.md`).join(', ')}` - : undefined, + systemMessage: agentResult.systemMessage, }; } diff --git a/packages/core/src/memory/extractAgent.test.ts b/packages/core/src/memory/extractAgent.test.ts index c848e0538cf..b635197fe78 100644 --- a/packages/core/src/memory/extractAgent.test.ts +++ b/packages/core/src/memory/extractAgent.test.ts @@ -9,10 +9,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { - planAutoMemoryExtractionPatchesByAgent, - runAutoMemoryExtractionByAgent, -} from './extractionAgentPlanner.js'; +import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { runAutoMemoryExtract } from './extract.js'; import { getAutoMemoryRoot } from './paths.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; @@ -20,7 +17,6 @@ import { ensureAutoMemoryScaffold } from './store.js'; import { resetAutoMemoryStateForTests } from './state.js'; vi.mock('./extractionAgentPlanner.js', () => ({ - planAutoMemoryExtractionPatchesByAgent: vi.fn(), runAutoMemoryExtractionByAgent: vi.fn(), })); @@ -98,7 +94,6 @@ describe('auto-memory extraction with agent planner', () => { projectRoot, expect.any(Array), ); - expect(planAutoMemoryExtractionPatchesByAgent).not.toHaveBeenCalled(); const docs = await scanAutoMemoryTopicDocuments(projectRoot); expect(docs.find((doc) => doc.type === 'user')?.body).toContain( diff --git a/packages/core/src/memory/extractModel.test.ts b/packages/core/src/memory/extractModel.test.ts deleted file mode 100644 index 62a8cd5c38c..00000000000 --- a/packages/core/src/memory/extractModel.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Config } from '../config/config.js'; -import { planAutoMemoryExtractionPatchesByAgent } from './extractionAgentPlanner.js'; -import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; -import { runAutoMemoryExtract } from './extract.js'; -import { scanAutoMemoryTopicDocuments } from './scan.js'; -import { ensureAutoMemoryScaffold } from './store.js'; -import { resetAutoMemoryStateForTests } from './state.js'; - -vi.mock('./extractionAgentPlanner.js', () => ({ - planAutoMemoryExtractionPatchesByAgent: vi.fn(), -})); - -vi.mock('./extractionPlanner.js', () => ({ - planAutoMemoryExtractionPatchesByModel: vi.fn(), -})); - -describe('auto-memory extraction with model planner', () => { - let tempDir: string; - let projectRoot: string; - const mockConfig = {} as Config; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-extract-model-')); - projectRoot = path.join(tempDir, 'project'); - await fs.mkdir(projectRoot, { recursive: true }); - await ensureAutoMemoryScaffold(projectRoot); - vi.clearAllMocks(); - }); - - afterEach(async () => { - resetAutoMemoryStateForTests(); - await fs.rm(tempDir, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 10, - }); - }); - - it('applies model-planned extraction patches when config is provided', async () => { - vi.mocked(planAutoMemoryExtractionPatchesByAgent).mockRejectedValue( - new Error('agent planner failed'), - ); - vi.mocked(planAutoMemoryExtractionPatchesByModel).mockResolvedValue([ - { - topic: 'reference', - summary: 'Latency dashboard: https://grafana.internal/d/api-latency', - sourceOffset: 0, - }, - ]); - - const result = await runAutoMemoryExtract({ - projectRoot, - sessionId: 'session-1', - config: mockConfig, - history: [ - { - role: 'user', - parts: [{ text: 'The latency dashboard is https://grafana.internal/d/api-latency' }], - }, - ], - }); - - expect(result.touchedTopics).toEqual(['reference']); - expect(planAutoMemoryExtractionPatchesByModel).toHaveBeenCalledWith( - mockConfig, - projectRoot, - expect.any(Array), - ); - - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - expect(docs.find((doc) => doc.type === 'reference')?.body).toContain( - 'Latency dashboard: https://grafana.internal/d/api-latency', - ); - }); - - it('falls back to heuristic extraction when the model planner fails', async () => { - vi.mocked(planAutoMemoryExtractionPatchesByAgent).mockRejectedValue( - new Error('agent planner failed'), - ); - vi.mocked(planAutoMemoryExtractionPatchesByModel).mockRejectedValue( - new Error('planner failed'), - ); - - const result = await runAutoMemoryExtract({ - projectRoot, - sessionId: 'session-1', - config: mockConfig, - history: [ - { - role: 'user', - parts: [{ text: 'I prefer terse responses.' }], - }, - ], - }); - - expect(result.touchedTopics).toEqual(['user']); - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - expect(docs.find((doc) => doc.type === 'user')?.body).toContain( - 'I prefer terse responses.', - ); - }); -}); diff --git a/packages/core/src/memory/extractScheduler.test.ts b/packages/core/src/memory/extractScheduler.test.ts index cf92c2bdabe..6d14255f042 100644 --- a/packages/core/src/memory/extractScheduler.test.ts +++ b/packages/core/src/memory/extractScheduler.test.ts @@ -7,15 +7,23 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; import { createManagedAutoMemoryExtractRuntimeForTests } from './extractScheduler.js'; +import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; +import { getAutoMemoryFilePath } from './paths.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { markExtractRunning, resetAutoMemoryStateForTests } from './state.js'; +vi.mock('./extractionAgentPlanner.js', () => ({ + runAutoMemoryExtractionByAgent: vi.fn(), +})); + describe('managed auto-memory extraction runtime', () => { let tempDir: string; let projectRoot: string; + let mockConfig: Config; beforeEach(async () => { tempDir = await fs.mkdtemp( @@ -24,6 +32,53 @@ describe('managed auto-memory extraction runtime', () => { projectRoot = path.join(tempDir, 'project'); await fs.mkdir(projectRoot, { recursive: true }); await ensureAutoMemoryScaffold(projectRoot); + mockConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + getModel: vi.fn().mockReturnValue('qwen3-coder-plus'), + } as unknown as Config; + vi.clearAllMocks(); + vi.mocked(runAutoMemoryExtractionByAgent).mockImplementation( + async (_config, root, messages) => { + const lastUserText = messages + .filter((message) => message.role === 'user') + .at(-1)?.text; + const topic = lastUserText?.includes('grafana.example/d/api') + ? 'reference' + : 'user'; + const relativePath = + topic === 'reference' + ? path.join('reference', 'latency-dashboard.md') + : path.join('user', 'terse-responses.md'); + const filePath = getAutoMemoryFilePath(root, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + [ + '---', + `type: ${topic}`, + `name: ${topic === 'reference' ? 'Latency Dashboard' : 'Terse Responses'}`, + `description: ${lastUserText ?? 'User prefers terse responses.'}`, + '---', + '', + lastUserText ?? 'User prefers terse responses.', + '', + ].join('\n'), + 'utf-8', + ); + + return { + patches: [ + { + topic, + summary: lastUserText ?? 'User prefers terse responses.', + sourceOffset: messages.at(-1)?.offset ?? 0, + }, + ], + touchedTopics: [topic], + systemMessage: undefined, + }; + }, + ); }); afterEach(async () => { @@ -42,12 +97,14 @@ describe('managed auto-memory extraction runtime', () => { const firstPromise = runtime.schedule({ projectRoot, sessionId: 'session-1', + config: mockConfig, history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], }); const queued = await runtime.schedule({ projectRoot, sessionId: 'session-1', + config: mockConfig, history: [ { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, { role: 'model', parts: [{ text: 'Done.' }] }, @@ -82,6 +139,7 @@ describe('managed auto-memory extraction runtime', () => { const result = await runtime.schedule({ projectRoot, sessionId: 'session-1', + config: mockConfig, history: [ { role: 'model', @@ -101,6 +159,7 @@ describe('managed auto-memory extraction runtime', () => { const result = await runtime.schedule({ projectRoot, sessionId: 'session-1', + config: mockConfig, history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], }); diff --git a/packages/core/src/memory/extractionAgentPlanner.test.ts b/packages/core/src/memory/extractionAgentPlanner.test.ts index a2654776365..1ef3fb150ea 100644 --- a/packages/core/src/memory/extractionAgentPlanner.test.ts +++ b/packages/core/src/memory/extractionAgentPlanner.test.ts @@ -6,10 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { - planAutoMemoryExtractionPatchesByAgent, - runAutoMemoryExtractionByAgent, -} from './extractionAgentPlanner.js'; +import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; vi.mock('./scan.js', async (importOriginal) => { @@ -20,115 +17,6 @@ vi.mock('./scan.js', async (importOriginal) => { }; }); -describe('planAutoMemoryExtractionPatchesByAgent', () => { - const mockConfig = { - getSessionId: vi.fn().mockReturnValue('session-1'), - getModel: vi.fn().mockReturnValue('qwen3-coder-plus'), - } as unknown as Config; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([ - { - type: 'user', - filePath: '/tmp/user.md', - relativePath: 'user.md', - filename: 'user.md', - title: 'User Memory', - description: 'User preferences', - body: '- Existing terse preference.', - mtimeMs: 1, - }, - ]); - }); - - it('returns parsed patches from BackgroundAgentRunner output', async () => { - const runner = { - run: vi.fn().mockResolvedValue({ - taskId: 'task-1', - status: 'completed', - finalText: JSON.stringify({ - patches: [ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 0, - }, - ], - }), - filesTouched: [], - }), - }; - - const patches = await planAutoMemoryExtractionPatchesByAgent( - mockConfig, - '/tmp/project', - [{ offset: 0, role: 'user', text: 'I prefer terse responses.' }], - runner, - ); - - expect(patches).toEqual([ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 0, - }, - ]); - expect(runner.run).toHaveBeenCalledWith( - expect.objectContaining({ - taskType: 'managed-auto-memory-extraction-agent', - sessionId: 'session-1', - runConfig: expect.objectContaining({ - max_turns: 4, - max_time_minutes: 2, - }), - toolConfig: { tools: ['read_file'] }, - }), - ); - }); - - it('returns empty list when there are no user messages', async () => { - const runner = { run: vi.fn() }; - await expect( - planAutoMemoryExtractionPatchesByAgent( - mockConfig, - '/tmp/project', - [{ offset: 0, role: 'model', text: 'hello' }], - runner, - ), - ).resolves.toEqual([]); - expect(runner.run).not.toHaveBeenCalled(); - }); - - it('throws when the agent returns invalid source offsets', async () => { - const runner = { - run: vi.fn().mockResolvedValue({ - taskId: 'task-1', - status: 'completed', - finalText: JSON.stringify({ - patches: [ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 99, - }, - ], - }), - filesTouched: [], - }), - }; - - await expect( - planAutoMemoryExtractionPatchesByAgent( - mockConfig, - '/tmp/project', - [{ offset: 0, role: 'user', text: 'I prefer terse responses.' }], - runner, - ), - ).rejects.toThrow('Invalid extraction agent response: invalid sourceOffset'); - }); -}); - describe('runAutoMemoryExtractionByAgent', () => { const mockConfig = { getSessionId: vi.fn().mockReturnValue('session-1'), diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index d95ad6cb03d..f41138d78e7 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -52,38 +52,6 @@ const EXTRACTION_AGENT_SYSTEM_PROMPT = [ ...MEMORY_FRONTMATTER_EXAMPLE, ].join('\n'); -const EXTRACTION_AGENT_RESPONSE_SCHEMA: Record = { - type: 'object', - properties: { - patches: { - type: 'array', - items: { - type: 'object', - properties: { - topic: { - type: 'string', - enum: ['user', 'feedback', 'project', 'reference'], - }, - summary: { - type: 'string', - }, - why: { - type: 'string', - }, - howToApply: { - type: 'string', - }, - sourceOffset: { - type: 'integer', - }, - }, - required: ['topic', 'summary', 'sourceOffset'], - }, - }, - }, - required: ['patches'], -}; - const EXTRACTION_AGENT_EXECUTION_RESPONSE_SCHEMA: Record = { type: 'object', properties: { @@ -123,10 +91,6 @@ const EXTRACTION_AGENT_EXECUTION_RESPONSE_SCHEMA: Record = { required: ['patches', 'touchedTopics'], }; -interface ExtractionAgentResponse { - patches: AutoMemoryExtractPatch[]; -} - interface ExtractionAgentExecutionResponse { patches: AutoMemoryExtractPatch[]; touchedTopics: AutoMemoryType[]; @@ -178,22 +142,6 @@ async function buildTopicSummaryBlock(projectRoot: string): Promise { .join('\n\n'); } -function buildTaskPrompt( - messages: AutoMemoryTranscriptMessage[], - topicSummaries: string, -): string { - return [ - 'Return a JSON object that matches this schema:', - JSON.stringify(EXTRACTION_AGENT_RESPONSE_SCHEMA, null, 2), - '', - 'Transcript slice:', - buildTranscriptBlock(messages), - '', - 'Current topic summaries:', - topicSummaries || '(no topics found)', - ].join('\n'); -} - function buildExecutionTaskPrompt( memoryRoot: string, messages: AutoMemoryTranscriptMessage[], @@ -231,19 +179,19 @@ function buildExecutionTaskPrompt( ].join('\n'); } -function validateExtractionAgentResponse( - parsed: ExtractionAgentResponse, +function validateExtractionExecutionResponse( + parsed: ExtractionAgentExecutionResponse, userOffsets: Set, -): AutoMemoryExtractPatch[] { +): AutoMemoryExtractionExecutionResult { const schemaError = SchemaValidator.validate( - EXTRACTION_AGENT_RESPONSE_SCHEMA, + EXTRACTION_AGENT_EXECUTION_RESPONSE_SCHEMA, parsed, ); if (schemaError) { throw new Error(`Invalid extraction agent response: ${schemaError}`); } - for (const patch of parsed.patches) { + const patches = parsed.patches.map((patch) => { if (!patch.summary?.trim()) { throw new Error('Invalid extraction agent response: empty summary'); } @@ -252,30 +200,15 @@ function validateExtractionAgentResponse( 'Invalid extraction agent response: invalid sourceOffset', ); } - } - - return parsed.patches.map((patch) => ({ - topic: patch.topic as AutoMemoryType, - summary: patch.summary.trim(), - why: patch.why?.trim(), - howToApply: patch.howToApply?.trim(), - sourceOffset: patch.sourceOffset, - })); -} - -function validateExtractionExecutionResponse( - parsed: ExtractionAgentExecutionResponse, - userOffsets: Set, -): AutoMemoryExtractionExecutionResult { - const schemaError = SchemaValidator.validate( - EXTRACTION_AGENT_EXECUTION_RESPONSE_SCHEMA, - parsed, - ); - if (schemaError) { - throw new Error(`Invalid extraction agent response: ${schemaError}`); - } - const patches = validateExtractionAgentResponse(parsed, userOffsets); + return { + topic: patch.topic as AutoMemoryType, + summary: patch.summary.trim(), + why: patch.why?.trim(), + howToApply: patch.howToApply?.trim(), + sourceOffset: patch.sourceOffset, + }; + }); const touchedTopics = Array.from( new Set( (parsed.touchedTopics ?? []).filter( @@ -298,66 +231,6 @@ function validateExtractionExecutionResponse( }; } -export async function planAutoMemoryExtractionPatchesByAgent( - config: Config, - projectRoot: string, - messages: AutoMemoryTranscriptMessage[], - runner: BackgroundAgentRunnerLike = new BackgroundAgentRunner(), -): Promise { - if (messages.length === 0) { - return []; - } - - const userOffsets = new Set( - messages - .filter((message) => message.role === 'user') - .map((message) => message.offset), - ); - if (userOffsets.size === 0) { - return []; - } - - const topicSummaries = await buildTopicSummaryBlock(projectRoot); - const result = await runner.run({ - taskType: 'managed-auto-memory-extraction-agent', - title: 'Managed auto-memory extraction agent', - description: 'Extract durable managed memory patches from transcript history.', - projectRoot, - sessionId: config.getSessionId(), - dedupeKey: `managed-auto-memory-extraction-agent:${projectRoot}`, - name: 'managed-auto-memory-extractor', - runtimeContext: config, - taskPrompt: buildTaskPrompt(messages, topicSummaries), - promptConfig: { - systemPrompt: EXTRACTION_AGENT_SYSTEM_PROMPT, - }, - modelConfig: { - model: config.getModel(), - temp: 0, - }, - runConfig: { - max_turns: 4, - max_time_minutes: 2, - }, - toolConfig: { - tools: ['read_file'], - }, - metadata: { - planner: 'extraction-agent', - stage: 'agent-b', - }, - }); - - if (result.status !== 'completed' || !result.finalText) { - throw new Error(result.error || 'Extraction agent did not complete successfully'); - } - - const parsed = safeJsonParse(result.finalText, { - patches: [], - }); - return validateExtractionAgentResponse(parsed, userOffsets); -} - export async function runAutoMemoryExtractionByAgent( config: Config, projectRoot: string, diff --git a/packages/core/src/memory/extractionPlanner.test.ts b/packages/core/src/memory/extractionPlanner.test.ts deleted file mode 100644 index 56f10cc4ac5..00000000000 --- a/packages/core/src/memory/extractionPlanner.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Config } from '../config/config.js'; -import { runSideQuery } from '../auxiliary/sideQuery.js'; -import { scanAutoMemoryTopicDocuments } from './scan.js'; -import { planAutoMemoryExtractionPatchesByModel } from './extractionPlanner.js'; - -vi.mock('../auxiliary/sideQuery.js', () => ({ - runSideQuery: vi.fn(), -})); - -vi.mock('./scan.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - scanAutoMemoryTopicDocuments: vi.fn(), - }; -}); - -describe('planAutoMemoryExtractionPatchesByModel', () => { - const mockConfig = {} as Config; - const messages = [ - { offset: 0, role: 'user' as const, text: 'I prefer terse responses.' }, - { offset: 1, role: 'model' as const, text: 'Understood.' }, - { - offset: 2, - role: 'user' as const, - text: 'The latency dashboard is https://grafana.internal/d/api-latency', - }, - ]; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([ - { - type: 'user', - filePath: '/tmp/user.md', - relativePath: 'user.md', - filename: 'user.md', - title: 'User Memory', - description: 'User preferences', - body: '- Existing terse preference.', - mtimeMs: 1, - }, - ]); - }); - - it('returns model-planned extraction patches', async () => { - vi.mocked(runSideQuery).mockResolvedValue({ - patches: [ - { topic: 'user', summary: 'User prefers terse responses.', sourceOffset: 0 }, - { - topic: 'reference', - summary: 'Latency dashboard: https://grafana.internal/d/api-latency', - sourceOffset: 2, - }, - ], - }); - - const patches = await planAutoMemoryExtractionPatchesByModel( - mockConfig, - '/tmp/project', - messages, - ); - - expect(patches).toEqual([ - { topic: 'user', summary: 'User prefers terse responses.', sourceOffset: 0 }, - { - topic: 'reference', - summary: 'Latency dashboard: https://grafana.internal/d/api-latency', - sourceOffset: 2, - }, - ]); - expect(runSideQuery).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - purpose: 'auto-memory-extract', - systemInstruction: expect.stringContaining( - 'You are acting as the managed memory extraction planner', - ), - }), - ); - }); - - it('returns empty list when there are no user messages', async () => { - await expect( - planAutoMemoryExtractionPatchesByModel(mockConfig, '/tmp/project', [ - { offset: 0, role: 'model', text: 'hello' }, - ]), - ).resolves.toEqual([]); - expect(runSideQuery).not.toHaveBeenCalled(); - }); - - it('throws when the planner returns an invalid sourceOffset', async () => { - vi.mocked(runSideQuery).mockImplementation(async (_config, options) => { - const error = options.validate?.({ - patches: [ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 99, - }, - ], - }); - if (error) { - throw new Error(error); - } - return { patches: [] }; - }); - - await expect( - planAutoMemoryExtractionPatchesByModel(mockConfig, '/tmp/project', messages), - ).rejects.toThrow('Extraction planner returned invalid sourceOffset'); - }); -}); diff --git a/packages/core/src/memory/extractionPlanner.ts b/packages/core/src/memory/extractionPlanner.ts deleted file mode 100644 index 90ecfe24f85..00000000000 --- a/packages/core/src/memory/extractionPlanner.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Content } from '@google/genai'; -import type { Config } from '../config/config.js'; -import { runSideQuery } from '../auxiliary/sideQuery.js'; -import { scanAutoMemoryTopicDocuments } from './scan.js'; -import type { AutoMemoryType } from './types.js'; -import type { - AutoMemoryExtractPatch, - AutoMemoryTranscriptMessage, -} from './extract.js'; - -const MAX_TOPIC_SUMMARY_CHARS = 280; - -const SYSTEM_PROMPT = `You are acting as the managed memory extraction planner for an AI coding assistant. - -Analyze only the provided recent transcript slice and the existing managed memory topic summaries, then return durable memory patches worth keeping beyond the current task. - -Save only information that is likely to matter in future sessions. - -Allowed topics: -- user: stable user preferences, habits, background, recurring requirements -- feedback: lasting instructions about how the assistant should respond or work -- project: stable project constraints, environments, releases, architecture facts -- reference: durable links, dashboards, tickets, docs, runbooks, identifiers - -Extract only durable facts stated by the user. - -Do not extract: -- temporary task steps -- session-only instructions -- speculative conclusions -- questions -- assistant-only plans not stated by the user -- content that only makes sense relative to “today”, “this task”, or “right now” - -If the user explicitly asks the assistant to remember something durable, prefer to keep it. - -Return concise summaries suitable for bullet points. Do not include leading bullet markers. Output must match the provided JSON schema exactly.`; - -const RESPONSE_SCHEMA: Record = { - type: 'object', - properties: { - patches: { - type: 'array', - items: { - type: 'object', - properties: { - topic: { - type: 'string', - enum: ['user', 'feedback', 'project', 'reference'], - }, - summary: { - type: 'string', - }, - why: { - type: 'string', - }, - howToApply: { - type: 'string', - }, - sourceOffset: { - type: 'integer', - }, - }, - required: ['topic', 'summary', 'sourceOffset'], - }, - }, - }, - required: ['patches'], -}; - -interface ExtractionPlannerResponse { - patches: AutoMemoryExtractPatch[]; -} - -function truncate(text: string, maxChars: number): string { - const normalized = text.replace(/\s+/g, ' ').trim(); - if (normalized.length <= maxChars) { - return normalized; - } - return `${normalized.slice(0, maxChars).trimEnd()}…`; -} - -function buildTranscriptBlock(messages: AutoMemoryTranscriptMessage[]): string { - return messages - .map( - (message) => - `- offset=${message.offset} role=${message.role} text=${message.text}`, - ) - .join('\n'); -} - -function buildTopicSummaryBlock(projectRoot: string): Promise { - return scanAutoMemoryTopicDocuments(projectRoot).then((docs) => - docs - .map((doc) => { - const body = truncate(doc.body === '_No entries yet._' ? '' : doc.body, MAX_TOPIC_SUMMARY_CHARS); - return [ - `topic=${doc.type}`, - `title=${doc.title}`, - `description=${doc.description || '(none)'}`, - `current=${body || '(empty)'}`, - ].join('\n'); - }) - .join('\n\n'), - ); -} - -export async function planAutoMemoryExtractionPatchesByModel( - config: Config, - projectRoot: string, - messages: AutoMemoryTranscriptMessage[], -): Promise { - if (messages.length === 0) { - return []; - } - - const userOffsets = new Set( - messages.filter((message) => message.role === 'user').map((message) => message.offset), - ); - if (userOffsets.size === 0) { - return []; - } - - const topicSummaries = await buildTopicSummaryBlock(projectRoot); - const contents: Content[] = [ - { - role: 'user', - parts: [ - { - text: [ - 'Transcript slice:', - buildTranscriptBlock(messages), - '', - 'Current topic summaries:', - topicSummaries || '(no topics found)', - ].join('\n'), - }, - ], - }, - ]; - - const response = await runSideQuery(config, { - purpose: 'auto-memory-extract', - contents, - schema: RESPONSE_SCHEMA, - abortSignal: AbortSignal.timeout(7_500), - systemInstruction: SYSTEM_PROMPT, - config: { - temperature: 0, - }, - validate: (value) => { - if (!Array.isArray(value.patches)) { - return 'Extraction planner must return patches array'; - } - for (const patch of value.patches) { - if (!patch.summary?.trim()) { - return 'Extraction planner returned empty summary'; - } - if (!userOffsets.has(patch.sourceOffset)) { - return 'Extraction planner returned invalid sourceOffset'; - } - } - return null; - }, - }); - - return response.patches.map((patch) => ({ - topic: patch.topic as AutoMemoryType, - summary: patch.summary, - why: patch.why, - howToApply: patch.howToApply, - sourceOffset: patch.sourceOffset, - })); -} diff --git a/packages/core/src/memory/memoryLifecycle.integration.test.ts b/packages/core/src/memory/memoryLifecycle.integration.test.ts index 408b1409df5..098596e6006 100644 --- a/packages/core/src/memory/memoryLifecycle.integration.test.ts +++ b/packages/core/src/memory/memoryLifecycle.integration.test.ts @@ -7,14 +7,15 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { runManagedAutoMemoryDream } from './dream.js'; import { drainManagedAutoMemoryExtractTasks, resetManagedAutoMemoryExtractRuntimeForTests, scheduleManagedAutoMemoryExtract, } from './extractScheduler.js'; -import { applyExtractedMemoryPatches } from './extract.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; import { getAutoMemoryFilePath, getAutoMemoryIndexPath } from './paths.js'; import { resolveRelevantAutoMemoryPromptForQuery } from './recall.js'; @@ -22,15 +23,67 @@ import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { resetAutoMemoryStateForTests } from './state.js'; +vi.mock('./extractionAgentPlanner.js', () => ({ + runAutoMemoryExtractionByAgent: vi.fn(), +})); + describe('managed auto-memory lifecycle integration', () => { let tempDir: string; let projectRoot: string; + let mockConfig: Config; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-lifecycle-int-')); projectRoot = path.join(tempDir, 'project'); await fs.mkdir(projectRoot, { recursive: true }); await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T00:00:00.000Z')); + mockConfig = { + getSessionId: () => 'session-1', + getModel: () => 'qwen3-coder-plus', + } as Config; + vi.clearAllMocks(); + vi.mocked(runAutoMemoryExtractionByAgent).mockImplementation( + async (_config, root, messages) => { + const lastUserText = messages + .filter((message) => message.role === 'user') + .at(-1)?.text; + const topic = lastUserText?.includes('grafana.example/d/api-latency') + ? 'reference' + : 'user'; + const relativePath = + topic === 'reference' + ? path.join('reference', 'latency-dashboard.md') + : path.join('user', 'terse-responses.md'); + const filePath = getAutoMemoryFilePath(root, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + [ + '---', + `type: ${topic}`, + `name: ${topic === 'reference' ? 'Latency Dashboard' : 'Terse Responses'}`, + `description: ${lastUserText ?? 'I prefer terse responses.'}`, + '---', + '', + lastUserText ?? 'I prefer terse responses.', + '', + ].join('\n'), + 'utf-8', + ); + + return { + patches: [ + { + topic, + summary: lastUserText ?? 'I prefer terse responses.', + sourceOffset: messages.at(-1)?.offset ?? 0, + }, + ], + touchedTopics: [topic], + systemMessage: undefined, + }; + }, + ); }); afterEach(async () => { @@ -48,12 +101,14 @@ describe('managed auto-memory lifecycle integration', () => { const firstExtraction = scheduleManagedAutoMemoryExtract({ projectRoot, sessionId: 'session-1', + config: mockConfig, history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], }); const queuedExtraction = await scheduleManagedAutoMemoryExtract({ projectRoot, sessionId: 'session-1', + config: mockConfig, history: [ { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, { role: 'model', parts: [{ text: 'Understood.' }] }, @@ -76,18 +131,27 @@ describe('managed auto-memory lifecycle integration', () => { const drained = await drainManagedAutoMemoryExtractTasks({ timeoutMs: 1_000 }); expect(drained).toBe(true); - await applyExtractedMemoryPatches(projectRoot, [ - { - topic: 'project', - summary: 'The latency dashboard is https://grafana.example/d/api-latency', - sourceOffset: 100, - }, - { - topic: 'project', - summary: 'This is temporary for this task.', - sourceOffset: 101, - }, - ]); + const projectPath = getAutoMemoryFilePath( + projectRoot, + path.join('project', 'latency-dashboard.md'), + ); + await fs.mkdir(path.dirname(projectPath), { recursive: true }); + await fs.writeFile( + projectPath, + [ + '---', + 'type: project', + 'name: Latency Dashboard', + 'description: The latency dashboard is https://grafana.example/d/api-latency', + '---', + '', + 'The latency dashboard is https://grafana.example/d/api-latency', + '', + 'Why: This is temporary for this task.', + ].join('\n'), + 'utf-8', + ); + await rebuildManagedAutoMemoryIndex(projectRoot); const duplicateUserPath = getAutoMemoryFilePath( projectRoot, From 458843c19f4c06b21becffa4efb6b537abc0c1f4 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 9 Apr 2026 10:08:36 +0800 Subject: [PATCH 30/56] refactor(memory): move auxiliary files out of memory/ directory meta.json, extract-cursor.json, and consolidation.lock are internal bookkeeping files, not user-visible memories. Move them one level up to the project state dir (parent of memory/) so that the memory/ directory contains only MEMORY.md and topic files, matching the clean layout of the upstream reference implementation. Add getAutoMemoryProjectStateDir() helper in paths.ts and update the three path accessors + store.test.ts path assertions accordingly. --- packages/core/src/memory/paths.ts | 16 +++++++++++++--- packages/core/src/memory/store.test.ts | 6 +++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/core/src/memory/paths.ts b/packages/core/src/memory/paths.ts index 5b6b7a00f4a..a86a597bdcf 100644 --- a/packages/core/src/memory/paths.ts +++ b/packages/core/src/memory/paths.ts @@ -102,6 +102,16 @@ export function getAutoMemoryRoot(projectRoot: string): string { ); } +/** + * Returns the project-level state directory that holds auxiliary files + * (meta.json, extract-cursor.json, consolidation.lock) for the given project. + * This is the parent of getAutoMemoryRoot(), so memory/ stays clean: + * only MEMORY.md and topic files live inside it. + */ +export function getAutoMemoryProjectStateDir(projectRoot: string): string { + return path.dirname(getAutoMemoryRoot(projectRoot)); +} + /** * Returns true if the given absolute path is inside the auto-memory root for * the given project. The path is normalized to prevent path-traversal tricks. @@ -118,14 +128,14 @@ export function getAutoMemoryIndexPath(projectRoot: string): string { export function getAutoMemoryMetadataPath(projectRoot: string): string { return path.join( - getAutoMemoryRoot(projectRoot), + getAutoMemoryProjectStateDir(projectRoot), AUTO_MEMORY_METADATA_FILENAME, ); } export function getAutoMemoryExtractCursorPath(projectRoot: string): string { return path.join( - getAutoMemoryRoot(projectRoot), + getAutoMemoryProjectStateDir(projectRoot), AUTO_MEMORY_EXTRACT_CURSOR_FILENAME, ); } @@ -134,7 +144,7 @@ export function getAutoMemoryConsolidationLockPath( projectRoot: string, ): string { return path.join( - getAutoMemoryRoot(projectRoot), + getAutoMemoryProjectStateDir(projectRoot), AUTO_MEMORY_CONSOLIDATION_LOCK_FILENAME, ); } diff --git a/packages/core/src/memory/store.test.ts b/packages/core/src/memory/store.test.ts index c7502d04742..3838f82cbec 100644 --- a/packages/core/src/memory/store.test.ts +++ b/packages/core/src/memory/store.test.ts @@ -50,13 +50,13 @@ describe('auto-memory storage scaffold', () => { path.join(projectRoot, '.qwen', 'memory', 'MEMORY.md'), ); expect(getAutoMemoryMetadataPath(projectRoot)).toBe( - path.join(projectRoot, '.qwen', 'memory', 'meta.json'), + path.join(projectRoot, '.qwen', 'meta.json'), ); expect(getAutoMemoryExtractCursorPath(projectRoot)).toBe( - path.join(projectRoot, '.qwen', 'memory', 'extract-cursor.json'), + path.join(projectRoot, '.qwen', 'extract-cursor.json'), ); expect(getAutoMemoryConsolidationLockPath(projectRoot)).toBe( - path.join(projectRoot, '.qwen', 'memory', 'consolidation.lock'), + path.join(projectRoot, '.qwen', 'consolidation.lock'), ); expect(getAutoMemoryTopicPath(projectRoot, 'feedback')).toBe( path.join(projectRoot, '.qwen', 'memory', 'feedback.md'), From 6298393c1fb2106cc760eba80cbb494b7469dff4 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 9 Apr 2026 10:19:22 +0800 Subject: [PATCH 31/56] fix(memory): record lastDreamAt after manual /dream run The /dream command submits a prompt to the main agent (submit_prompt), which writes memory files directly. Because it bypasses dreamScheduler, meta.json was never updated and /memory always showed 'never'. Fix by: - Exporting writeDreamManualRunToMetadata() from dream.ts - Adding optional onComplete callback to SubmitPromptActionReturn and SubmitPromptResult (types.ts / commands/types.ts) - Propagating onComplete through slashCommandProcessor.ts - Firing onComplete after turn completion in useGeminiStream.ts - Providing the callback in dreamCommand.ts to write lastDreamAt --- packages/cli/src/ui/commands/dreamCommand.ts | 7 +++++++ packages/cli/src/ui/commands/types.ts | 2 ++ packages/cli/src/ui/hooks/slashCommandProcessor.ts | 1 + packages/cli/src/ui/hooks/useGeminiStream.ts | 11 +++++++++++ packages/cli/src/ui/types.ts | 2 ++ packages/core/src/memory/dream.ts | 14 ++++++++++++++ 6 files changed, 37 insertions(+) diff --git a/packages/cli/src/ui/commands/dreamCommand.ts b/packages/cli/src/ui/commands/dreamCommand.ts index 179e9091a7e..a8fd0195c15 100644 --- a/packages/cli/src/ui/commands/dreamCommand.ts +++ b/packages/cli/src/ui/commands/dreamCommand.ts @@ -9,6 +9,7 @@ import { getProjectHash, QWEN_DIR, buildConsolidationTaskPrompt, + writeDreamManualRunToMetadata, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; import type { SlashCommand } from './types.js'; @@ -40,6 +41,12 @@ export const dreamCommand: SlashCommand = { return { type: 'submit_prompt', content: prompt, + onComplete: async () => { + await writeDreamManualRunToMetadata( + projectRoot, + config.getSessionId(), + ); + }, }; }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 701d4ca8ed4..e5907a73e90 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -186,6 +186,8 @@ export interface LoadHistoryActionReturn { export interface SubmitPromptActionReturn { type: 'submit_prompt'; content: PartListUnion; + /** Optional callback invoked after the agent turn completes successfully. */ + onComplete?: () => Promise; } /** diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 71cd8af78e0..927e225092d 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -568,6 +568,7 @@ export const useSlashCommandProcessor = ( return { type: 'submit_prompt', content: result.content, + onComplete: result.onComplete, }; case 'confirm_shell_commands': { const { outcome, approvedCommands } = await new Promise<{ diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 9edee8b6c24..6419a52a5b9 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -197,6 +197,7 @@ export const useGeminiStream = ( null, ); const processedMemoryToolsRef = useRef>(new Set()); + const submitPromptOnCompleteRef = useRef<(() => Promise) | null>(null); const { startNewPrompt, getPromptCount, @@ -523,6 +524,8 @@ export const useGeminiStream = ( } case 'submit_prompt': { localQueryToSendToGemini = slashCommandResult.content; + submitPromptOnCompleteRef.current = + slashCommandResult.onComplete ?? null; return { queryToSend: localQueryToSendToGemini, @@ -1258,6 +1261,14 @@ export const useGeminiStream = ( handleLoopDetectedEvent(); } + // If the turn was initiated by a submit_prompt with an onComplete + // callback (e.g. /dream recording lastDreamAt), fire it now. + const onComplete = submitPromptOnCompleteRef.current; + if (onComplete) { + submitPromptOnCompleteRef.current = null; + void onComplete(); + } + // After the turn completes, wire up notifications for any background // dream / extraction tasks that were kicked off by the client. if (geminiClient) { diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 211f5c738ac..c10de89c9e0 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -537,6 +537,8 @@ export interface ConsoleMessageItem { export interface SubmitPromptResult { type: 'submit_prompt'; content: PartListUnion; + /** Optional callback invoked after the agent turn completes successfully. */ + onComplete?: () => Promise; } /** diff --git a/packages/core/src/memory/dream.ts b/packages/core/src/memory/dream.ts index 836553a986a..1f1996ae56c 100644 --- a/packages/core/src/memory/dream.ts +++ b/packages/core/src/memory/dream.ts @@ -205,4 +205,18 @@ async function updateDreamMetadataResult( } catch { // Best-effort metadata bump. } +} + +/** + * Record that the user manually ran /dream. Called from the CLI command's + * onComplete callback after the main agent turn finishes writing memory files. + * Writes lastDreamAt (and resets recentSessionIdsSinceDream) so that + * /memory status reflects the correct "last dream" time. + */ +export async function writeDreamManualRunToMetadata( + projectRoot: string, + sessionId: string, + now = new Date(), +): Promise { + return updateDreamMetadataResult(projectRoot, now, []); } \ No newline at end of file From 53b239ed5e7d4df4a53ead9a051677346cd9ac8f Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 9 Apr 2026 10:45:46 +0800 Subject: [PATCH 32/56] fix(memory): remove scope params from /remember in managed auto-memory mode --global/--project are legacy save_memory tool concepts. In managed auto-memory mode the forked agent decides the appropriate type (user/feedback/project/reference) based on the content of the fact. Also improve the prompt wording to explicitly ask the agent to choose the correct type, reducing the tendency to default to 'project'. --- .../cli/src/ui/commands/rememberCommand.ts | 51 +++---------------- 1 file changed, 8 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/ui/commands/rememberCommand.ts b/packages/cli/src/ui/commands/rememberCommand.ts index 9c3339d1498..b4280ddbfee 100644 --- a/packages/cli/src/ui/commands/rememberCommand.ts +++ b/packages/cli/src/ui/commands/rememberCommand.ts @@ -9,35 +9,6 @@ import { t } from '../../i18n/index.js'; import type { CommandContext, SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; -function parseRememberArgs(args: string): - | { fact: string; scope?: 'global' | 'project' } - | null { - const trimmedArgs = args.trim(); - if (!trimmedArgs) { - return null; - } - - if (trimmedArgs.startsWith('--global ')) { - return { - scope: 'global', - fact: trimmedArgs.slice('--global '.length).trim(), - }; - } - - if (trimmedArgs.startsWith('--project ')) { - return { - scope: 'project', - fact: trimmedArgs.slice('--project '.length).trim(), - }; - } - - if (trimmedArgs === '--global' || trimmedArgs === '--project') { - return null; - } - - return { fact: trimmedArgs }; -} - export const rememberCommand: SlashCommand = { name: 'remember', get description() { @@ -45,12 +16,12 @@ export const rememberCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, action: (context: CommandContext, args): SlashCommandActionReturn | void => { - const parsed = parseRememberArgs(args); - if (!parsed?.fact) { + const fact = args.trim(); + if (!fact) { return { type: 'message', messageType: 'error', - content: t('Usage: /remember [--global|--project] '), + content: t('Usage: /remember '), }; } @@ -59,20 +30,16 @@ export const rememberCommand: SlashCommand = { if (useManagedMemory) { // In managed auto-memory mode the save_memory tool is not registered. - // Instead, submit a prompt so the main agent writes the per-entry file - // directly, following the instructions in buildManagedAutoMemoryPrompt. - const scopeHint = parsed.scope === 'project' - ? ' (type: project)' - : parsed.scope === 'global' - ? ' (type: user)' - : ''; + // Submit a prompt so the main agent writes the per-entry file directly, + // choosing the appropriate type (user / feedback / project / reference) + // based on the content, following the instructions in buildManagedAutoMemoryPrompt. const memoryDir = config ? getAutoMemoryRoot(config.getProjectRoot()) : undefined; const dirHint = memoryDir ? ` Save it to \`${memoryDir}\`.` : ''; return { type: 'submit_prompt', - content: `Please save the following to your memory system${scopeHint}:${dirHint}\n\n${parsed.fact}`, + content: `Please save the following to your memory system.${dirHint} Choose the most appropriate memory type (user, feedback, project, or reference) based on the content:\n\n${fact}`, }; } @@ -80,9 +47,7 @@ export const rememberCommand: SlashCommand = { return { type: 'tool', toolName: 'save_memory', - toolArgs: parsed.scope - ? { fact: parsed.fact, scope: parsed.scope } - : { fact: parsed.fact }, + toolArgs: { fact }, }; }, }; From d3311657df404f545cea573014deebae12b30547 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 9 Apr 2026 11:03:16 +0800 Subject: [PATCH 33/56] =?UTF-8?q?feat(ui):=20show=20'=E2=9C=A6=20dreaming'?= =?UTF-8?q?=20indicator=20in=20footer=20during=20background=20dream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscribe to getManagedAutoMemoryDreamTaskRegistry() in Footer via a useDreamRunning() hook. While any dream task for the current project is pending or running, display '✦ dreaming' in the right section of the footer bar, between Debug Mode and context usage. --- packages/cli/src/ui/components/Footer.tsx | 39 ++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index af81f6a5d7c..6483cc614e1 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -5,6 +5,7 @@ */ import type React from 'react'; +import { useEffect, useState } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; @@ -16,13 +17,43 @@ import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; -import { ApprovalMode } from '@qwen-code/qwen-code-core'; +import { + ApprovalMode, + getManagedAutoMemoryDreamTaskRegistry, +} from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; +/** + * Subscribes to the dream task registry and returns true while any dream task + * for the current project is in 'pending' or 'running' state. + */ +function useDreamRunning(projectRoot: string): boolean { + const [running, setRunning] = useState(false); + + useEffect(() => { + const registry = getManagedAutoMemoryDreamTaskRegistry(); + + function check() { + const tasks = registry.list(projectRoot); + setRunning(tasks.some((t) => t.status === 'pending' || t.status === 'running')); + } + + check(); + return registry.subscribe((task) => { + if (task.projectRoot === projectRoot) { + check(); + } + }); + }, [projectRoot]); + + return running; +} + export const Footer: React.FC = () => { const uiState = useUIState(); const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); + const dreamRunning = useDreamRunning(config.getProjectRoot()); const { promptTokenCount, showAutoAcceptIndicator } = { promptTokenCount: uiState.sessionStats.lastPromptTokenCount, @@ -79,6 +110,12 @@ export const Footer: React.FC = () => { node: Debug Mode, }); } + if (dreamRunning) { + rightItems.push({ + key: 'dream', + node: ✦ dreaming, + }); + } if (promptTokenCount > 0 && contextWindowSize) { rightItems.push({ key: 'context', From 21c3f5a90a4ca2e4e8ff911655fecc772f184119 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 9 Apr 2026 11:40:26 +0800 Subject: [PATCH 34/56] refactor(memory): align dream/extract infrastructure with Claude Code patterns Five improvements based on Claude Code parity audit: 1. Memoize getAutoMemoryRoot (paths.ts) - Add _autoMemoryRootCache Map, keyed by projectRoot - findCanonicalGitRoot() walks the filesystem per call; memoize avoids repeated git-tree traversal on hot-path schedulers/scanners - Expose clearAutoMemoryRootCache() for test teardown 2. Lock file stores PID + isProcessRunning reclaim (dreamScheduler.ts) - acquireDreamLock() writes process.pid to the lock file body - lockExists() reads PID and calls process.kill(pid, 0); dead/missing PID reclaims the lock immediately instead of waiting 2h - Stale threshold reduced to 1h (PID-reuse guard, same as CC) 3. Session scan throttle (dreamScheduler.ts) - Add SESSION_SCAN_INTERVAL_MS = 10min (same as CC) - Add lastSessionScanAt Map to ManagedAutoMemoryDreamRuntime - When time-gate passes but session-gate doesn't, throttle prevents re-scanning the filesystem on every user turn 4. mtime-based session counting (dreamScheduler.ts) - Replace fragile recentSessionIdsSinceDream Set in meta.json with filesystem mtime scan (listSessionsTouchedSince) - Mirrors Claude Code's listSessionsTouchedSince: reads session JSONL files from Storage.getProjectDir()/chats/, filters by mtime > lastDreamAt - Immune to meta.json corruption/loss; no per-turn metadata write - ManagedAutoMemoryDreamRuntime accepts injectable SessionScannerFn for clean unit testing without real session files 5. Extraction mutual exclusion extended to write_file/edit (extractScheduler.ts) - historySliceUsesMemoryTool() now checks write_file/edit/replace/create_file tool calls whose file_path is within isAutoMemPath() - Previously only detected save_memory; missed direct file writes by the main agent, causing redundant background extraction --- .../core/src/memory/dreamScheduler.test.ts | 32 +++- packages/core/src/memory/dreamScheduler.ts | 155 +++++++++++++++--- packages/core/src/memory/extractScheduler.ts | 36 +++- packages/core/src/memory/paths.ts | 31 +++- 4 files changed, 208 insertions(+), 46 deletions(-) diff --git a/packages/core/src/memory/dreamScheduler.test.ts b/packages/core/src/memory/dreamScheduler.test.ts index 5f21a4957c8..40ba55fde19 100644 --- a/packages/core/src/memory/dreamScheduler.test.ts +++ b/packages/core/src/memory/dreamScheduler.test.ts @@ -16,9 +16,19 @@ import { import { createManagedAutoMemoryDreamRuntimeForTests, DEFAULT_AUTO_DREAM_MIN_HOURS, + type SessionScannerFn, } from './dreamScheduler.js'; import { ensureAutoMemoryScaffold } from './store.js'; +/** + * Creates a simple in-memory session scanner for tests. + * Returns session IDs from `sessions` that are not in `excluded`. + */ +function makeSessionScanner(sessions: string[]): SessionScannerFn { + return async (_projectRoot, _sinceMs, excludeSessionId) => + sessions.filter((id) => id !== excludeSessionId); +} + describe('managed auto-memory dream scheduler', () => { let tempDir: string; let projectRoot: string; @@ -40,7 +50,9 @@ describe('managed auto-memory dream scheduler', () => { }); it('waits for enough distinct sessions before scheduling dream', async () => { - const runtime = createManagedAutoMemoryDreamRuntimeForTests(); + // Start with one session in the scanner; first call should skip (need 2) + const knownSessions = ['session-0']; + const runtime = createManagedAutoMemoryDreamRuntimeForTests(makeSessionScanner(knownSessions)); const first = await runtime.schedule({ projectRoot, @@ -54,7 +66,11 @@ describe('managed auto-memory dream scheduler', () => { skippedReason: 'min_sessions', }); - const second = await runtime.schedule({ + // Add a second session so the count reaches the threshold + knownSessions.push('session-00'); + const runtime2 = createManagedAutoMemoryDreamRuntimeForTests(makeSessionScanner(knownSessions)); + + const second = await runtime2.schedule({ projectRoot, sessionId: 'session-2', now: new Date('2026-04-01T11:00:00.000Z'), @@ -67,18 +83,17 @@ describe('managed auto-memory dream scheduler', () => { const metadata = JSON.parse( await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'), - ) as { lastDreamAt?: string; lastDreamSessionId?: string; recentSessionIdsSinceDream?: string[] }; + ) as { lastDreamAt?: string; lastDreamSessionId?: string }; expect(metadata.lastDreamSessionId).toBe('session-2'); expect(metadata.lastDreamAt).toBe('2026-04-01T11:00:00.000Z'); - expect(metadata.recentSessionIdsSinceDream).toEqual([]); await expect( fs.access(getAutoMemoryConsolidationLockPath(projectRoot)), ).rejects.toThrow(); }); it('skips dream in the same session after a successful run', async () => { - const runtime = createManagedAutoMemoryDreamRuntimeForTests(); + const runtime = createManagedAutoMemoryDreamRuntimeForTests(makeSessionScanner(['session-0'])); const scheduled = await runtime.schedule({ projectRoot, @@ -104,8 +119,9 @@ describe('managed auto-memory dream scheduler', () => { }); it('skips dream when consolidation lock already exists', async () => { - const runtime = createManagedAutoMemoryDreamRuntimeForTests(); - await fs.writeFile(getAutoMemoryConsolidationLockPath(projectRoot), 'locked', 'utf-8'); + const runtime = createManagedAutoMemoryDreamRuntimeForTests(makeSessionScanner(['session-0'])); + // Write our own PID so isProcessRunning() considers the lock live. + await fs.writeFile(getAutoMemoryConsolidationLockPath(projectRoot), String(process.pid), 'utf-8'); const result = await runtime.schedule({ projectRoot, @@ -122,7 +138,7 @@ describe('managed auto-memory dream scheduler', () => { }); it('runs the existing mechanical dream logic inside scheduled tasks', async () => { - const runtime = createManagedAutoMemoryDreamRuntimeForTests(); + const runtime = createManagedAutoMemoryDreamRuntimeForTests(makeSessionScanner(['session-0'])); const firstPath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse.md')); const duplicatePath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse-duplicate.md')); await fs.mkdir(path.dirname(firstPath), { recursive: true }); diff --git a/packages/core/src/memory/dreamScheduler.ts b/packages/core/src/memory/dreamScheduler.ts index cf21dfecf59..0dd844c8e3b 100644 --- a/packages/core/src/memory/dreamScheduler.ts +++ b/packages/core/src/memory/dreamScheduler.ts @@ -5,7 +5,9 @@ */ import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import type { Config } from '../config/config.js'; +import { Storage } from '../config/storage.js'; import { BackgroundTaskDrainer, type DrainBackgroundTasksOptions, @@ -25,7 +27,23 @@ import type { AutoMemoryMetadata } from './types.js'; export const DEFAULT_AUTO_DREAM_MIN_HOURS = 24; export const DEFAULT_AUTO_DREAM_MIN_SESSIONS = 5; -const DREAM_LOCK_STALE_MS = 2 * 60 * 60 * 1000; +/** Maximum age before a lock is reclaimed even if the PID appears live (PID-reuse guard). */ +const DREAM_LOCK_STALE_MS = 60 * 60 * 1000; // 1 hour (same as CC) +/** Minimum interval between session-count filesystem scans when time-gate is open. */ +const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes (same as CC) + +/** + * Returns true if the given process ID is currently alive. + * Uses kill(pid, 0) — no signal sent, just existence check. + */ +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} export interface ScheduleManagedAutoMemoryDreamParams { projectRoot: string; @@ -76,24 +94,86 @@ function hoursSince(lastDreamAt: string | undefined, now: Date): number | null { return (now.getTime() - timestamp) / (1000 * 60 * 60); } +/** Pattern matching session JSONL files: .jsonl */ +const SESSION_FILE_PATTERN = /^[0-9a-fA-F-]{32,36}\.jsonl$/; + +/** + * Returns session IDs whose transcript files have mtime after sinceMs. + * Mirrors Claude Code’s listSessionsTouchedSince approach: ground truth from + * the filesystem, immune to meta.json corruption or loss. + * Caller should exclude the current session (its mtime is always recent). + */ +async function listSessionsTouchedSince( + projectRoot: string, + sinceMs: number, + excludeSessionId: string, +): Promise { + const chatsDir = path.join(new Storage(projectRoot).getProjectDir(), 'chats'); + let names: string[]; + try { + names = await fs.readdir(chatsDir); + } catch { + return []; + } + const results: string[] = []; + await Promise.all( + names.map(async (name) => { + if (!SESSION_FILE_PATTERN.test(name)) return; + const sessionId = name.slice(0, -'.jsonl'.length); + if (sessionId === excludeSessionId) return; + try { + const stats = await fs.stat(path.join(chatsDir, name)); + if (stats.mtimeMs > sinceMs) { + results.push(sessionId); + } + } catch { + // Skip files we cannot stat + } + }), + ); + return results; +} + async function lockExists(projectRoot: string): Promise { + const lockPath = getAutoMemoryConsolidationLockPath(projectRoot); + let mtimeMs: number; + let holderPid: number | undefined; try { - const lockPath = getAutoMemoryConsolidationLockPath(projectRoot); - const stats = await fs.stat(lockPath); - const ageMs = Date.now() - stats.mtimeMs; - if (ageMs > DREAM_LOCK_STALE_MS) { - await fs.rm(lockPath, { force: true }); - return false; - } - return true; + const [stats, content] = await Promise.all([ + fs.stat(lockPath), + fs.readFile(lockPath, 'utf-8').catch(() => ''), + ]); + mtimeMs = stats.mtimeMs; + const parsed = parseInt(content.trim(), 10); + holderPid = Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } catch { + return false; // ENOENT — no lock + } + + const ageMs = Date.now() - mtimeMs; + + // Within stale threshold: check if the holder PID is still alive. + if (ageMs <= DREAM_LOCK_STALE_MS) { + if (holderPid !== undefined && isProcessRunning(holderPid)) { + return true; // live holder + } + // Dead PID or unparseable body — reclaim the stale lock immediately. + await fs.rm(lockPath, { force: true }); return false; } + + // Past stale threshold regardless of PID (PID-reuse guard). + await fs.rm(lockPath, { force: true }); + return false; } async function acquireDreamLock(projectRoot: string): Promise { - const handle = await fs.open(getAutoMemoryConsolidationLockPath(projectRoot), 'wx'); - await handle.close(); + // Write our PID so lockExists() can detect whether we're still alive. + await fs.writeFile( + getAutoMemoryConsolidationLockPath(projectRoot), + String(process.pid), + { flag: 'wx' }, // exclusive create — throws EEXIST if already locked + ); } async function releaseDreamLock(projectRoot: string): Promise { @@ -102,11 +182,28 @@ async function releaseDreamLock(projectRoot: string): Promise { }); } +/** Function type for scanning session files by mtime. Injected for testing. */ +export type SessionScannerFn = ( + projectRoot: string, + sinceMs: number, + excludeSessionId: string, +) => Promise; + export class ManagedAutoMemoryDreamRuntime { readonly registry = new BackgroundTaskRegistry(); readonly drainer = new BackgroundTaskDrainer(); readonly scheduler = new BackgroundTaskScheduler(this.registry, this.drainer); + constructor( + private readonly sessionScanner: SessionScannerFn = listSessionsTouchedSince, + ) {} + /** + * Timestamp (ms) of the last session-count filesystem scan per project root. + * When the time-gate passes but session-count doesn't, we'd otherwise re-scan + * every turn. Throttle to SESSION_SCAN_INTERVAL_MS (10 min). + */ + private lastSessionScanAt = new Map(); + async schedule( params: ScheduleManagedAutoMemoryDreamParams, ): Promise { @@ -132,12 +229,6 @@ export class ManagedAutoMemoryDreamRuntime { }; } - const recentSessionIds = new Set(metadata.recentSessionIdsSinceDream ?? []); - recentSessionIds.add(params.sessionId); - metadata.recentSessionIdsSinceDream = [...recentSessionIds]; - metadata.updatedAt = now.toISOString(); - await writeDreamMetadata(params.projectRoot, metadata); - const elapsedHours = hoursSince(metadata.lastDreamAt, now); if (elapsedHours !== null && elapsedHours < minHoursBetweenDreams) { return { @@ -146,7 +237,26 @@ export class ManagedAutoMemoryDreamRuntime { }; } - if (recentSessionIds.size < minSessionsBetweenDreams) { + // Scan throttle: when the time-gate passes but the session-gate hasn't, we'd + // re-scan the session set on every turn. Throttle to SESSION_SCAN_INTERVAL_MS. + const lastScan = this.lastSessionScanAt.get(params.projectRoot) ?? 0; + const sinceScanMs = now.getTime() - lastScan; + if (sinceScanMs < SESSION_SCAN_INTERVAL_MS) { + return { + status: 'skipped', + skippedReason: 'min_sessions', + }; + } + this.lastSessionScanAt.set(params.projectRoot, now.getTime()); + + // Scan session files by mtime (filesystem ground truth, immune to meta.json loss). + const lastDreamMs = metadata.lastDreamAt ? Date.parse(metadata.lastDreamAt) : 0; + const sessionIds = await this.sessionScanner( + params.projectRoot, + lastDreamMs, + params.sessionId, + ); + if (sessionIds.length < minSessionsBetweenDreams) { return { status: 'skipped', skippedReason: 'min_sessions', @@ -167,7 +277,7 @@ export class ManagedAutoMemoryDreamRuntime { sessionId: params.sessionId, dedupeKey: `managed-auto-memory-dream:${params.projectRoot}`, metadata: { - sessionCount: recentSessionIds.size, + sessionCount: sessionIds.length, }, run: async () => { try { @@ -191,7 +301,6 @@ export class ManagedAutoMemoryDreamRuntime { const nextMetadata = await readDreamMetadata(params.projectRoot); nextMetadata.lastDreamAt = now.toISOString(); nextMetadata.lastDreamSessionId = params.sessionId; - nextMetadata.recentSessionIdsSinceDream = []; nextMetadata.updatedAt = now.toISOString(); await writeDreamMetadata(params.projectRoot, nextMetadata); @@ -254,6 +363,8 @@ export async function drainManagedAutoMemoryDreamTasks( return defaultManagedAutoMemoryDreamRuntime.drain(options); } -export function createManagedAutoMemoryDreamRuntimeForTests(): ManagedAutoMemoryDreamRuntime { - return new ManagedAutoMemoryDreamRuntime(); +export function createManagedAutoMemoryDreamRuntimeForTests( + sessionScanner?: SessionScannerFn, +): ManagedAutoMemoryDreamRuntime { + return new ManagedAutoMemoryDreamRuntime(sessionScanner); } diff --git a/packages/core/src/memory/extractScheduler.ts b/packages/core/src/memory/extractScheduler.ts index 3d4a5bcc1e0..db30a3c208c 100644 --- a/packages/core/src/memory/extractScheduler.ts +++ b/packages/core/src/memory/extractScheduler.ts @@ -23,6 +23,7 @@ import { isExtractRunning, markExtractRunning, } from './state.js'; +import { isAutoMemPath } from './paths.js'; export interface ScheduleAutoMemoryExtractParams { projectRoot: string; @@ -52,16 +53,35 @@ function buildSkippedExtractResult( }; } -function partIsSaveMemoryCall(part: Part): boolean { - return ( - (part.functionCall?.name === 'save_memory') || - (part.functionResponse?.name === 'save_memory') - ); +/** + * Returns true if the part is a write-tool call targeting a file path inside + * the auto-memory directory. Covers both `save_memory` (legacy tool) and any + * direct write_file/edit tool calls the main agent may have issued. + */ +function partWritesToMemory(part: Part, projectRoot: string): boolean { + // save_memory tool call/response + if ( + part.functionCall?.name === 'save_memory' || + part.functionResponse?.name === 'save_memory' + ) { + return true; + } + // Direct write_file or edit tool calls to a memory path + const writeToolNames = new Set(['write_file', 'edit', 'replace', 'create_file']); + const name = part.functionCall?.name; + if (name && writeToolNames.has(name)) { + const args = part.functionCall?.args as Record | undefined; + const filePath = args?.['file_path'] ?? args?.['path'] ?? args?.['target_file']; + if (typeof filePath === 'string' && isAutoMemPath(filePath, projectRoot)) { + return true; + } + } + return false; } -function historySliceUsesMemoryTool(history: Content[]): boolean { +function historySliceUsesMemoryTool(history: Content[], projectRoot: string): boolean { return history.some((message) => - (message.parts ?? []).some(partIsSaveMemoryCall), + (message.parts ?? []).some((part) => partWritesToMemory(part, projectRoot)), ); } @@ -75,7 +95,7 @@ export class ManagedAutoMemoryExtractRuntime { async schedule( params: ScheduleAutoMemoryExtractParams, ): Promise { - if (historySliceUsesMemoryTool(params.history)) { + if (historySliceUsesMemoryTool(params.history, params.projectRoot)) { const task = this.registry.register({ taskType: 'managed-auto-memory-extraction', title: 'Managed auto-memory extraction', diff --git a/packages/core/src/memory/paths.ts b/packages/core/src/memory/paths.ts index a86a597bdcf..5aba317e78d 100644 --- a/packages/core/src/memory/paths.ts +++ b/packages/core/src/memory/paths.ts @@ -88,18 +88,33 @@ export function getMemoryBaseDir(): string { return path.join(os.homedir(), QWEN_DIR); } +// Memoize by projectRoot — findCanonicalGitRoot() walks the file system (existsSync +// per directory) and is called from hot-path code such as schedulers and scanners. +const _autoMemoryRootCache = new Map(); + export function getAutoMemoryRoot(projectRoot: string): string { + const cached = _autoMemoryRootCache.get(projectRoot); + if (cached !== undefined) return cached; + + let result: string; if (process.env['QWEN_CODE_MEMORY_LOCAL'] === '1') { - return path.join(projectRoot, QWEN_DIR, AUTO_MEMORY_DIRNAME); + result = path.join(projectRoot, QWEN_DIR, AUTO_MEMORY_DIRNAME); + } else { + const canonicalRoot = findCanonicalGitRoot(projectRoot) ?? path.resolve(projectRoot); + result = path.join( + getMemoryBaseDir(), + 'projects', + sanitizeCwd(canonicalRoot), + AUTO_MEMORY_DIRNAME, + ); } + _autoMemoryRootCache.set(projectRoot, result); + return result; +} - const canonicalRoot = findCanonicalGitRoot(projectRoot) ?? path.resolve(projectRoot); - return path.join( - getMemoryBaseDir(), - 'projects', - sanitizeCwd(canonicalRoot), - AUTO_MEMORY_DIRNAME, - ); +/** Clear the memoization cache (for tests that change environment or git layout). */ +export function clearAutoMemoryRootCache(): void { + _autoMemoryRootCache.clear(); } /** From 40732867d7ec036e88a3ecf31cd01e0ee862d1fb Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 10 Apr 2026 11:24:32 +0800 Subject: [PATCH 35/56] docs(memory): add user-facing memory docs, i18n for all locales, simplify /forget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add docs/users/features/memory.md: comprehensive user-facing guide covering QWEN.md instructions, auto-memory behaviour, all memory commands, and troubleshooting; replaces the placeholder auto-memory.md - Update docs/users/features/_meta.ts: rename entry auto-memory → memory - Update docs/users/features/commands.md: add /init, /remember, /forget, /dream rows; fix /memory description; remove /init duplicate - Update docs/users/configuration/settings.md: add memory.* settings section (enableManagedAutoMemory, enableManagedAutoDream) between tools and permissions - Remove /forget --apply flag: preview-then-apply flow replaced with direct deletion; update forgetCommand.ts, en.js, zh.js accordingly - Add all auto-memory i18n keys to de, ja, pt, ru locales (18 keys each): Open auto-memory folder, Auto-memory/Auto-dream status lines, never/on/off, ✦ dreaming, /forget and /remember usage strings, all managed-memory messages - Remove dead save_memory branch from extractScheduler.partWritesToMemory() - Add ✦ dreaming indicator to Footer.tsx with i18n; fix Footer.test.tsx mocks - Refactor MemoryDialog.tsx auto-dream status line to use i18n - Remove save_memory tool (memoryTool.ts/test); clean up webui references - Add extractionPlanner.ts, const.ts and associated tests - Delete stale docs/users/configuration/memory.md and docs/developers/tools/memory.md (content superseded) --- docs/developers/tools/_meta.ts | 1 - docs/developers/tools/memory.md | 44 -- docs/users/configuration/memory.md | 0 docs/users/configuration/settings.md | 9 + docs/users/features/_meta.ts | 1 + docs/users/features/commands.md | 5 +- docs/users/features/memory.md | 166 ++++++ integration-tests/globalSetup.ts | 2 +- packages/cli/src/i18n/locales/de.js | 37 ++ packages/cli/src/i18n/locales/en.js | 14 + packages/cli/src/i18n/locales/ja.js | 37 ++ packages/cli/src/i18n/locales/pt.js | 37 ++ packages/cli/src/i18n/locales/ru.js | 37 ++ packages/cli/src/i18n/locales/zh.js | 14 + packages/cli/src/ui/commands/dreamCommand.ts | 2 +- packages/cli/src/ui/commands/forgetCommand.ts | 32 +- .../cli/src/ui/components/Footer.test.tsx | 13 + packages/cli/src/ui/components/Footer.tsx | 2 +- .../cli/src/ui/components/MemoryDialog.tsx | 15 +- packages/core/src/config/config.test.ts | 5 +- packages/core/src/config/config.ts | 10 +- packages/core/src/core/prompts.test.ts | 2 +- packages/core/src/core/prompts.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/memory/const.test.ts | 49 ++ packages/core/src/memory/const.ts | 42 ++ packages/core/src/memory/extract.test.ts | 3 +- packages/core/src/memory/extractModel.test.ts | 5 + .../core/src/memory/extractScheduler.test.ts | 19 - packages/core/src/memory/extractScheduler.ts | 12 +- .../core/src/memory/extractionPlanner.test.ts | 5 + packages/core/src/memory/extractionPlanner.ts | 11 + packages/core/src/permissions/rule-parser.ts | 5 - packages/core/src/tools/memoryTool.test.ts | 514 ----------------- packages/core/src/tools/memoryTool.ts | 543 ------------------ .../core/src/utils/ignorePatterns.test.ts | 2 +- packages/core/src/utils/ignorePatterns.ts | 2 +- .../core/src/utils/memoryDiscovery.test.ts | 2 +- packages/core/src/utils/memoryDiscovery.ts | 2 +- .../components/messages/toolcalls/index.tsx | 6 - .../src/components/ChatViewer/ChatViewer.tsx | 5 - .../toolcalls/SaveMemoryToolCall.tsx | 69 --- .../webui/src/components/toolcalls/index.ts | 1 - packages/webui/src/index.ts | 1 - 44 files changed, 511 insertions(+), 1276 deletions(-) delete mode 100644 docs/developers/tools/memory.md delete mode 100644 docs/users/configuration/memory.md create mode 100644 docs/users/features/memory.md create mode 100644 packages/core/src/memory/const.test.ts create mode 100644 packages/core/src/memory/const.ts create mode 100644 packages/core/src/memory/extractModel.test.ts create mode 100644 packages/core/src/memory/extractionPlanner.test.ts create mode 100644 packages/core/src/memory/extractionPlanner.ts delete mode 100644 packages/core/src/tools/memoryTool.test.ts delete mode 100644 packages/core/src/tools/memoryTool.ts delete mode 100644 packages/webui/src/components/toolcalls/SaveMemoryToolCall.tsx diff --git a/docs/developers/tools/_meta.ts b/docs/developers/tools/_meta.ts index 7d4f494b87d..2662563769f 100644 --- a/docs/developers/tools/_meta.ts +++ b/docs/developers/tools/_meta.ts @@ -8,7 +8,6 @@ export default { 'exit-plan-mode': 'Exit Plan Mode', 'web-fetch': 'Web Fetch', 'web-search': 'Web Search', - memory: 'Memory', 'mcp-server': 'MCP Servers', sandbox: 'Sandboxing', }; diff --git a/docs/developers/tools/memory.md b/docs/developers/tools/memory.md deleted file mode 100644 index 6359f013f0c..00000000000 --- a/docs/developers/tools/memory.md +++ /dev/null @@ -1,44 +0,0 @@ -# Memory Tool (`save_memory`) - -This document describes the `save_memory` tool for Qwen Code. - -## Description - -Use `save_memory` to save and recall information across your Qwen Code sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance. - -### Arguments - -`save_memory` takes one argument: - -- `fact` (string, required): The specific fact or piece of information to remember. This should be a clear, self-contained statement written in natural language. - -## How to use `save_memory` with Qwen Code - -The tool appends the provided `fact` to your context file in the user's home directory (`~/.qwen/QWEN.md` by default). This filename can be configured via `contextFileName`. - -Once added, the facts are stored under a `## Qwen Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information. - -Usage: - -``` -save_memory(fact="Your fact here.") -``` - -### `save_memory` examples - -Remember a user preference: - -``` -save_memory(fact="My preferred programming language is Python.") -``` - -Store a project-specific detail: - -``` -save_memory(fact="The project I'm currently working on is called 'qwen-code'.") -``` - -## Important notes - -- **General usage:** This tool should be used for concise, important facts. It is not intended for storing large amounts of data or conversational history. -- **Memory file:** The memory file is a plain text Markdown file, so you can view and edit it manually if needed. diff --git a/docs/users/configuration/memory.md b/docs/users/configuration/memory.md deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 1c7c20404f8..7ce26aa5c1d 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -227,6 +227,15 @@ If you are experiencing performance issues with file searching (e.g., with `@` c > > **Migrating from `tools.core` / `tools.exclude` / `tools.allowed`:** These legacy settings are **deprecated** and automatically migrated to the new `permissions` format on first load. Prefer configuring `permissions.allow` / `permissions.deny` directly. Use `/permissions` to manage rules interactively. +#### memory + +| Setting | Type | Description | Default | +| ------------------------------------ | ------- | -------------------------------------------------------------------------------- | ------- | +| `memory.enableManagedAutoMemory` | boolean | Enable background extraction of memories from conversations. | `true` | +| `memory.enableManagedAutoDream` | boolean | Enable automatic consolidation (deduplication and cleanup) of collected memories. | `true` | + +See [Memory](../features/memory) for details on how auto-memory works and how to use the `/memory`, `/remember`, `/forget`, and `/dream` commands. + #### permissions The permissions system provides fine-grained control over which tools can run, which require confirmation, and which are blocked. diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts index cb083c35aad..d03e64ca729 100644 --- a/docs/users/features/_meta.ts +++ b/docs/users/features/_meta.ts @@ -3,6 +3,7 @@ export default { 'sub-agents': 'SubAgents', arena: 'Agent Arena', skills: 'Skills', + memory: 'Memory', headless: 'Headless Mode', checkpointing: { display: 'hidden', diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index faa3ec32343..c768cb5ab1d 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -68,7 +68,10 @@ Commands for managing AI tools and models. | →`yolo` | Automatically approve all | Quick prototyping | | `/model` | Switch model used in current session | `/model` | | `/extensions` | List all active extensions in current session | `/extensions` | -| `/memory` | Manage AI's instruction context | `/memory add Important Info` | +| `/memory` | Open the Memory Manager dialog | `/memory` | +| `/remember` | Save a durable memory | `/remember Prefer terse responses` | +| `/forget` | Remove matching entries from auto-memory | `/forget ` | +| `/dream` | Manually run auto-memory consolidation | `/dream` | ### 1.5 Information, Settings, and Help diff --git a/docs/users/features/memory.md b/docs/users/features/memory.md new file mode 100644 index 00000000000..077082a2714 --- /dev/null +++ b/docs/users/features/memory.md @@ -0,0 +1,166 @@ +# Memory + +Every Qwen Code session starts with a fresh context window. Two mechanisms carry knowledge across sessions so you don't have to re-explain yourself every time: + +- **QWEN.md** — instructions *you* write once and Qwen reads every session +- **Auto-memory** — notes Qwen writes itself based on what it learns from you + +--- + +## QWEN.md: your instructions to Qwen + +QWEN.md is a plain text file where you write things Qwen should always know about your project or your preferences. Think of it as a permanent briefing that loads at the start of every conversation. + +### What to put in QWEN.md + +Add things you'd otherwise have to repeat every session: + +- Build and test commands (`npm run test`, `make build`) +- Coding conventions your team follows ("all new files must have JSDoc comments") +- Architectural decisions ("we use the repository pattern, never call the database directly from controllers") +- Personal preferences ("always use pnpm, not npm") + +Don't include things Qwen can figure out by reading your code. QWEN.md works best when it's short and specific — the longer it gets, the less reliably Qwen follows it. + +### Where to create QWEN.md + +| File | Who it applies to | +|---|---| +| `~/.qwen/QWEN.md` | You, across all your projects | +| `QWEN.md` in the project root | Your whole team (commit it to source control) | + +You can have both. Qwen loads all QWEN.md files it finds when you start a session — your personal one plus any in the project. + +If your repository already has an `AGENTS.md` file for other AI tools, Qwen reads that too. No need to duplicate instructions. + +### Generate one automatically with `/init` + +Run `/init` and Qwen will analyze your codebase to create a starter QWEN.md with build commands, test instructions, and conventions it finds. If one already exists, it suggests additions instead of overwriting. + +### Reference other files + +You can point QWEN.md at other files so Qwen reads them too: + +```markdown +See @README.md for project overview. + +# Conventions +- Git workflow: @docs/git-workflow.md +``` + +Use `@path/to/file` anywhere in QWEN.md. Relative paths resolve from the QWEN.md file itself. + +--- + +## Auto-memory: what Qwen learns about you + +Auto-memory runs in the background. After each of your conversations, Qwen quietly saves useful things it learned — your preferences, feedback you gave, project context — so it can use them in future sessions without you repeating yourself. + +This is different from QWEN.md: you don't write it, Qwen does. + +### What Qwen saves + +Qwen looks for four kinds of things worth remembering: + +| What | Examples | +|---|---| +| **About you** | Your role, background, how you like to work | +| **Your feedback** | Corrections you made, approaches you confirmed | +| **Project context** | Ongoing work, decisions, goals not obvious from the code | +| **External references** | Dashboards, ticket trackers, docs links you mentioned | + +Qwen doesn't save everything — only things that would actually be useful next time. + +### Where it's stored + +Auto-memory files live at `~/.qwen/projects//memory/`. All branches and worktrees of the same repository share the same memory folder, so what Qwen learns in one branch is available in others. + +Everything saved is plain markdown — you can open, edit, or delete any file at any time. + +### Periodic cleanup + +Qwen periodically goes through its saved memories to remove duplicates and clean up outdated entries. This runs automatically in the background once a day after enough sessions have accumulated. You can trigger it manually with `/dream` if you want it to run now. + +While cleanup is running, **✦ dreaming** appears in the corner of the screen. Your session continues normally. + +### Turning it on or off + +Auto-memory is on by default. To toggle it, open `/memory` and use the switches at the top. You can turn off just the automatic saving, just the periodic cleanup, or both. + +You can also set them in `~/.qwen/settings.json` (applies to all projects) or `.qwen/settings.json` (this project only): + +```json +{ + "memory": { + "enableManagedAutoMemory": true, + "enableManagedAutoDream": true + } +} +``` + +--- + +## Commands + +### `/memory` + +Opens the Memory panel. From here you can: + +- Turn auto-memory saving on or off +- Turn periodic cleanup (dream) on or off +- Open your personal QWEN.md (`~/.qwen/QWEN.md`) +- Open the project QWEN.md +- Browse the auto-memory folder + +### `/init` + +Generates a starter QWEN.md for your project. Qwen reads your codebase and fills in build commands, test instructions, and conventions it discovers. + +### `/remember ` + +Immediately saves something to auto-memory without waiting for Qwen to pick it up automatically: + +``` +/remember always use snake_case for Python variable names +/remember the staging environment is at staging.example.com +``` + +### `/forget ` + +Removes auto-memory entries that match your description: + +``` +/forget old workaround for the login bug +``` + +### `/dream` + +Runs the memory cleanup now instead of waiting for the automatic schedule: + +``` +/dream +``` + +--- + +## Troubleshooting + +### Qwen isn't following my QWEN.md + +Open `/memory` to see which files are loaded. If your file isn't listed, Qwen can't see it — make sure it's in the project root or `~/.qwen/`. + +Instructions work better when they're specific: +- ✓ `Use 2-space indentation for TypeScript files` +- ✗ `Format code nicely` + +If you have multiple QWEN.md files with conflicting instructions, Qwen may behave inconsistently. Review them and remove any contradictions. + +### I want to see what Qwen has saved + +Run `/memory` and select **Open auto-memory folder**. All saved memories are readable markdown files you can browse, edit, or delete. + +### Qwen keeps forgetting things + +If auto-memory is on but Qwen doesn't seem to remember things across sessions, try running `/dream` to force a cleanup pass. Also check `/memory` to confirm both toggles are enabled. + +For things you always want Qwen to remember, add them to QWEN.md instead — auto-memory is best-effort, QWEN.md is guaranteed. diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index 02cea685988..ef763eec5da 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -24,7 +24,7 @@ import * as os from 'node:os'; import { QWEN_CONFIG_DIR, DEFAULT_CONTEXT_FILENAME, -} from '../packages/core/src/tools/memoryTool.js'; +} from '@qwen-code/qwen-code-core/src/memory/const.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index cb3229a2bed..b6773ab6a66 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -868,6 +868,43 @@ export default { 'Verwendung: /memory add [--global|--project] ', 'Attempting to save to memory {{scope}}: "{{fact}}"': 'Versuche im Speicher {{scope}} zu speichern: "{{fact}}"', + 'Open auto-memory folder': 'Auto-Speicher-Ordner öffnen', + 'Auto-memory: {{status}}': 'Auto-Speicher: {{status}}', + 'Auto-dream: {{status}} · {{lastDream}} · /dream to run': + 'Auto-Konsolidierung: {{status}} · {{lastDream}} · /dream zum Ausführen', + never: 'nie', + on: 'ein', + off: 'aus', + '❆ dreaming': '❆ konsolidiert', + 'Remove matching entries from managed auto-memory.': + 'Passende Einträge aus dem verwalteten Auto-Speicher entfernen.', + 'Usage: /forget ': + 'Verwendung: /forget ', + 'No managed auto-memory entries matched: {{query}}': + 'Keine verwalteten Auto-Speicher-Einträge gefunden: {{query}}', + 'Show managed auto-memory status.': 'Status des verwalteten Auto-Speichers anzeigen.', + 'Run managed auto-memory extraction for the current session.': + 'Verwaltete Auto-Speicher-Extraktion für die aktuelle Sitzung ausführen.', + 'Managed auto-memory root: {{root}}': 'Verwalteter Auto-Speicher-Stamm: {{root}}', + 'Managed auto-memory topics:': 'Verwaltete Auto-Speicher-Themen:', + 'No extraction cursor found yet.': 'Noch kein Extraktions-Cursor gefunden.', + 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}': + 'Cursor: Sitzung={{sessionId}}, Offset={{offset}}, Aktualisiert={{updatedAt}}', + 'No chat client available to extract memory.': + 'Kein Chat-Client verfügbar, um Erinnerungen zu extrahieren.', + 'Managed auto-memory extraction is already running.': + 'Verwaltete Auto-Speicher-Extraktion läuft bereits.', + 'Managed auto-memory extraction found no new durable memories.': + 'Verwaltete Auto-Speicher-Extraktion hat keine neuen dauerhaften Erinnerungen gefunden.', + 'Consolidate managed auto-memory topic files.': + 'Verwaltete Auto-Speicher-Themendateien konsolidieren.', + 'Managed auto-memory dream found nothing to improve.': + 'Auto-Speicher-Konsolidierung hat nichts zu verbessern gefunden.', + 'Deduplicated entries: {{count}}': 'Deduplizierte Einträge: {{count}}', + 'Save a durable memory using the save_memory tool.': + 'Eine dauerhafte Erinnerung mit dem save_memory-Tool speichern.', + 'Usage: /remember [--global|--project] ': + 'Verwendung: /remember [--global|--project] ', // ============================================================================ // Commands - MCP diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index faa9c25cbbf..5d4fc1adfdd 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -930,6 +930,20 @@ export default { 'Usage: /memory add [--global|--project] ', 'Attempting to save to memory {{scope}}: "{{fact}}"': 'Attempting to save to memory {{scope}}: "{{fact}}"', + 'Open auto-memory folder': 'Open auto-memory folder', + 'Auto-memory: {{status}}': 'Auto-memory: {{status}}', + 'Auto-dream: {{status}} · {{lastDream}} · /dream to run': + 'Auto-dream: {{status}} · {{lastDream}} · /dream to run', + never: 'never', + on: 'on', + off: 'off', + '✦ dreaming': '✦ dreaming', + 'Remove matching entries from managed auto-memory.': + 'Remove matching entries from managed auto-memory.', + 'Usage: /forget ': + 'Usage: /forget ', + 'No managed auto-memory entries matched: {{query}}': + 'No managed auto-memory entries matched: {{query}}', 'Show managed auto-memory status.': 'Show managed auto-memory status.', 'Run managed auto-memory extraction for the current session.': 'Run managed auto-memory extraction for the current session.', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index ac5f5911166..2ce56814d40 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -631,6 +631,43 @@ export default { '使い方: /memory add [--global|--project] <記憶するテキスト>', 'Attempting to save to memory {{scope}}: "{{fact}}"': 'メモリ {{scope}} への保存を試行中: "{{fact}}"', + 'Open auto-memory folder': '自動メモリフォルダを開く', + 'Auto-memory: {{status}}': '自動メモリ: {{status}}', + 'Auto-dream: {{status}} · {{lastDream}} · /dream to run': + '自動統合: {{status}} · {{lastDream}} · /dream で実行', + never: '未実行', + on: 'オン', + off: 'オフ', + '❆ dreaming': '❆ 整理中', + 'Remove matching entries from managed auto-memory.': + 'マネージド自動メモリから一致するエントリを削除する。', + 'Usage: /forget ': + '使い方: /forget <削除するメモリテキスト>', + 'No managed auto-memory entries matched: {{query}}': + '一致するマネージド自動メモリエントリなし: {{query}}', + 'Show managed auto-memory status.': 'マネージド自動メモリのステータスを表示する。', + 'Run managed auto-memory extraction for the current session.': + '現在のセッションのマネージド自動メモリ抽出を実行する。', + 'Managed auto-memory root: {{root}}': 'マネージド自動メモリのルート: {{root}}', + 'Managed auto-memory topics:': 'マネージド自動メモリのトピック:', + 'No extraction cursor found yet.': 'まだ抽出カーソルが見つかりません。', + 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}': + 'カーソル: セッション={{sessionId}}, オフセット={{offset}}, 更新={{updatedAt}}', + 'No chat client available to extract memory.': + 'メモリを抽出できるチャットクライアントがありません。', + 'Managed auto-memory extraction is already running.': + 'マネージド自動メモリ抽出はすでに実行中です。', + 'Managed auto-memory extraction found no new durable memories.': + 'マネージド自動メモリ抽出で新しい永続メモリは見つかりませんでした。', + 'Consolidate managed auto-memory topic files.': + 'マネージド自動メモリトピックファイルを統合する。', + 'Managed auto-memory dream found nothing to improve.': + '自動メモリ統合で改善するものは見つかりませんでした。', + 'Deduplicated entries: {{count}}': '重複除去したエントリ: {{count}}', + 'Save a durable memory using the save_memory tool.': + 'save_memoryツールを使用して永続メモリを保存する。', + 'Usage: /remember [--global|--project] ': + '使い方: /remember [--global|--project] <覚えておくテキスト>', // MCP 'Authenticate with an OAuth-enabled MCP server': 'OAuth対応のMCPサーバーで認証', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 993cd8d8c1d..a4154613307 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -873,6 +873,43 @@ export default { 'Uso: /memory add [--global|--project] ', 'Attempting to save to memory {{scope}}: "{{fact}}"': 'Tentando salvar na memória {{scope}}: "{{fact}}"', + 'Open auto-memory folder': 'Abrir pasta de memória automática', + 'Auto-memory: {{status}}': 'Memória automática: {{status}}', + 'Auto-dream: {{status}} · {{lastDream}} · /dream to run': + 'Consolidação automática: {{status}} · {{lastDream}} · /dream para executar', + never: 'nunca', + on: 'ativado', + off: 'desativado', + '❆ dreaming': '❆ consolidando', + 'Remove matching entries from managed auto-memory.': + 'Remover entradas correspondentes da memória automática gerenciada.', + 'Usage: /forget ': + 'Uso: /forget ', + 'No managed auto-memory entries matched: {{query}}': + 'Nenhuma entrada de memória automática gerenciada correspondeu: {{query}}', + 'Show managed auto-memory status.': 'Mostrar status da memória automática gerenciada.', + 'Run managed auto-memory extraction for the current session.': + 'Executar extração de memória automática gerenciada para a sessão atual.', + 'Managed auto-memory root: {{root}}': 'Raiz da memória automática gerenciada: {{root}}', + 'Managed auto-memory topics:': 'Tópicos de memória automática gerenciada:', + 'No extraction cursor found yet.': 'Nenhum cursor de extração encontrado ainda.', + 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}': + 'Cursor: sessão={{sessionId}}, offset={{offset}}, atualizado={{updatedAt}}', + 'No chat client available to extract memory.': + 'Nenhum cliente de chat disponível para extrair memória.', + 'Managed auto-memory extraction is already running.': + 'A extração de memória automática gerenciada já está em execução.', + 'Managed auto-memory extraction found no new durable memories.': + 'A extração de memória automática gerenciada não encontrou novas memórias duráveis.', + 'Consolidate managed auto-memory topic files.': + 'Consolidar arquivos de tópicos de memória automática gerenciada.', + 'Managed auto-memory dream found nothing to improve.': + 'A consolidação de memória automática não encontrou nada para melhorar.', + 'Deduplicated entries: {{count}}': 'Entradas desduplicadas: {{count}}', + 'Save a durable memory using the save_memory tool.': + 'Salvar uma memória durável usando a ferramenta save_memory.', + 'Usage: /remember [--global|--project] ': + 'Uso: /remember [--global|--project] ', // ============================================================================ // Commands - MCP diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index bb7e8968f76..09b3a0d281f 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -876,6 +876,43 @@ export default { 'Использование: /memory add [--global|--project] <текст для запоминания>', 'Attempting to save to memory {{scope}}: "{{fact}}"': 'Попытка сохранить в память {{scope}}: "{{fact}}"', + 'Open auto-memory folder': 'Открыть папку автопамяти', + 'Auto-memory: {{status}}': 'Автопамять: {{status}}', + 'Auto-dream: {{status}} · {{lastDream}} · /dream to run': + 'Автоконсолидация: {{status}} · {{lastDream}} · /dream для запуска', + never: 'никогда', + on: 'вкл', + off: 'выкл', + '❆ dreaming': '❆ консолидация', + 'Remove matching entries from managed auto-memory.': + 'Удалить совпадающие записи из управляемой автопамяти.', + 'Usage: /forget ': + 'Использование: /forget <текст воспоминания для удаления>', + 'No managed auto-memory entries matched: {{query}}': + 'Не найдено совпадающих записей автопамяти: {{query}}', + 'Show managed auto-memory status.': 'Показать статус управляемой автопамяти.', + 'Run managed auto-memory extraction for the current session.': + 'Запустить извлечение управляемой автопамяти для текущей сессии.', + 'Managed auto-memory root: {{root}}': 'Корневая директория управляемой автопамяти: {{root}}', + 'Managed auto-memory topics:': 'Темы управляемой автопамяти:', + 'No extraction cursor found yet.': 'Курсор извлечения ещё не найден.', + 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}': + 'Курсор: сессия={{sessionId}}, смещение={{offset}}, обновлено={{updatedAt}}', + 'No chat client available to extract memory.': + 'Нет доступного чат-клиента для извлечения памяти.', + 'Managed auto-memory extraction is already running.': + 'Извлечение управляемой автопамяти уже выполняется.', + 'Managed auto-memory extraction found no new durable memories.': + 'Извлечение управляемой автопамяти не нашло новых долгосрочных воспоминаний.', + 'Consolidate managed auto-memory topic files.': + 'Консолидировать файлы тем управляемой автопамяти.', + 'Managed auto-memory dream found nothing to improve.': + 'Консолидация автопамяти не нашла чего улучшать.', + 'Deduplicated entries: {{count}}': 'Удалено дубликатов: {{count}}', + 'Save a durable memory using the save_memory tool.': + 'Сохранить долгосрочную память с помощью инструмента save_memory.', + 'Usage: /remember [--global|--project] ': + 'Использование: /remember [--global|--project] <текст для запоминания>', // ============================================================================ // Команды - MCP diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 74ca2d799be..b4c60f53557 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -880,6 +880,20 @@ export default { '用法:/memory add [--global|--project] <要记住的文本>', 'Attempting to save to memory {{scope}}: "{{fact}}"': '正在尝试保存到记忆 {{scope}}:"{{fact}}"', + 'Open auto-memory folder': '打开自动记忆文件夹', + 'Auto-memory: {{status}}': '自动记忆:{{status}}', + 'Auto-dream: {{status}} · {{lastDream}} · /dream to run': + '自动整理:{{status}} · {{lastDream}} · /dream 立即运行', + never: '从未', + on: '开', + off: '关', + '✦ dreaming': '✦ 整理中', + 'Remove matching entries from managed auto-memory.': + '从托管自动记忆中删除匹配的条目。', + 'Usage: /forget ': + '用法:/forget <要删除的记忆文本>', + 'No managed auto-memory entries matched: {{query}}': + '没有匹配的托管自动记忆条目:{{query}}', 'Show managed auto-memory status.': '显示托管自动记忆状态', 'Run managed auto-memory extraction for the current session.': '为当前会话运行托管自动记忆提炼', diff --git a/packages/cli/src/ui/commands/dreamCommand.ts b/packages/cli/src/ui/commands/dreamCommand.ts index a8fd0195c15..09d2aea158f 100644 --- a/packages/cli/src/ui/commands/dreamCommand.ts +++ b/packages/cli/src/ui/commands/dreamCommand.ts @@ -42,7 +42,7 @@ export const dreamCommand: SlashCommand = { type: 'submit_prompt', content: prompt, onComplete: async () => { - await writeDreamManualRunToMetadata( + await writeDreamManualRunToMetadata( projectRoot, config.getSessionId(), ); diff --git a/packages/cli/src/ui/commands/forgetCommand.ts b/packages/cli/src/ui/commands/forgetCommand.ts index 06c81b6ba98..6fb2f0b3274 100644 --- a/packages/cli/src/ui/commands/forgetCommand.ts +++ b/packages/cli/src/ui/commands/forgetCommand.ts @@ -19,17 +19,13 @@ export const forgetCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, action: async (context, args) => { - const trimmedArgs = args.trim(); - const apply = trimmedArgs.startsWith('--apply '); - const query = apply - ? trimmedArgs.slice('--apply '.length).trim() - : trimmedArgs; + const query = args.trim(); if (!query) { return { type: 'message', messageType: 'error', - content: t('Usage: /forget [--apply] '), + content: t('Usage: /forget '), }; } @@ -48,30 +44,6 @@ export const forgetCommand: SlashCommand = { { config }, ); - if (!apply) { - return { - type: 'message', - messageType: 'info', - content: - selection.matches.length > 0 - ? [ - t('Forget preview (strategy={{strategy}}):', { - strategy: selection.strategy, - }), - ...(selection.reasoning ? [selection.reasoning] : []), - ...selection.matches.map( - (match, index) => - `${index + 1}. ${match.topic}: ${match.summary}`, - ), - '', - t('Run /forget --apply {{query}} to apply these removals.', { - query, - }), - ].join('\n') - : t('No managed auto-memory entries matched: {{query}}', { query }), - }; - } - const result = await forgetManagedAutoMemoryMatches( config.getProjectRoot(), selection.matches, diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index f2b759e69b6..c5a374c62b6 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -16,6 +16,18 @@ import type { LoadedSettings } from '../../config/settings.js'; vi.mock('../hooks/useTerminalSize.js'); const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = await importOriginal(); + const registry = { + list: vi.fn(() => []), + subscribe: vi.fn(() => () => {}), + }; + return { + ...actual, + getManagedAutoMemoryDreamTaskRegistry: vi.fn(() => registry), + }; +}); + const defaultProps = { model: 'gemini-pro', }; @@ -26,6 +38,7 @@ const createMockConfig = (overrides = {}) => ({ getContentGeneratorConfig: vi.fn(() => ({ contextWindowSize: 131072 })), getMcpServers: vi.fn(() => ({})), getBlockedMcpServers: vi.fn(() => []), + getProjectRoot: vi.fn(() => '/test/project'), ...overrides, }); diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 6483cc614e1..fa74ceba05e 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -113,7 +113,7 @@ export const Footer: React.FC = () => { if (dreamRunning) { rightItems.push({ key: 'dream', - node: ✦ dreaming, + node: {t('✦ dreaming')}, }); } if (promptTokenCount > 0 && contextWindowSize) { diff --git a/packages/cli/src/ui/components/MemoryDialog.tsx b/packages/cli/src/ui/components/MemoryDialog.tsx index dd7fab733da..c0e781e49c3 100644 --- a/packages/cli/src/ui/components/MemoryDialog.tsx +++ b/packages/cli/src/ui/components/MemoryDialog.tsx @@ -14,6 +14,7 @@ import { getAllGeminiMdFilenames, QWEN_DIR, getAutoMemoryRoot, + getAutoMemoryProjectStateDir, } from '@qwen-code/qwen-code-core'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; @@ -142,6 +143,11 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { [config], ); + const memoryStatePath = useMemo( + () => getAutoMemoryProjectStateDir(config.getProjectRoot()), + [config], + ); + const items = useMemo( () => [ { @@ -174,7 +180,7 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { async function loadMeta() { try { - const metadataPath = path.join(managedMemoryPath, 'meta.json'); + const metadataPath = path.join(memoryStatePath, 'meta.json'); const content = await fs.readFile(metadataPath, 'utf-8'); const parsed = JSON.parse(content) as { lastDreamAt?: string }; if (!cancelled && parsed.lastDreamAt) { @@ -192,7 +198,7 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { return () => { cancelled = true; }; - }, [managedMemoryPath]); + }, [memoryStatePath]); const dreamStatusText = useMemo(() => { if (lastDreamAt !== null) return formatRelativeTime(lastDreamAt); @@ -363,7 +369,10 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { } > {focusedSection === 'autoDream' ? '› ' : ' '} - {`Auto-dream: ${autoDreamOn ? 'on' : 'off'} · ${dreamStatusText} · /dream to run`} + {t('Auto-dream: {{status}} · {{lastDream}} · /dream to run', { + status: autoDreamOn ? t('on') : t('off'), + lastDream: dreamStatusText, + })} diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1ebc1699f39..9d8109b345c 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -9,7 +9,7 @@ import type { Mock } from 'vitest'; import type { ConfigParameters, SandboxConfig } from './config.js'; import { Config, ApprovalMode } from './config.js'; import * as path from 'node:path'; -import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js'; +import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../memory/const.js'; import { DEFAULT_TELEMETRY_TARGET, DEFAULT_OTLP_ENDPOINT, @@ -122,8 +122,7 @@ vi.mock('../tools/web-fetch', () => ({ vi.mock('../tools/read-many-files', () => ({ ReadManyFilesTool: createToolMock('read_many_files'), })); -vi.mock('../tools/memoryTool', () => ({ - MemoryTool: createToolMock('save_memory'), +vi.mock('../memory/const.js', () => ({ setGeminiMdFilename: vi.fn(), getCurrentGeminiMdFilename: vi.fn(() => 'QWEN.md'), // Mock the original filename getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index bf9c5fec138..f044c10da4f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -50,7 +50,7 @@ import { GlobTool } from '../tools/glob.js'; import { GrepTool } from '../tools/grep.js'; import { LSTool } from '../tools/ls.js'; import type { SendSdkMcpMessage } from '../tools/mcp-client.js'; -import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; +import { setGeminiMdFilename } from '../memory/const.js'; import { ReadFileTool } from '../tools/read-file.js'; import { canUseRipgrep } from '../utils/ripgrepUtils.js'; import { RipGrepTool } from '../tools/ripGrep.js'; @@ -2215,14 +2215,6 @@ export class Config { await registerCoreTool(EditTool, this); await registerCoreTool(WriteFileTool, this); await registerCoreTool(ShellTool, this); - // When managed auto-memory is enabled, the model writes per-entry files - // directly (as instructed by buildManagedAutoMemoryPrompt). The legacy - // save_memory tool writes to a single QWEN.md file and conflicts with that - // model. Claude Code solves this the same way: no save_memory tool exists - // when the file-based memory system is active. - if (!this.getManagedAutoMemoryEnabled()) { - await registerCoreTool(MemoryTool); - } await registerCoreTool(TodoWriteTool, this); await registerCoreTool(AskUserQuestionTool, this); !this.sdkMode && (await registerCoreTool(ExitPlanModeTool, this)); diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index b0947e98fb7..9438ecf92dd 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -16,7 +16,7 @@ import { isGitRepository } from '../utils/gitUtils.js'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { QWEN_CONFIG_DIR } from '../tools/memoryTool.js'; +import { QWEN_CONFIG_DIR } from '../memory/const.js'; // Mock tool names if they are dynamically generated or complex vi.mock('../tools/ls', () => ({ LSTool: { Name: 'list_directory' } })); diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index e52aff2fcd0..3b741bdfe33 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -10,7 +10,7 @@ import os from 'node:os'; import { ToolNames } from '../tools/tool-names.js'; import process from 'node:process'; import { isGitRepository } from '../utils/gitUtils.js'; -import { QWEN_CONFIG_DIR } from '../tools/memoryTool.js'; +import { QWEN_CONFIG_DIR } from '../memory/const.js'; import type { GenerateContentConfig } from '@google/genai'; import { createDebugLogger } from '../utils/debugLogger.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 38e3e57b8ca..5eaf9bf15e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -84,7 +84,7 @@ export * from './tools/lsp.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; -export * from './tools/memoryTool.js'; +export * from './memory/const.js'; export * from './tools/read-file.js'; export * from './tools/ripGrep.js'; export * from './tools/sdk-control-client-transport.js'; diff --git a/packages/core/src/memory/const.test.ts b/packages/core/src/memory/const.test.ts new file mode 100644 index 00000000000..3f6f449b88e --- /dev/null +++ b/packages/core/src/memory/const.test.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect } from 'vitest'; +import { + setGeminiMdFilename, + getCurrentGeminiMdFilename, + getAllGeminiMdFilenames, +} from './const.js'; + +// Mock dependencies +vi.mock(import('node:fs/promises'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mkdir: vi.fn(), + readFile: vi.fn(), + }; +}); + +vi.mock('os'); + + +describe('setGeminiMdFilename', () => { + it('should update currentGeminiMdFilename when a valid new name is provided', () => { + const newName = 'CUSTOM_CONTEXT.md'; + setGeminiMdFilename(newName); + expect(getCurrentGeminiMdFilename()).toBe(newName); + }); + + it('should not update currentGeminiMdFilename if the new name is empty or whitespace', () => { + const initialName = getCurrentGeminiMdFilename(); // Get current before trying to change + setGeminiMdFilename(' '); + expect(getCurrentGeminiMdFilename()).toBe(initialName); + + setGeminiMdFilename(''); + expect(getCurrentGeminiMdFilename()).toBe(initialName); + }); + + it('should handle an array of filenames', () => { + const newNames = ['CUSTOM_CONTEXT.md', 'ANOTHER_CONTEXT.md']; + setGeminiMdFilename(newNames); + expect(getCurrentGeminiMdFilename()).toBe('CUSTOM_CONTEXT.md'); + expect(getAllGeminiMdFilenames()).toEqual(newNames); + }); + }); diff --git a/packages/core/src/memory/const.ts b/packages/core/src/memory/const.ts new file mode 100644 index 00000000000..1e7df9ceb19 --- /dev/null +++ b/packages/core/src/memory/const.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const QWEN_CONFIG_DIR = '.qwen'; +export const DEFAULT_CONTEXT_FILENAME = 'QWEN.md'; +export const AGENT_CONTEXT_FILENAME = 'AGENTS.md'; +export const MEMORY_SECTION_HEADER = '## Qwen Added Memories'; + +// This variable will hold the currently configured filename for context files. +// It defaults to include both QWEN.md and AGENTS.md but can be overridden by setGeminiMdFilename. +// QWEN.md is first to maintain backward compatibility (used by /init command tool). +let currentGeminiMdFilename: string | string[] = [ + DEFAULT_CONTEXT_FILENAME, + AGENT_CONTEXT_FILENAME, +]; + +export function setGeminiMdFilename(newFilename: string | string[]): void { + if (Array.isArray(newFilename)) { + if (newFilename.length > 0) { + currentGeminiMdFilename = newFilename.map((name) => name.trim()); + } + } else if (newFilename && newFilename.trim() !== '') { + currentGeminiMdFilename = newFilename.trim(); + } +} + +export function getCurrentGeminiMdFilename(): string { + if (Array.isArray(currentGeminiMdFilename)) { + return currentGeminiMdFilename[0]; + } + return currentGeminiMdFilename; +} + +export function getAllGeminiMdFilenames(): string[] { + if (Array.isArray(currentGeminiMdFilename)) { + return currentGeminiMdFilename; + } + return [currentGeminiMdFilename]; +} diff --git a/packages/core/src/memory/extract.test.ts b/packages/core/src/memory/extract.test.ts index 357461a064c..2d5f1fbbaa3 100644 --- a/packages/core/src/memory/extract.test.ts +++ b/packages/core/src/memory/extract.test.ts @@ -9,14 +9,13 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { getAutoMemoryExtractCursorPath, getAutoMemoryIndexPath } from './paths.js'; +import { getAutoMemoryExtractCursorPath } from './paths.js'; import { buildTranscriptMessages, loadUnprocessedTranscriptSlice, runAutoMemoryExtract, } from './extract.js'; import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; -import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { resetAutoMemoryStateForTests } from './state.js'; diff --git a/packages/core/src/memory/extractModel.test.ts b/packages/core/src/memory/extractModel.test.ts new file mode 100644 index 00000000000..080c373e2b1 --- /dev/null +++ b/packages/core/src/memory/extractModel.test.ts @@ -0,0 +1,5 @@ +/** + * Deprecated with the model/heuristic fallback removal. Intentionally empty. + */ + +export {}; diff --git a/packages/core/src/memory/extractScheduler.test.ts b/packages/core/src/memory/extractScheduler.test.ts index 6d14255f042..8f377c7251e 100644 --- a/packages/core/src/memory/extractScheduler.test.ts +++ b/packages/core/src/memory/extractScheduler.test.ts @@ -133,25 +133,6 @@ describe('managed auto-memory extraction runtime', () => { expect(tasks.some((task) => task.metadata?.['trailing'] === true)).toBe(true); }); - it('skips extraction when save_memory was used in the same slice', async () => { - const runtime = createManagedAutoMemoryExtractRuntimeForTests(); - - const result = await runtime.schedule({ - projectRoot, - sessionId: 'session-1', - config: mockConfig, - history: [ - { - role: 'model', - parts: [{ functionResponse: { name: 'save_memory', response: { output: 'ok' } } }], - }, - ], - }); - - expect(result.skippedReason).toBe('memory_tool'); - expect(runtime.listTasks(projectRoot)[0]?.status).toBe('skipped'); - }); - it('returns already_running when extraction state is externally locked', async () => { markExtractRunning(projectRoot); const runtime = createManagedAutoMemoryExtractRuntimeForTests(); diff --git a/packages/core/src/memory/extractScheduler.ts b/packages/core/src/memory/extractScheduler.ts index db30a3c208c..25045b738e6 100644 --- a/packages/core/src/memory/extractScheduler.ts +++ b/packages/core/src/memory/extractScheduler.ts @@ -55,17 +55,9 @@ function buildSkippedExtractResult( /** * Returns true if the part is a write-tool call targeting a file path inside - * the auto-memory directory. Covers both `save_memory` (legacy tool) and any - * direct write_file/edit tool calls the main agent may have issued. + * the auto-memory directory (write_file / edit / replace / create_file). */ function partWritesToMemory(part: Part, projectRoot: string): boolean { - // save_memory tool call/response - if ( - part.functionCall?.name === 'save_memory' || - part.functionResponse?.name === 'save_memory' - ) { - return true; - } // Direct write_file or edit tool calls to a memory path const writeToolNames = new Set(['write_file', 'edit', 'replace', 'create_file']); const name = part.functionCall?.name; @@ -109,7 +101,7 @@ export class ManagedAutoMemoryExtractRuntime { this.registry.update(task.id, { status: 'skipped', progressText: - 'Skipped managed auto-memory extraction because save_memory already handled this turn.', + 'Skipped managed auto-memory extraction: main agent wrote to memory files this turn.', }); return buildSkippedExtractResult(params, 'memory_tool'); } diff --git a/packages/core/src/memory/extractionPlanner.test.ts b/packages/core/src/memory/extractionPlanner.test.ts new file mode 100644 index 00000000000..7f6f6904ee5 --- /dev/null +++ b/packages/core/src/memory/extractionPlanner.test.ts @@ -0,0 +1,5 @@ +/** + * Deprecated with the model-planner removal. Intentionally empty. + */ + +export {}; diff --git a/packages/core/src/memory/extractionPlanner.ts b/packages/core/src/memory/extractionPlanner.ts new file mode 100644 index 00000000000..c071f439dc9 --- /dev/null +++ b/packages/core/src/memory/extractionPlanner.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Deprecated: managed auto-memory extraction no longer has a separate +// model-planner stage. Extraction now runs directly through the forked agent +// path implemented in extractionAgentPlanner.ts. + +export {}; diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index 6ca9e83632c..005821fc89b 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -79,11 +79,6 @@ export const TOOL_NAME_ALIASES: Readonly> = { ListFilesTool: 'list_directory', ReadFolder: 'list_directory', // legacy display name - // Memory tool - save_memory: 'save_memory', - SaveMemory: 'save_memory', - SaveMemoryTool: 'save_memory', - // TodoWrite tool todo_write: 'todo_write', TodoWrite: 'todo_write', diff --git a/packages/core/src/tools/memoryTool.test.ts b/packages/core/src/tools/memoryTool.test.ts deleted file mode 100644 index 7050ab7feb2..00000000000 --- a/packages/core/src/tools/memoryTool.test.ts +++ /dev/null @@ -1,514 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Mock } from 'vitest'; -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { - MemoryTool, - setGeminiMdFilename, - getCurrentGeminiMdFilename, - getAllGeminiMdFilenames, - DEFAULT_CONTEXT_FILENAME, -} from './memoryTool.js'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import * as os from 'node:os'; -import { ToolConfirmationOutcome } from './tools.js'; -import { ToolErrorType } from './tool-error.js'; - -// Mock dependencies -vi.mock(import('node:fs/promises'), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - mkdir: vi.fn(), - readFile: vi.fn(), - }; -}); - -vi.mock('os'); - -const MEMORY_SECTION_HEADER = '## Qwen Added Memories'; - -// Define a type for our fsAdapter to ensure consistency -interface FsAdapter { - readFile: (path: string, encoding: 'utf-8') => Promise; - writeFile: (path: string, data: string, encoding: 'utf-8') => Promise; - mkdir: ( - path: string, - options: { recursive: boolean }, - ) => Promise; -} - -describe('MemoryTool', () => { - const mockAbortSignal = new AbortController().signal; - - const mockFsAdapter: { - readFile: Mock; - writeFile: Mock; - mkdir: Mock; - } = { - readFile: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), - }; - - beforeEach(() => { - vi.mocked(os.homedir).mockReturnValue(path.join('/mock', 'home')); - mockFsAdapter.readFile.mockReset(); - mockFsAdapter.writeFile.mockReset().mockResolvedValue(undefined); - mockFsAdapter.mkdir - .mockReset() - .mockResolvedValue(undefined as string | undefined); - }); - - afterEach(() => { - vi.restoreAllMocks(); - // Reset GEMINI_MD_FILENAME to its original value after each test - setGeminiMdFilename(DEFAULT_CONTEXT_FILENAME); - }); - - describe('setGeminiMdFilename', () => { - it('should update currentGeminiMdFilename when a valid new name is provided', () => { - const newName = 'CUSTOM_CONTEXT.md'; - setGeminiMdFilename(newName); - expect(getCurrentGeminiMdFilename()).toBe(newName); - }); - - it('should not update currentGeminiMdFilename if the new name is empty or whitespace', () => { - const initialName = getCurrentGeminiMdFilename(); // Get current before trying to change - setGeminiMdFilename(' '); - expect(getCurrentGeminiMdFilename()).toBe(initialName); - - setGeminiMdFilename(''); - expect(getCurrentGeminiMdFilename()).toBe(initialName); - }); - - it('should handle an array of filenames', () => { - const newNames = ['CUSTOM_CONTEXT.md', 'ANOTHER_CONTEXT.md']; - setGeminiMdFilename(newNames); - expect(getCurrentGeminiMdFilename()).toBe('CUSTOM_CONTEXT.md'); - expect(getAllGeminiMdFilenames()).toEqual(newNames); - }); - }); - - describe('performAddMemoryEntry (static method)', () => { - let testFilePath: string; - - beforeEach(() => { - testFilePath = path.join(os.homedir(), '.qwen', DEFAULT_CONTEXT_FILENAME); - }); - - it('should create section and save a fact if file does not exist', async () => { - mockFsAdapter.readFile.mockRejectedValue({ code: 'ENOENT' }); // Simulate file not found - const fact = 'The sky is blue'; - await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); - - expect(mockFsAdapter.mkdir).toHaveBeenCalledWith( - path.dirname(testFilePath), - { - recursive: true, - }, - ); - expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); - const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; - expect(writeFileCall[0]).toBe(testFilePath); - const expectedContent = `${MEMORY_SECTION_HEADER}\n- ${fact}\n`; - expect(writeFileCall[1]).toBe(expectedContent); - expect(writeFileCall[2]).toBe('utf-8'); - }); - - it('should create section and save a fact if file is empty', async () => { - mockFsAdapter.readFile.mockResolvedValue(''); // Simulate empty file - const fact = 'The sky is blue'; - await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); - const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; - const expectedContent = `${MEMORY_SECTION_HEADER}\n- ${fact}\n`; - expect(writeFileCall[1]).toBe(expectedContent); - }); - - it('should add a fact to an existing section', async () => { - const initialContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- Existing fact 1\n`; - mockFsAdapter.readFile.mockResolvedValue(initialContent); - const fact = 'New fact 2'; - await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); - - expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); - const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; - const expectedContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- Existing fact 1\n- ${fact}\n`; - expect(writeFileCall[1]).toBe(expectedContent); - }); - - it('should add a fact to an existing empty section', async () => { - const initialContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n`; // Empty section - mockFsAdapter.readFile.mockResolvedValue(initialContent); - const fact = 'First fact in section'; - await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); - - expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); - const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; - const expectedContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- ${fact}\n`; - expect(writeFileCall[1]).toBe(expectedContent); - }); - - it('should add a fact when other ## sections exist and preserve spacing', async () => { - const initialContent = `${MEMORY_SECTION_HEADER}\n- Fact 1\n\n## Another Section\nSome other text.`; - mockFsAdapter.readFile.mockResolvedValue(initialContent); - const fact = 'Fact 2'; - await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); - - expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); - const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; - // Note: The implementation ensures a single newline at the end if content exists. - const expectedContent = `${MEMORY_SECTION_HEADER}\n- Fact 1\n- ${fact}\n\n## Another Section\nSome other text.\n`; - expect(writeFileCall[1]).toBe(expectedContent); - }); - - it('should correctly trim and add a fact that starts with a dash', async () => { - mockFsAdapter.readFile.mockResolvedValue(`${MEMORY_SECTION_HEADER}\n`); - const fact = '- - My fact with dashes'; - await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); - const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; - const expectedContent = `${MEMORY_SECTION_HEADER}\n- My fact with dashes\n`; - expect(writeFileCall[1]).toBe(expectedContent); - }); - - it('should handle error from fsAdapter.writeFile', async () => { - mockFsAdapter.readFile.mockResolvedValue(''); - mockFsAdapter.writeFile.mockRejectedValue(new Error('Disk full')); - const fact = 'This will fail'; - await expect( - MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter), - ).rejects.toThrow('[MemoryTool] Failed to add memory entry: Disk full'); - }); - }); - - describe('execute (instance method)', () => { - let memoryTool: MemoryTool; - let performAddMemoryEntrySpy: Mock; - - beforeEach(() => { - memoryTool = new MemoryTool(); - // Spy on the static method for these tests - performAddMemoryEntrySpy = vi - .spyOn(MemoryTool, 'performAddMemoryEntry') - .mockResolvedValue(undefined) as Mock< - typeof MemoryTool.performAddMemoryEntry - >; - // Cast needed as spyOn returns MockInstance - }); - - it('should have correct name, displayName, description, and schema', () => { - expect(memoryTool.name).toBe('save_memory'); - expect(memoryTool.displayName).toBe('SaveMemory'); - expect(memoryTool.description).toContain( - 'Saves a specific piece of information', - ); - expect(memoryTool.schema).toBeDefined(); - expect(memoryTool.schema.name).toBe('save_memory'); - expect(memoryTool.schema.parametersJsonSchema).toBeDefined(); - }); - - it('should call performAddMemoryEntry with correct parameters and return success for global scope', async () => { - const params = { fact: 'The sky is blue', scope: 'global' as const }; - const invocation = memoryTool.build(params); - const result = await invocation.execute(mockAbortSignal); - - // Use getCurrentGeminiMdFilename for the default expectation before any setGeminiMdFilename calls in a test - const expectedFilePath = path.join( - os.homedir(), - '.qwen', - getCurrentGeminiMdFilename(), // This will be DEFAULT_CONTEXT_FILENAME unless changed by a test - ); - - // For this test, we expect the actual fs methods to be passed - const expectedFsArgument = { - readFile: fs.readFile, - writeFile: fs.writeFile, - mkdir: fs.mkdir, - }; - - expect(performAddMemoryEntrySpy).toHaveBeenCalledWith( - params.fact, - expectedFilePath, - expectedFsArgument, - ); - const successMessage = `Okay, I've remembered that in global memory: "${params.fact}"`; - expect(result.llmContent).toBe(successMessage); - expect(result.returnDisplay).toBe(successMessage); - }); - - it('should call performAddMemoryEntry with correct parameters and return success for project scope', async () => { - const params = { fact: 'The sky is blue', scope: 'project' as const }; - const invocation = memoryTool.build(params); - const result = await invocation.execute(mockAbortSignal); - - // For project scope, expect the file to be in current working directory - const expectedFilePath = path.join( - process.cwd(), - getCurrentGeminiMdFilename(), - ); - - // For this test, we expect the actual fs methods to be passed - const expectedFsArgument = { - readFile: fs.readFile, - writeFile: fs.writeFile, - mkdir: fs.mkdir, - }; - - expect(performAddMemoryEntrySpy).toHaveBeenCalledWith( - params.fact, - expectedFilePath, - expectedFsArgument, - ); - const successMessage = `Okay, I've remembered that in project memory: "${params.fact}"`; - expect(result.llmContent).toBe(successMessage); - expect(result.returnDisplay).toBe(successMessage); - }); - - it('should return an error if fact is empty', async () => { - const params = { fact: ' ' }; // Empty fact - expect(memoryTool.validateToolParams(params)).toBe( - 'Parameter "fact" must be a non-empty string.', - ); - expect(() => memoryTool.build(params)).toThrow( - 'Parameter "fact" must be a non-empty string.', - ); - }); - - it('should handle errors from performAddMemoryEntry', async () => { - const params = { fact: 'This will fail', scope: 'global' as const }; - const underlyingError = new Error( - '[MemoryTool] Failed to add memory entry: Disk full', - ); - performAddMemoryEntrySpy.mockRejectedValue(underlyingError); - - const invocation = memoryTool.build(params); - const result = await invocation.execute(mockAbortSignal); - - expect(result.llmContent).toBe( - `Error saving memory: ${underlyingError.message}`, - ); - expect(result.returnDisplay).toBe( - `Error saving memory: ${underlyingError.message}`, - ); - expect(result.error?.type).toBe( - ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR, - ); - }); - - it('should return error when executing without scope parameter', async () => { - const params = { fact: 'Test fact' }; - const invocation = memoryTool.build(params); - const result = await invocation.execute(mockAbortSignal); - - expect(result.llmContent).toContain( - 'Please specify where to save this memory', - ); - expect(result.llmContent).toContain('Global:'); - expect(result.llmContent).toContain('Project:'); - expect(result.returnDisplay).toContain('Global:'); - expect(result.returnDisplay).toContain('Project:'); - }); - }); - - describe('getDefaultPermission and getConfirmationDetails', () => { - let memoryTool: MemoryTool; - - beforeEach(() => { - memoryTool = new MemoryTool(); - // Mock fs.readFile to return empty string (file doesn't exist) - vi.mocked(fs.readFile).mockResolvedValue(''); - }); - - it('should always return ask from getDefaultPermission', async () => { - const params = { fact: 'Test fact', scope: 'global' as const }; - const invocation = memoryTool.build(params); - const permission = await invocation.getDefaultPermission(); - - expect(permission).toBe('ask'); - }); - - it('should return confirmation details for global scope', async () => { - const params = { fact: 'Test fact', scope: 'global' as const }; - const invocation = memoryTool.build(params); - const permission = await invocation.getDefaultPermission(); - expect(permission).toBe('ask'); - - const result = await invocation.getConfirmationDetails(mockAbortSignal); - - expect(result).toBeDefined(); - - if (result.type === 'edit') { - const expectedPath = path.join('~', '.qwen', 'QWEN.md'); - expect(result.title).toBe( - `Confirm Memory Save: ${expectedPath} (global)`, - ); - expect(result.fileName).toContain(path.join('mock', 'home', '.qwen')); - expect(result.fileName).toContain('QWEN.md'); - expect(result.fileDiff).toContain('Index: QWEN.md'); - expect(result.fileDiff).toContain('+## Qwen Added Memories'); - expect(result.fileDiff).toContain('+- Test fact'); - expect(result.originalContent).toBe(''); - expect(result.newContent).toContain('## Qwen Added Memories'); - expect(result.newContent).toContain('- Test fact'); - } - }); - - it('should return confirmation details for project scope', async () => { - const params = { fact: 'Test fact', scope: 'project' as const }; - const invocation = memoryTool.build(params); - const permission = await invocation.getDefaultPermission(); - expect(permission).toBe('ask'); - - const result = await invocation.getConfirmationDetails(mockAbortSignal); - - expect(result).toBeDefined(); - - if (result.type === 'edit') { - const expectedPath = path.join(process.cwd(), 'QWEN.md'); - expect(result.title).toBe( - `Confirm Memory Save: ${expectedPath} (project)`, - ); - expect(result.fileName).toBe(expectedPath); - expect(result.fileDiff).toContain('Index: QWEN.md'); - expect(result.fileDiff).toContain('+## Qwen Added Memories'); - expect(result.fileDiff).toContain('+- Test fact'); - expect(result.originalContent).toBe(''); - expect(result.newContent).toContain('## Qwen Added Memories'); - expect(result.newContent).toContain('- Test fact'); - } - }); - - it('should have no-op onConfirm callback', async () => { - const params = { fact: 'Test fact', scope: 'global' as const }; - const invocation = memoryTool.build(params); - const result = await invocation.getConfirmationDetails(mockAbortSignal); - - if (result.type === 'edit') { - // onConfirm should be a no-op — just verify it doesn't throw - await expect( - result.onConfirm(ToolConfirmationOutcome.ProceedAlways), - ).resolves.toBeUndefined(); - await expect( - result.onConfirm(ToolConfirmationOutcome.ProceedOnce), - ).resolves.toBeUndefined(); - await expect( - result.onConfirm(ToolConfirmationOutcome.Cancel), - ).resolves.toBeUndefined(); - } - }); - - it('should handle existing memory file with content for global scope', async () => { - const params = { fact: 'New fact', scope: 'global' as const }; - const existingContent = - 'Some existing content.\n\n## Qwen Added Memories\n- Old fact\n'; - - // Mock fs.readFile to return existing content - vi.mocked(fs.readFile).mockResolvedValue(existingContent); - - const invocation = memoryTool.build(params); - const permission = await invocation.getDefaultPermission(); - expect(permission).toBe('ask'); - - const result = await invocation.getConfirmationDetails(mockAbortSignal); - - expect(result).toBeDefined(); - - if (result.type === 'edit') { - const expectedPath = path.join('~', '.qwen', 'QWEN.md'); - expect(result.title).toBe( - `Confirm Memory Save: ${expectedPath} (global)`, - ); - expect(result.fileDiff).toContain('Index: QWEN.md'); - expect(result.fileDiff).toContain('+- New fact'); - expect(result.originalContent).toBe(existingContent); - expect(result.newContent).toContain('- Old fact'); - expect(result.newContent).toContain('- New fact'); - } - }); - - it('should prompt for scope selection when scope is not specified', async () => { - const params = { fact: 'Test fact' }; - const invocation = memoryTool.build(params); - const permission = await invocation.getDefaultPermission(); - expect(permission).toBe('ask'); - - const result = await invocation.getConfirmationDetails(mockAbortSignal); - - expect(result).toBeDefined(); - - if (result.type === 'edit') { - expect(result.title).toContain('Choose Memory Location'); - expect(result.title).toContain('GLOBAL'); - expect(result.title).toContain('PROJECT'); - expect(result.fileName).toBe('QWEN.md'); - expect(result.fileDiff).toContain('Test fact'); - expect(result.fileDiff).toContain('--- QWEN.md'); - expect(result.fileDiff).toContain('+++ QWEN.md'); - expect(result.fileDiff).toContain('+- Test fact'); - expect(result.originalContent).toContain('scope: global'); - expect(result.originalContent).toContain('INSTRUCTIONS:'); - } - }); - - it('should show correct file paths in scope selection prompt', async () => { - const params = { fact: 'Test fact' }; - const invocation = memoryTool.build(params); - const result = await invocation.getConfirmationDetails(mockAbortSignal); - - expect(result).toBeDefined(); - - if (result.type === 'edit') { - const globalPath = path.join('~', '.qwen', 'QWEN.md'); - const projectPath = path.join(process.cwd(), 'QWEN.md'); - - expect(result.fileDiff).toContain(`Global: ${globalPath}`); - expect(result.fileDiff).toContain(`Project: ${projectPath}`); - expect(result.fileDiff).toContain('(shared across all projects)'); - expect(result.fileDiff).toContain('(current project only)'); - } - }); - }); - - describe('getDescription', () => { - let memoryTool: MemoryTool; - - beforeEach(() => { - memoryTool = new MemoryTool(); - }); - - it('should return correct description for global scope', () => { - const params = { fact: 'Test fact', scope: 'global' as const }; - const invocation = memoryTool.build(params); - const description = invocation.getDescription(); - - const expectedPath = path.join('~', '.qwen', 'QWEN.md'); - expect(description).toBe(`${expectedPath} (global)`); - }); - - it('should return correct description for project scope', () => { - const params = { fact: 'Test fact', scope: 'project' as const }; - const invocation = memoryTool.build(params); - const description = invocation.getDescription(); - - const expectedPath = path.join(process.cwd(), 'QWEN.md'); - expect(description).toBe(`${expectedPath} (project)`); - }); - - it('should show choice prompt when scope is not specified', () => { - const params = { fact: 'Test fact' }; - const invocation = memoryTool.build(params); - const description = invocation.getDescription(); - - const globalPath = path.join('~', '.qwen', 'QWEN.md'); - const projectPath = path.join(process.cwd(), 'QWEN.md'); - expect(description).toBe( - `CHOOSE: ${globalPath} (global) OR ${projectPath} (project)`, - ); - }); - }); -}); diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts deleted file mode 100644 index 6554490684a..00000000000 --- a/packages/core/src/tools/memoryTool.ts +++ /dev/null @@ -1,543 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - ToolEditConfirmationDetails, - ToolResult, - ToolCallConfirmationDetails, - ToolConfirmationOutcome, -} from './tools.js'; -import type { PermissionDecision } from '../permissions/types.js'; -import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; -import type { FunctionDeclaration } from '@google/genai'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import { Storage } from '../config/storage.js'; -import * as Diff from 'diff'; -import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; -import { tildeifyPath } from '../utils/paths.js'; -import { ToolDisplayNames, ToolNames } from './tool-names.js'; -import type { - ModifiableDeclarativeTool, - ModifyContext, -} from './modifiable-tool.js'; -import { ToolErrorType } from './tool-error.js'; -import { createDebugLogger } from '../utils/debugLogger.js'; - -const debugLogger = createDebugLogger('MEMORY_TOOL'); - -const memoryToolSchemaData: FunctionDeclaration = { - name: 'save_memory', - description: - 'Saves a specific piece of information or fact to your long-term memory. Use this when the user explicitly asks you to remember something, or when they state a clear, concise fact that seems important to retain for future interactions.', - parametersJsonSchema: { - type: 'object', - properties: { - fact: { - type: 'string', - description: - 'The specific fact or piece of information to remember. Should be a clear, self-contained statement.', - }, - scope: { - type: 'string', - description: - 'Where to save the memory: "global" saves to user-level ~/.qwen/QWEN.md (shared across all projects), "project" saves to current project\'s QWEN.md (project-specific). If not specified, will prompt user to choose.', - enum: ['global', 'project'], - }, - }, - required: ['fact'], - }, -}; - -const memoryToolDescription = ` -Saves a specific piece of information or fact to your long-term memory. - -Use this tool: - -- When the user explicitly asks you to remember something (e.g., "Remember that I like pineapple on pizza", "Please save this: my cat's name is Whiskers"). -- When the user states a clear, concise fact about themselves, their preferences, or their environment that seems important for you to retain for future interactions to provide a more personalized and effective assistance. - -Do NOT use this tool: - -- To remember conversational context that is only relevant for the current session. -- To save long, complex, or rambling pieces of text. The fact should be relatively short and to the point. -- If you are unsure whether the information is a fact worth remembering long-term. If in doubt, you can ask the user, "Should I remember that for you?" - -## Parameters - -- \`fact\` (string, required): The specific fact or piece of information to remember. This should be a clear, self-contained statement. For example, if the user says "My favorite color is blue", the fact would be "My favorite color is blue". -- \`scope\` (string, optional): Where to save the memory: - - "global": Saves to user-level ~/.qwen/QWEN.md (shared across all projects) - - "project": Saves to current project's QWEN.md (project-specific) - - If not specified, the tool will ask the user where they want to save the memory. -`; - -export const QWEN_CONFIG_DIR = '.qwen'; -export const DEFAULT_CONTEXT_FILENAME = 'QWEN.md'; -export const AGENT_CONTEXT_FILENAME = 'AGENTS.md'; -export const MEMORY_SECTION_HEADER = '## Qwen Added Memories'; - -// This variable will hold the currently configured filename for context files. -// It defaults to include both QWEN.md and AGENTS.md but can be overridden by setGeminiMdFilename. -// QWEN.md is first to maintain backward compatibility (used by /init command and save_memory tool). -let currentGeminiMdFilename: string | string[] = [ - DEFAULT_CONTEXT_FILENAME, - AGENT_CONTEXT_FILENAME, -]; - -export function setGeminiMdFilename(newFilename: string | string[]): void { - if (Array.isArray(newFilename)) { - if (newFilename.length > 0) { - currentGeminiMdFilename = newFilename.map((name) => name.trim()); - } - } else if (newFilename && newFilename.trim() !== '') { - currentGeminiMdFilename = newFilename.trim(); - } -} - -export function getCurrentGeminiMdFilename(): string { - if (Array.isArray(currentGeminiMdFilename)) { - return currentGeminiMdFilename[0]; - } - return currentGeminiMdFilename; -} - -export function getAllGeminiMdFilenames(): string[] { - if (Array.isArray(currentGeminiMdFilename)) { - return currentGeminiMdFilename; - } - return [currentGeminiMdFilename]; -} - -interface SaveMemoryParams { - fact: string; - modified_by_user?: boolean; - modified_content?: string; - scope?: 'global' | 'project'; -} - -function getGlobalMemoryFilePath(): string { - return path.join(Storage.getGlobalQwenDir(), getCurrentGeminiMdFilename()); -} - -function getProjectMemoryFilePath(): string { - return path.join(process.cwd(), getCurrentGeminiMdFilename()); -} - -function getMemoryFilePath(scope: 'global' | 'project' = 'global'): string { - return scope === 'project' - ? getProjectMemoryFilePath() - : getGlobalMemoryFilePath(); -} - -/** - * Ensures proper newline separation before appending content. - */ -function ensureNewlineSeparation(currentContent: string): string { - if (currentContent.length === 0) return ''; - if (currentContent.endsWith('\n\n') || currentContent.endsWith('\r\n\r\n')) - return ''; - if (currentContent.endsWith('\n') || currentContent.endsWith('\r\n')) - return '\n'; - return '\n\n'; -} - -/** - * Reads the current content of the memory file - */ -async function readMemoryFileContent( - scope: 'global' | 'project' = 'global', -): Promise { - try { - return await fs.readFile(getMemoryFilePath(scope), 'utf-8'); - } catch (err) { - const error = err as Error & { code?: string }; - if (!(error instanceof Error) || error.code !== 'ENOENT') throw err; - return ''; - } -} - -/** - * Computes the new content that would result from adding a memory entry - */ -function computeNewContent(currentContent: string, fact: string): string { - let processedText = fact.trim(); - processedText = processedText.replace(/^(-+\s*)+/, '').trim(); - const newMemoryItem = `- ${processedText}`; - - const headerIndex = currentContent.indexOf(MEMORY_SECTION_HEADER); - - if (headerIndex === -1) { - // Header not found, append header and then the entry - const separator = ensureNewlineSeparation(currentContent); - return ( - currentContent + - `${separator}${MEMORY_SECTION_HEADER}\n${newMemoryItem}\n` - ); - } else { - // Header found, find where to insert the new memory entry - const startOfSectionContent = headerIndex + MEMORY_SECTION_HEADER.length; - let endOfSectionIndex = currentContent.indexOf( - '\n## ', - startOfSectionContent, - ); - if (endOfSectionIndex === -1) { - endOfSectionIndex = currentContent.length; // End of file - } - - const beforeSectionMarker = currentContent - .substring(0, startOfSectionContent) - .trimEnd(); - let sectionContent = currentContent - .substring(startOfSectionContent, endOfSectionIndex) - .trimEnd(); - const afterSectionMarker = currentContent.substring(endOfSectionIndex); - - sectionContent += `\n${newMemoryItem}`; - return ( - `${beforeSectionMarker}\n${sectionContent.trimStart()}\n${afterSectionMarker}`.trimEnd() + - '\n' - ); - } -} - -class MemoryToolInvocation extends BaseToolInvocation< - SaveMemoryParams, - ToolResult -> { - getDescription(): string { - if (!this.params.scope) { - const globalPath = tildeifyPath(getMemoryFilePath('global')); - const projectPath = tildeifyPath(getMemoryFilePath('project')); - return `CHOOSE: ${globalPath} (global) OR ${projectPath} (project)`; - } - const scope = this.params.scope; - const memoryFilePath = getMemoryFilePath(scope); - return `${tildeifyPath(memoryFilePath)} (${scope})`; - } - - /** - * Memory save always needs user confirmation. - */ - override async getDefaultPermission(): Promise { - return 'ask'; - } - - /** - * Constructs the memory save confirmation dialog. - */ - override async getConfirmationDetails( - _abortSignal: AbortSignal, - ): Promise { - // When scope is not specified, show a choice dialog defaulting to global - if (!this.params.scope) { - const defaultScope = 'global'; - const currentContent = await readMemoryFileContent(defaultScope); - const newContent = computeNewContent(currentContent, this.params.fact); - - const globalPath = tildeifyPath(getMemoryFilePath('global')); - const projectPath = tildeifyPath(getMemoryFilePath('project')); - - const fileName = path.basename(getMemoryFilePath(defaultScope)); - const choiceText = `Choose where to save this memory: - -"${this.params.fact}" - -Options: -- Global: ${globalPath} (shared across all projects) -- Project: ${projectPath} (current project only) - -Preview of changes to be made to GLOBAL memory: -`; - const fileDiff = - choiceText + - Diff.createPatch( - fileName, - currentContent, - newContent, - 'Current', - 'Proposed (Global)', - DEFAULT_DIFF_OPTIONS, - ); - - const confirmationDetails: ToolEditConfirmationDetails = { - type: 'edit', - title: `Choose Memory Location: GLOBAL (${globalPath}) or PROJECT (${projectPath})`, - fileName, - filePath: getMemoryFilePath(defaultScope), - fileDiff, - originalContent: `scope: global\n\n# INSTRUCTIONS:\n# - Click "Yes" to save to GLOBAL memory: ${globalPath}\n# - Click "Modify with external editor" and change "global" to "project" to save to PROJECT memory: ${projectPath}\n\n${currentContent}`, - newContent: `scope: global\n\n# INSTRUCTIONS:\n# - Click "Yes" to save to GLOBAL memory: ${globalPath}\n# - Click "Modify with external editor" and change "global" to "project" to save to PROJECT memory: ${projectPath}\n\n${newContent}`, - onConfirm: async (_outcome: ToolConfirmationOutcome) => { - // Will be handled in createUpdatedParams - }, - }; - return confirmationDetails; - } - - // Scope is specified - const scope = this.params.scope; - const memoryFilePath = getMemoryFilePath(scope); - - // Read current content of the memory file - const currentContent = await readMemoryFileContent(scope); - - // Calculate the new content that will be written to the memory file - const newContent = computeNewContent(currentContent, this.params.fact); - - const fileName = path.basename(memoryFilePath); - const fileDiff = Diff.createPatch( - fileName, - currentContent, - newContent, - 'Current', - 'Proposed', - DEFAULT_DIFF_OPTIONS, - ); - - const confirmationDetails: ToolEditConfirmationDetails = { - type: 'edit', - title: `Confirm Memory Save: ${tildeifyPath(memoryFilePath)} (${scope})`, - fileName: memoryFilePath, - filePath: memoryFilePath, - fileDiff, - originalContent: currentContent, - newContent, - onConfirm: async (_outcome: ToolConfirmationOutcome) => { - // No-op: persistence is handled by coreToolScheduler via PM rules - }, - }; - return confirmationDetails; - } - - async execute(_signal: AbortSignal): Promise { - const { fact, modified_by_user, modified_content } = this.params; - - if (!fact || typeof fact !== 'string' || fact.trim() === '') { - const errorMessage = 'Parameter "fact" must be a non-empty string.'; - return { - llmContent: `Error: ${errorMessage}`, - returnDisplay: `Error: ${errorMessage}`, - }; - } - - // If scope is not specified and user didn't modify content, return error prompting for choice - if (!this.params.scope && !modified_by_user) { - const globalPath = tildeifyPath(getMemoryFilePath('global')); - const projectPath = tildeifyPath(getMemoryFilePath('project')); - const errorMessage = `Please specify where to save this memory: - -Global: ${globalPath} (shared across all projects) -Project: ${projectPath} (current project only)`; - - return { - llmContent: errorMessage, - returnDisplay: errorMessage, - }; - } - - const scope = this.params.scope || 'global'; - const memoryFilePath = getMemoryFilePath(scope); - - try { - if (modified_by_user && modified_content !== undefined) { - // User modified the content in external editor, write it directly - await fs.mkdir(path.dirname(memoryFilePath), { - recursive: true, - }); - await fs.writeFile(memoryFilePath, modified_content, 'utf-8'); - const successMessage = `Okay, I've updated the ${scope} memory file with your modifications.`; - return { - llmContent: successMessage, - returnDisplay: successMessage, - }; - } else { - // Use the normal memory entry logic - await MemoryTool.performAddMemoryEntry(fact, memoryFilePath, { - readFile: fs.readFile, - writeFile: fs.writeFile, - mkdir: fs.mkdir, - }); - const successMessage = `Okay, I've remembered that in ${scope} memory: "${fact}"`; - return { - llmContent: successMessage, - returnDisplay: successMessage, - }; - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - debugLogger.error( - `[MemoryTool] Error executing save_memory for fact "${fact}" in ${scope}: ${errorMessage}`, - ); - - return { - llmContent: `Error saving memory: ${errorMessage}`, - returnDisplay: `Error saving memory: ${errorMessage}`, - error: { - message: errorMessage, - type: ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR, - }, - }; - } - } -} - -export class MemoryTool - extends BaseDeclarativeTool - implements ModifiableDeclarativeTool -{ - static readonly Name: string = ToolNames.MEMORY; - constructor() { - super( - MemoryTool.Name, - ToolDisplayNames.MEMORY, - memoryToolDescription, - Kind.Think, - memoryToolSchemaData.parametersJsonSchema as Record, - ); - } - - protected override validateToolParamValues( - params: SaveMemoryParams, - ): string | null { - if (params.fact.trim() === '') { - return 'Parameter "fact" must be a non-empty string.'; - } - - return null; - } - - protected createInvocation(params: SaveMemoryParams) { - return new MemoryToolInvocation(params); - } - - static async performAddMemoryEntry( - text: string, - memoryFilePath: string, - fsAdapter: { - readFile: (path: string, encoding: 'utf-8') => Promise; - writeFile: ( - path: string, - data: string, - encoding: 'utf-8', - ) => Promise; - mkdir: ( - path: string, - options: { recursive: boolean }, - ) => Promise; - }, - ): Promise { - try { - await fsAdapter.mkdir(path.dirname(memoryFilePath), { recursive: true }); - let currentContent = ''; - try { - currentContent = await fsAdapter.readFile(memoryFilePath, 'utf-8'); - } catch (_e) { - // File doesn't exist, which is fine. currentContent will be empty. - } - - const newContent = computeNewContent(currentContent, text); - - await fsAdapter.writeFile(memoryFilePath, newContent, 'utf-8'); - } catch (error) { - debugLogger.error( - `[MemoryTool] Error adding memory entry to ${memoryFilePath}:`, - error, - ); - throw new Error( - `[MemoryTool] Failed to add memory entry: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - getModifyContext(_abortSignal: AbortSignal): ModifyContext { - return { - getFilePath: (params: SaveMemoryParams) => { - // Determine scope from modified content or default - let scope = params.scope || 'global'; - if (params.modified_content) { - const scopeMatch = params.modified_content.match( - /^scope:\s*(global|project)\s*\n/i, - ); - if (scopeMatch) { - scope = scopeMatch[1].toLowerCase() as 'global' | 'project'; - } - } - return getMemoryFilePath(scope); - }, - getCurrentContent: async (params: SaveMemoryParams): Promise => { - // Check if content starts with scope directive - if (params.modified_content) { - const scopeMatch = params.modified_content.match( - /^scope:\s*(global|project)\s*\n/i, - ); - if (scopeMatch) { - const scope = scopeMatch[1].toLowerCase() as 'global' | 'project'; - const content = await readMemoryFileContent(scope); - const globalPath = tildeifyPath(getMemoryFilePath('global')); - const projectPath = tildeifyPath(getMemoryFilePath('project')); - return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${content}`; - } - } - const scope = params.scope || 'global'; - const content = await readMemoryFileContent(scope); - const globalPath = tildeifyPath(getMemoryFilePath('global')); - const projectPath = tildeifyPath(getMemoryFilePath('project')); - return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${content}`; - }, - getProposedContent: async (params: SaveMemoryParams): Promise => { - let scope = params.scope || 'global'; - - // Check if modified content has scope directive - if (params.modified_content) { - const scopeMatch = params.modified_content.match( - /^scope:\s*(global|project)\s*\n/i, - ); - if (scopeMatch) { - scope = scopeMatch[1].toLowerCase() as 'global' | 'project'; - } - } - - const currentContent = await readMemoryFileContent(scope); - const newContent = computeNewContent(currentContent, params.fact); - const globalPath = tildeifyPath(getMemoryFilePath('global')); - const projectPath = tildeifyPath(getMemoryFilePath('project')); - return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${newContent}`; - }, - createUpdatedParams: ( - _oldContent: string, - modifiedProposedContent: string, - originalParams: SaveMemoryParams, - ): SaveMemoryParams => { - // Parse user's scope choice from modified content - const scopeMatch = modifiedProposedContent.match( - /^scope:\s*(global|project)/i, - ); - const scope = scopeMatch - ? (scopeMatch[1].toLowerCase() as 'global' | 'project') - : 'global'; - - // Strip out the scope directive and instruction lines, keep only the actual memory content - const contentWithoutScope = modifiedProposedContent.replace( - /^scope:\s*(global|project)\s*\n/, - '', - ); - const actualContent = contentWithoutScope - .replace(/^#[^\n]*\n/gm, '') - .replace(/^\s*\n/gm, '') - .trim(); - - return { - ...originalParams, - scope, - modified_by_user: true, - modified_content: actualContent, - }; - }, - }; - } -} diff --git a/packages/core/src/utils/ignorePatterns.test.ts b/packages/core/src/utils/ignorePatterns.test.ts index 722f72edb31..8c1aed00478 100644 --- a/packages/core/src/utils/ignorePatterns.test.ts +++ b/packages/core/src/utils/ignorePatterns.test.ts @@ -13,7 +13,7 @@ import { import type { Config } from '../config/config.js'; // Mock the memoryTool module -vi.mock('../tools/memoryTool.js', () => ({ +vi.mock('../memory/const.js', () => ({ getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md', 'AGENTS.md']), })); diff --git a/packages/core/src/utils/ignorePatterns.ts b/packages/core/src/utils/ignorePatterns.ts index b4a4c2e40f1..9823ddcf485 100644 --- a/packages/core/src/utils/ignorePatterns.ts +++ b/packages/core/src/utils/ignorePatterns.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import type { Config } from '../config/config.js'; -import { getAllGeminiMdFilenames } from '../tools/memoryTool.js'; +import { getAllGeminiMdFilenames } from '../memory/const.js'; /** * Common ignore patterns used across multiple tools for basic exclusions. diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 8842e031150..32edf6e95c6 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -12,7 +12,7 @@ import { loadServerHierarchicalMemory } from './memoryDiscovery.js'; import { setGeminiMdFilename, DEFAULT_CONTEXT_FILENAME, -} from '../tools/memoryTool.js'; +} from '../memory/const.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { QWEN_DIR } from './paths.js'; diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 2a891e84a6a..9b1846013ba 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; import * as path from 'node:path'; import { homedir } from 'node:os'; -import { getAllGeminiMdFilenames } from '../tools/memoryTool.js'; +import { getAllGeminiMdFilenames } from '../memory/const.js'; import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { processImports } from './memoryImportProcessor.js'; import { QWEN_DIR } from './paths.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx index 4c97f146c6f..4ab7519819c 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -13,7 +13,6 @@ import { // All ToolCall components from webui GenericToolCall, ThinkToolCall, - SaveMemoryToolCall, EditToolCall, WriteToolCall, SearchToolCall, @@ -63,11 +62,6 @@ export const getToolCallComponent = (kind: string): FC => { case 'thinking': return ThinkToolCall; - case 'save_memory': - case 'savememory': - case 'memory': - return SaveMemoryToolCall; - case 'fetch': case 'web_fetch': case 'webfetch': diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.tsx b/packages/webui/src/components/ChatViewer/ChatViewer.tsx index ff3d7d8729f..ceee8b675bc 100644 --- a/packages/webui/src/components/ChatViewer/ChatViewer.tsx +++ b/packages/webui/src/components/ChatViewer/ChatViewer.tsx @@ -17,7 +17,6 @@ import { ThinkingMessage } from '../messages/ThinkingMessage.js'; import { GenericToolCall, ThinkToolCall, - SaveMemoryToolCall, EditToolCall, WriteToolCall, SearchToolCall, @@ -174,10 +173,6 @@ function getToolCallComponent(kind: string) { case 'think': case 'thinking': return ThinkToolCall; - case 'save_memory': - case 'savememory': - case 'memory': - return SaveMemoryToolCall; case 'fetch': case 'web_fetch': case 'webfetch': diff --git a/packages/webui/src/components/toolcalls/SaveMemoryToolCall.tsx b/packages/webui/src/components/toolcalls/SaveMemoryToolCall.tsx deleted file mode 100644 index e931c77d53f..00000000000 --- a/packages/webui/src/components/toolcalls/SaveMemoryToolCall.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * SaveMemory tool call component - displays saved memory content - */ - -import type { FC } from 'react'; -import { - ToolCallContainer, - groupContent, - mapToolStatusToContainerStatus, -} from './shared/index.js'; -import type { BaseToolCallProps } from './shared/index.js'; - -/** - * SaveMemory tool call component - * Displays saved memory content in a simple text format - */ -export const SaveMemoryToolCall: FC = ({ - toolCall, - isFirst, - isLast, -}) => { - const { content } = toolCall; - - // Group content by type - const { textOutputs, errors } = groupContent(content); - - // Determine container status - const containerStatus = mapToolStatusToContainerStatus(toolCall.status); - - // Error case - if (errors.length > 0) { - return ( - -
- {errors.join('\n')} -
-
- ); - } - - // No content case - if (textOutputs.length === 0) { - return null; - } - - const memoryContent = textOutputs.join('\n\n'); - - return ( - -
- {memoryContent} -
-
- ); -}; diff --git a/packages/webui/src/components/toolcalls/index.ts b/packages/webui/src/components/toolcalls/index.ts index 5a5249c13d3..c8a64ca568f 100644 --- a/packages/webui/src/components/toolcalls/index.ts +++ b/packages/webui/src/components/toolcalls/index.ts @@ -9,7 +9,6 @@ export * from './shared/index.js'; // Business ToolCall components export { ThinkToolCall } from './ThinkToolCall.js'; -export { SaveMemoryToolCall } from './SaveMemoryToolCall.js'; export { GenericToolCall } from './GenericToolCall.js'; export { EditToolCall } from './EditToolCall.js'; export { WriteToolCall } from './WriteToolCall.js'; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 777d2ccedfd..6ffc9e8c50b 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -151,7 +151,6 @@ export { mapToolStatusToContainerStatus, // Business ToolCall components ThinkToolCall, - SaveMemoryToolCall, GenericToolCall, EditToolCall, WriteToolCall, From a35afce45f772f121654c04bcf6b003a9110ebf2 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 10 Apr 2026 14:07:39 +0800 Subject: [PATCH 36/56] refactor(memory): remove all Claude Code references from comments and test names --- .../src/background/backgroundAgentRunner.ts | 30 ++++++---- packages/core/src/memory/dream.test.ts | 58 ++++++++++++++----- packages/core/src/memory/dreamScheduler.ts | 22 ++++--- packages/core/src/memory/entries.test.ts | 9 +-- .../memoryLifecycle.integration.test.ts | 21 +++++-- packages/core/src/memory/prompt.test.ts | 8 ++- packages/core/src/memory/relevanceSelector.ts | 7 +-- 7 files changed, 105 insertions(+), 50 deletions(-) diff --git a/packages/core/src/background/backgroundAgentRunner.ts b/packages/core/src/background/backgroundAgentRunner.ts index 992253408a0..e755c326cdb 100644 --- a/packages/core/src/background/backgroundAgentRunner.ts +++ b/packages/core/src/background/backgroundAgentRunner.ts @@ -86,8 +86,7 @@ export class BackgroundAgentRunner { registry = new BackgroundTaskRegistry(), drainer = new BackgroundTaskDrainer(), scheduler = new BackgroundTaskScheduler(registry, drainer), - private readonly createAgentHeadless: CreateAgentHeadlessFn = - AgentHeadless.create, + private readonly createAgentHeadless: CreateAgentHeadlessFn = AgentHeadless.create, ) { this.registry = registry; this.drainer = drainer; @@ -118,15 +117,20 @@ export class BackgroundAgentRunner { }, run: async (task) => { const emitter = new AgentEventEmitter(); - this.bindTaskEvents(task.id, emitter, usage, filesTouched, (nextRound) => { - roundCount = Math.max(roundCount, nextRound); - }); + this.bindTaskEvents( + task.id, + emitter, + usage, + filesTouched, + (nextRound) => { + roundCount = Math.max(roundCount, nextRound); + }, + ); // Background agents must never block on permission prompts — there is // no user present to answer them. Wrap the config to force YOLO mode // so any tool call that would return 'ask' is auto-approved instead of - // hanging the process indefinitely. This mirrors Claude Code's - // shouldAvoidPermissionPrompts: true pattern in createSubagentContext(). + // hanging the process indefinitely. // Safety boundary: toolConfig.tools already restricts the model to the // declared tool set; prompt instructions constrain intended paths. const backgroundConfig = createBackgroundConfig(request.runtimeContext); @@ -150,7 +154,9 @@ export class BackgroundAgentRunner { terminateReason === AgentTerminateMode.ERROR || terminateReason === AgentTerminateMode.TIMEOUT ) { - throw new Error(`Background agent terminated with ${terminateReason}`); + throw new Error( + `Background agent terminated with ${terminateReason}`, + ); } if (terminateReason === AgentTerminateMode.CANCELLED) { @@ -262,7 +268,9 @@ export class BackgroundAgentRunner { ? (usage as BackgroundAgentResult['usage']) : undefined, roundCount: typeof roundCount === 'number' ? roundCount : undefined, - filesTouched: Array.isArray(filesTouched) ? (filesTouched as string[]) : [], + filesTouched: Array.isArray(filesTouched) + ? (filesTouched as string[]) + : [], error: finalTask.error, }; } @@ -292,7 +300,9 @@ function extractFilePathsFromArgs(args: Record): string[] { } if (value && typeof value === 'object') { - for (const [nextKey, nextValue] of Object.entries(value as Record)) { + for (const [nextKey, nextValue] of Object.entries( + value as Record, + )) { visit(nextValue, nextKey); } } diff --git a/packages/core/src/memory/dream.test.ts b/packages/core/src/memory/dream.test.ts index da812dc9585..338c3048446 100644 --- a/packages/core/src/memory/dream.test.ts +++ b/packages/core/src/memory/dream.test.ts @@ -42,8 +42,14 @@ describe('managed auto-memory dream', () => { }); it('deduplicates repeated bullet entries in topic files', async () => { - const firstPath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse.md')); - const duplicatePath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse-duplicate.md')); + const firstPath = getAutoMemoryFilePath( + projectRoot, + path.join('user', 'terse.md'), + ); + const duplicatePath = getAutoMemoryFilePath( + projectRoot, + path.join('user', 'terse-duplicate.md'), + ); await fs.mkdir(path.dirname(firstPath), { recursive: true }); await fs.writeFile( firstPath, @@ -73,7 +79,10 @@ describe('managed auto-memory dream', () => { ); const result = await runManagedAutoMemoryDream(projectRoot); - const index = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'); + const index = await fs.readFile( + getAutoMemoryIndexPath(projectRoot), + 'utf-8', + ); const docs = await scanAutoMemoryTopicDocuments(projectRoot); const userDocs = docs.filter((doc) => doc.type === 'user'); @@ -84,9 +93,15 @@ describe('managed auto-memory dream', () => { expect(index).toContain('(user/'); }); - it('preserves Claude-style why/apply metadata when deduplicating entries', async () => { - const firstPath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse.md')); - const duplicatePath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse-context.md')); + it('preserves why/apply metadata when deduplicating entries', async () => { + const firstPath = getAutoMemoryFilePath( + projectRoot, + path.join('user', 'terse.md'), + ); + const duplicatePath = getAutoMemoryFilePath( + projectRoot, + path.join('user', 'terse-context.md'), + ); await fs.mkdir(path.dirname(firstPath), { recursive: true }); await fs.writeFile( firstPath, @@ -126,11 +141,16 @@ describe('managed auto-memory dream', () => { expect(content.match(/User prefers terse responses\./g)).toHaveLength(1); expect(content).toContain('Why: They repeatedly ask for concise replies.'); - expect(content).toContain('How to apply: Lead with a short answer before details.'); + expect(content).toContain( + 'How to apply: Lead with a short answer before details.', + ); }); it('leaves empty placeholder documents unchanged', async () => { - const projectPath = getAutoMemoryFilePath(projectRoot, path.join('project', 'empty.md')); + const projectPath = getAutoMemoryFilePath( + projectRoot, + path.join('project', 'empty.md'), + ); await fs.mkdir(path.dirname(projectPath), { recursive: true }); await fs.writeFile( projectPath, @@ -153,8 +173,14 @@ describe('managed auto-memory dream', () => { }); it('falls back to mechanical dedupe when config is provided', async () => { - const firstPath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse.md')); - const duplicatePath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse-again.md')); + const firstPath = getAutoMemoryFilePath( + projectRoot, + path.join('user', 'terse.md'), + ); + const duplicatePath = getAutoMemoryFilePath( + projectRoot, + path.join('user', 'terse-again.md'), + ); await fs.mkdir(path.dirname(firstPath), { recursive: true }); await fs.writeFile( @@ -204,8 +230,14 @@ describe('managed auto-memory dream', () => { new Error('agent failed'), ); - const firstPath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse.md')); - const duplicatePath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse-failover.md')); + const firstPath = getAutoMemoryFilePath( + projectRoot, + path.join('user', 'terse.md'), + ); + const duplicatePath = getAutoMemoryFilePath( + projectRoot, + path.join('user', 'terse-failover.md'), + ); await fs.mkdir(path.dirname(firstPath), { recursive: true }); await fs.writeFile( @@ -247,4 +279,4 @@ describe('managed auto-memory dream', () => { expect(result.touchedTopics).toContain('user'); expect(result.dedupedEntries).toBe(1); }); -}); \ No newline at end of file +}); diff --git a/packages/core/src/memory/dreamScheduler.ts b/packages/core/src/memory/dreamScheduler.ts index 0dd844c8e3b..df92726b14f 100644 --- a/packages/core/src/memory/dreamScheduler.ts +++ b/packages/core/src/memory/dreamScheduler.ts @@ -67,8 +67,13 @@ export interface ManagedAutoMemoryDreamScheduleResult { promise?: Promise; } -async function readDreamMetadata(projectRoot: string): Promise { - const content = await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'); +async function readDreamMetadata( + projectRoot: string, +): Promise { + const content = await fs.readFile( + getAutoMemoryMetadataPath(projectRoot), + 'utf-8', + ); return JSON.parse(content) as AutoMemoryMetadata; } @@ -99,8 +104,7 @@ const SESSION_FILE_PATTERN = /^[0-9a-fA-F-]{32,36}\.jsonl$/; /** * Returns session IDs whose transcript files have mtime after sinceMs. - * Mirrors Claude Code’s listSessionsTouchedSince approach: ground truth from - * the filesystem, immune to meta.json corruption or loss. + * Uses filesystem mtime as ground truth, immune to meta.json corruption or loss. * Caller should exclude the current session (its mtime is always recent). */ async function listSessionsTouchedSince( @@ -250,7 +254,9 @@ export class ManagedAutoMemoryDreamRuntime { this.lastSessionScanAt.set(params.projectRoot, now.getTime()); // Scan session files by mtime (filesystem ground truth, immune to meta.json loss). - const lastDreamMs = metadata.lastDreamAt ? Date.parse(metadata.lastDreamAt) : 0; + const lastDreamMs = metadata.lastDreamAt + ? Date.parse(metadata.lastDreamAt) + : 0; const sessionIds = await this.sessionScanner( params.projectRoot, lastDreamMs, @@ -285,7 +291,8 @@ export class ManagedAutoMemoryDreamRuntime { } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') { return { - progressText: 'Skipped managed auto-memory dream because consolidation lock already exists.', + progressText: + 'Skipped managed auto-memory dream because consolidation lock already exists.', metadata: { skippedReason: 'locked' }, }; } @@ -345,7 +352,8 @@ export class ManagedAutoMemoryDreamRuntime { } } -const defaultManagedAutoMemoryDreamRuntime = new ManagedAutoMemoryDreamRuntime(); +const defaultManagedAutoMemoryDreamRuntime = + new ManagedAutoMemoryDreamRuntime(); export async function scheduleManagedAutoMemoryDream( params: ScheduleManagedAutoMemoryDreamParams, diff --git a/packages/core/src/memory/entries.test.ts b/packages/core/src/memory/entries.test.ts index d9836580c20..57052cb2dc7 100644 --- a/packages/core/src/memory/entries.test.ts +++ b/packages/core/src/memory/entries.test.ts @@ -5,13 +5,10 @@ */ import { describe, expect, it } from 'vitest'; -import { - parseAutoMemoryEntries, - renderAutoMemoryBody, -} from './entries.js'; +import { parseAutoMemoryEntries, renderAutoMemoryBody } from './entries.js'; describe('managed auto-memory entries', () => { - it('parses and renders Claude-style why/apply fields', () => { + it('parses and renders why/apply fields', () => { const body = [ '# User Memory', '', @@ -34,4 +31,4 @@ describe('managed auto-memory entries', () => { expect(rendered).toContain('Why: This reduces back-and-forth.'); expect(rendered).toContain('How to apply: Prefer concise summaries first.'); }); -}); \ No newline at end of file +}); diff --git a/packages/core/src/memory/memoryLifecycle.integration.test.ts b/packages/core/src/memory/memoryLifecycle.integration.test.ts index 098596e6006..eefdeba3479 100644 --- a/packages/core/src/memory/memoryLifecycle.integration.test.ts +++ b/packages/core/src/memory/memoryLifecycle.integration.test.ts @@ -36,7 +36,10 @@ describe('managed auto-memory lifecycle integration', () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-lifecycle-int-')); projectRoot = path.join(tempDir, 'project'); await fs.mkdir(projectRoot, { recursive: true }); - await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T00:00:00.000Z')); + await ensureAutoMemoryScaffold( + projectRoot, + new Date('2026-04-01T00:00:00.000Z'), + ); mockConfig = { getSessionId: () => 'session-1', getModel: () => 'qwen3-coder-plus', @@ -97,12 +100,14 @@ describe('managed auto-memory lifecycle integration', () => { }); }); - it('supports a Claude-style durable memory lifecycle across extraction, recall, and dream', async () => { + it('supports a durable memory lifecycle across extraction, recall, and dream', async () => { const firstExtraction = scheduleManagedAutoMemoryExtract({ projectRoot, sessionId: 'session-1', config: mockConfig, - history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], + history: [ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + ], }); const queuedExtraction = await scheduleManagedAutoMemoryExtract({ @@ -128,7 +133,9 @@ describe('managed auto-memory lifecycle integration', () => { const firstResult = await firstExtraction; expect(firstResult.touchedTopics).toEqual(['user']); - const drained = await drainManagedAutoMemoryExtractTasks({ timeoutMs: 1_000 }); + const drained = await drainManagedAutoMemoryExtractTasks({ + timeoutMs: 1_000, + }); expect(drained).toBe(true); const projectPath = getAutoMemoryFilePath( @@ -192,7 +199,9 @@ describe('managed auto-memory lifecycle integration', () => { const referenceDoc = docs.find((doc) => doc.type === 'reference'); expect(userDoc?.body).toContain('I prefer terse responses.'); - expect(userDoc?.body).toContain('Why: User repeatedly asks for concise replies.'); + expect(userDoc?.body).toContain( + 'Why: User repeatedly asks for concise replies.', + ); expect(referenceDoc?.body).toContain('grafana.example/d/api-latency'); expect(projectDoc?.body).toContain('This is temporary for this task.'); expect(indexContent).toContain('user/'); @@ -206,4 +215,4 @@ describe('managed auto-memory lifecycle integration', () => { expect(recall.prompt).toContain('user/'); expect(recall.prompt).toContain('reference/'); }); -}); \ No newline at end of file +}); diff --git a/packages/core/src/memory/prompt.test.ts b/packages/core/src/memory/prompt.test.ts index 9f6c1e88225..b13ca09fedb 100644 --- a/packages/core/src/memory/prompt.test.ts +++ b/packages/core/src/memory/prompt.test.ts @@ -12,7 +12,7 @@ import { } from './prompt.js'; describe('managed auto-memory prompt helpers', () => { - it('builds the Claude-style memory mechanics prompt even when MEMORY.md is empty', () => { + it('builds the memory mechanics prompt even when MEMORY.md is empty', () => { const prompt = buildManagedAutoMemoryPrompt('/tmp/project/.qwen/memory'); expect(prompt).toContain('# auto memory'); @@ -65,7 +65,9 @@ describe('managed auto-memory prompt helpers', () => { oversizedIndex, ); - expect(result).toContain('WARNING: MEMORY.md is 250 lines (limit: 200). Only part of it was loaded.'); + expect(result).toContain( + 'WARNING: MEMORY.md is 250 lines (limit: 200). Only part of it was loaded.', + ); expect(result.split('\n').length).toBeLessThan(400); }); -}); \ No newline at end of file +}); diff --git a/packages/core/src/memory/relevanceSelector.ts b/packages/core/src/memory/relevanceSelector.ts index 67422242c55..8fbc55d8048 100644 --- a/packages/core/src/memory/relevanceSelector.ts +++ b/packages/core/src/memory/relevanceSelector.ts @@ -10,9 +10,7 @@ import { runSideQuery } from '../auxiliary/sideQuery.js'; import type { ScannedAutoMemoryDocument } from './scan.js'; /** - * System prompt for the selector side-query. Mirrors Claude Code's - * SELECT_MEMORIES_SYSTEM_PROMPT — including the recentTools instruction — - * so selection behaviour stays consistent. + * System prompt for the selector side-query. */ const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to an AI coding assistant as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions. @@ -40,8 +38,7 @@ interface RecallSelectorResponse { /** * Format memory headers as a text manifest: one line per file with * [type] relativePath (ISO-timestamp): description. - * Mirrors Claude Code's formatMemoryManifest — selector sees only - * the header (type, path, age, description), not the body content. + * Selector sees only the header (type, path, age, description), not the body content. */ function formatMemoryManifest(docs: ScannedAutoMemoryDocument[]): string { return docs From 5203141067eff7bb75edb737155e032a296db6da Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 10 Apr 2026 14:09:48 +0800 Subject: [PATCH 37/56] test(memory): remove empty placeholder test files that cause vitest to fail --- packages/core/src/memory/extractModel.test.ts | 5 ----- packages/core/src/memory/extractionPlanner.test.ts | 5 ----- 2 files changed, 10 deletions(-) delete mode 100644 packages/core/src/memory/extractModel.test.ts delete mode 100644 packages/core/src/memory/extractionPlanner.test.ts diff --git a/packages/core/src/memory/extractModel.test.ts b/packages/core/src/memory/extractModel.test.ts deleted file mode 100644 index 080c373e2b1..00000000000 --- a/packages/core/src/memory/extractModel.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Deprecated with the model/heuristic fallback removal. Intentionally empty. - */ - -export {}; diff --git a/packages/core/src/memory/extractionPlanner.test.ts b/packages/core/src/memory/extractionPlanner.test.ts deleted file mode 100644 index 7f6f6904ee5..00000000000 --- a/packages/core/src/memory/extractionPlanner.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Deprecated with the model-planner removal. Intentionally empty. - */ - -export {}; From 03b67518b0ba965a3d71fb3f4c007f8ad1687498 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 10 Apr 2026 14:13:11 +0800 Subject: [PATCH 38/56] fix eslint --- packages/core/src/background/taskScheduler.ts | 16 ++++++++++------ packages/core/src/memory/entries.ts | 12 ++++++++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/core/src/background/taskScheduler.ts b/packages/core/src/background/taskScheduler.ts index 60fe485226c..6f38ee04040 100644 --- a/packages/core/src/background/taskScheduler.ts +++ b/packages/core/src/background/taskScheduler.ts @@ -4,12 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { +import type { BackgroundTaskRegistry, - type BackgroundTaskStatus, - type BackgroundTaskState, + BackgroundTaskStatus, + BackgroundTaskState, } from './taskRegistry.js'; -import { BackgroundTaskDrainer } from './taskDrainer.js'; +import type { BackgroundTaskDrainer } from './taskDrainer.js'; export interface ScheduleBackgroundTaskParams { taskType: string; @@ -60,7 +60,9 @@ export class BackgroundTaskScheduler { }); return { taskId: skipped.id, - promise: Promise.resolve(this.registry.get(skipped.id) as BackgroundTaskState), + promise: Promise.resolve( + this.registry.get(skipped.id) as BackgroundTaskState, + ), }; } } @@ -82,7 +84,9 @@ export class BackgroundTaskScheduler { task.id, (async () => { try { - const result = await params.run(this.registry.get(task.id) as BackgroundTaskState); + const result = await params.run( + this.registry.get(task.id) as BackgroundTaskState, + ); const finalTask = this.registry.update(task.id, { status: result?.status ?? 'completed', progressText: result?.progressText, diff --git a/packages/core/src/memory/entries.ts b/packages/core/src/memory/entries.ts index 32df459d45b..3493176d42b 100644 --- a/packages/core/src/memory/entries.ts +++ b/packages/core/src/memory/entries.ts @@ -58,7 +58,11 @@ export function parseAutoMemoryEntries(body: string): ManagedAutoMemoryEntry[] { for (const rawLine of body.split('\n')) { const trimmed = rawLine.trim(); - if (!trimmed || trimmed === '_No entries yet._' || trimmed.startsWith('# ')) { + if ( + !trimmed || + trimmed === '_No entries yet._' || + trimmed.startsWith('# ') + ) { continue; } @@ -79,6 +83,8 @@ export function parseAutoMemoryEntries(body: string): ManagedAutoMemoryEntry[] { case 'how_to_apply': current.howToApply = value; break; + default: + break; } } continue; @@ -101,6 +107,8 @@ export function parseAutoMemoryEntries(body: string): ManagedAutoMemoryEntry[] { case 'how_to_apply': current.howToApply = value; break; + default: + break; } } continue; @@ -170,4 +178,4 @@ export function buildAutoMemoryEntrySearchText( .filter((value): value is string => Boolean(value)) .join(' ') .toLowerCase(); -} \ No newline at end of file +} From 303299f93eeccd0cb3392dcc9f6aab8a2ad44fdf Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 10 Apr 2026 14:45:57 +0800 Subject: [PATCH 39/56] fix test in windows --- packages/core/src/memory/scan.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/core/src/memory/scan.ts b/packages/core/src/memory/scan.ts index 661192c4a63..b2d7d1a1cca 100644 --- a/packages/core/src/memory/scan.ts +++ b/packages/core/src/memory/scan.ts @@ -65,14 +65,19 @@ export function parseAutoMemoryTopicDocument( async function listMarkdownFiles(root: string): Promise { try { const entries = await fs.readdir(root, { recursive: true }); - return entries - .filter( - (entry): entry is string => - typeof entry === 'string' && - entry.endsWith('.md') && - path.basename(entry) !== AUTO_MEMORY_INDEX_FILENAME, - ) - .sort(); + return ( + entries + .filter( + (entry): entry is string => + typeof entry === 'string' && + entry.endsWith('.md') && + path.basename(entry) !== AUTO_MEMORY_INDEX_FILENAME, + ) + // Normalize to forward slashes so relative paths are valid URL segments + // on all platforms (Windows readdir returns backslash-separated paths). + .map((entry) => entry.replaceAll('\\', '/')) + .sort() + ); } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === 'ENOENT') { @@ -106,6 +111,8 @@ export async function scanAutoMemoryTopicDocuments( return docs .filter((doc): doc is ScannedAutoMemoryDocument => doc !== null) .filter((doc) => AUTO_MEMORY_TYPES.includes(doc.type)) - .sort((a, b) => b.mtimeMs - a.mtimeMs || a.filename.localeCompare(b.filename)) + .sort( + (a, b) => b.mtimeMs - a.mtimeMs || a.filename.localeCompare(b.filename), + ) .slice(0, MAX_SCANNED_MEMORY_FILES); -} \ No newline at end of file +} From 69aaf53bfa136f05869296b82568a76366f2c6c7 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 10 Apr 2026 15:16:53 +0800 Subject: [PATCH 40/56] fix test --- packages/core/src/memory/scan.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/memory/scan.test.ts b/packages/core/src/memory/scan.test.ts index 95e6066f52f..64c4206c8c7 100644 --- a/packages/core/src/memory/scan.test.ts +++ b/packages/core/src/memory/scan.test.ts @@ -87,7 +87,7 @@ describe('auto-memory topic scanning', () => { const referenceDoc = docs.find((doc) => doc.type === 'reference'); expect(referenceDoc?.description).toBe('External references'); - expect(referenceDoc?.relativePath).toBe(path.join('reference', 'grafana.md')); + expect(referenceDoc?.relativePath).toBe('reference/grafana.md'); expect(referenceDoc?.body).toContain('grafana.internal/d/api-latency'); }); -}); \ No newline at end of file +}); From 601d1a89ac88b277128ad14638cea6faf54879d9 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 10 Apr 2026 15:39:09 +0800 Subject: [PATCH 41/56] fix(memory): address critical review findings from PR #3087 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(read-file): narrow auto-allow from getMemoryBaseDir() (~/.qwen) to isAutoMemPath(projectRoot) to prevent exposing settings.json / OAuth credentials without user approval (wenshao review) - fix(forget): per-entry deletion instead of whole-file unlink - assign stable per-entry IDs (relativePath:index for multi-entry files) so the model can target individual entries without removing siblings - rewrite file keeping unmatched entries; only unlink when file becomes empty (wenshao review) - fix(entries): round-trip correctness for multi-entry new-format bodies - parseAutoMemoryEntries: plain-text line closes current entry and opens a new one (was silently ignored when current was already set) - renderAutoMemoryBody: emit blank line between adjacent entries so the parser can detect entry boundaries on re-read (wenshao review) - fix(entries): resolve two CodeQL polynomial-regex alerts - indentedMatch: \s{2,}(?:[-*]\s+)? → [\t ]{2,}(?:[-*][\t ]+)? - topLevelMatch: :\s*(.+)$ → :[ \t]*(\S.*)$ (github-advanced-security review) - fix(scan.test): use forward-slash literal for relativePath expectation since listMarkdownFiles() normalises all separators to '/' on all platforms including Windows --- packages/core/src/memory/entries.ts | 20 ++++++--- packages/core/src/memory/forget.ts | 61 +++++++++++++++++++++++++--- packages/core/src/tools/read-file.ts | 11 ++--- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/packages/core/src/memory/entries.ts b/packages/core/src/memory/entries.ts index 3493176d42b..17818954635 100644 --- a/packages/core/src/memory/entries.ts +++ b/packages/core/src/memory/entries.ts @@ -69,7 +69,7 @@ export function parseAutoMemoryEntries(body: string): ManagedAutoMemoryEntry[] { // Indented nested field — legacy format: ` - Why: ...` or ` Why: ...` if (current) { const indentedMatch = rawLine.match( - /^\s{2,}(?:[-*]\s+)?(Why|How to apply|How_to_apply):\s*(.+)$/i, + /^[\t ]{2,}(?:[-*][\t ]+)?(Why|How to apply|How_to_apply):[\t ]*(\S.*)$/i, ); if (indentedMatch) { const [, rawKey, rawValue] = indentedMatch; @@ -93,7 +93,7 @@ export function parseAutoMemoryEntries(body: string): ManagedAutoMemoryEntry[] { // Top-level named field — new format: `Why: ...` or `**How to apply**: ...` const topLevelMatch = trimmed.match( - /^(?:\*\*)?(Why|How to apply|How_to_apply)(?:\*\*)?:\s*(.+)$/i, + /^(?:\*\*)?(Why|How to apply|How_to_apply)(?:\*\*)?:[ \t]*(\S.*)$/i, ); if (topLevelMatch) { const [, rawKey, rawValue] = topLevelMatch; @@ -125,10 +125,14 @@ export function parseAutoMemoryEntries(body: string): ManagedAutoMemoryEntry[] { continue; } - // Plain text — new per-entry format: first non-special line is the summary - if (!current) { - current = { summary: normalizeText(trimmed) }; + // Plain text — new per-entry format: each plain-text line starts a new + // entry. If a current entry is already open, close it first so that + // multi-entry bodies produced by renderAutoMemoryBody can round-trip + // correctly through parse→rewrite without losing later entries. + if (current) { + entries.push(current); } + current = { summary: normalizeText(trimmed) }; } if (current) { @@ -147,7 +151,11 @@ export function renderAutoMemoryBody( } const lines: string[] = []; - for (const entry of entries) { + for (let i = 0; i < entries.length; i++) { + if (i > 0) { + lines.push(''); + } + const entry = entries[i]; lines.push(normalizeText(entry.summary)); if (entry.why) { lines.push('', `Why: ${normalizeText(entry.why)}`); diff --git a/packages/core/src/memory/forget.ts b/packages/core/src/memory/forget.ts index 28fe048c4bd..bc5049a0172 100644 --- a/packages/core/src/memory/forget.ts +++ b/packages/core/src/memory/forget.ts @@ -10,7 +10,9 @@ import type { Config } from '../config/config.js'; import { runSideQuery } from '../auxiliary/sideQuery.js'; import { buildAutoMemoryEntrySearchText, + getAutoMemoryBodyHeading, parseAutoMemoryEntries, + renderAutoMemoryBody, } from './entries.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; import { getAutoMemoryMetadataPath } from './paths.js'; @@ -70,9 +72,13 @@ async function listIndexedForgetCandidates( for (const doc of docs) { const entries = parseAutoMemoryEntries(doc.body); - for (const entry of entries) { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; candidates.push({ - id: doc.relativePath, + // Use a stable per-entry ID so the model can target individual entries + // in multi-entry files without accidentally removing siblings. + id: + entries.length === 1 ? doc.relativePath : `${doc.relativePath}:${i}`, topic: doc.type, summary: entry.summary, filePath: doc.filePath, @@ -232,11 +238,56 @@ export async function forgetManagedAutoMemoryMatches( const removedEntries: AutoMemoryForgetMatch[] = []; const touchedTopics = new Set(); + // Group matches by file so we can do per-entry removal rather than + // blindly deleting entire files (which would destroy unrelated entries in + // legacy multi-entry files). + const matchesByFile = new Map(); for (const match of matches) { + const existing = matchesByFile.get(match.filePath) ?? []; + existing.push(match); + matchesByFile.set(match.filePath, existing); + } + + for (const [filePath, fileMatches] of matchesByFile) { try { - await fs.unlink(match.filePath); - removedEntries.push(match); - touchedTopics.add(match.topic); + const rawContent = await fs.readFile(filePath, 'utf-8'); + const fmMatch = rawContent.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + + if (!fmMatch) { + // No frontmatter — delete the whole file. + await fs.unlink(filePath); + removedEntries.push(...fileMatches); + for (const m of fileMatches) touchedTopics.add(m.topic); + continue; + } + + const [, frontmatter, rawBody] = fmMatch; + const allEntries = parseAutoMemoryEntries(rawBody.trim()); + const matchedSummaries = new Set( + fileMatches.map((m) => m.summary.toLowerCase()), + ); + const kept = allEntries.filter( + (e) => !matchedSummaries.has(e.summary.toLowerCase()), + ); + + if (kept.length === 0) { + await fs.unlink(filePath); + } else { + const heading = getAutoMemoryBodyHeading(rawBody); + const newBody = renderAutoMemoryBody(heading, kept); + await fs.writeFile( + filePath, + `---\n${frontmatter}\n---\n\n${newBody}\n`, + 'utf-8', + ); + } + + // Record the entries that were actually removed (by summary match count). + const removedCount = allEntries.length - kept.length; + removedEntries.push(...fileMatches.slice(0, removedCount)); + for (const m of fileMatches.slice(0, removedCount)) { + touchedTopics.add(m.topic); + } } catch { // File may have already been removed; continue. } diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 95763f254ac..b4f28fad317 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -25,7 +25,7 @@ import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { isSubpaths, isSubpath } from '../utils/paths.js'; import { Storage } from '../config/storage.js'; -import { isAutoMemPath, getMemoryBaseDir } from '../memory/paths.js'; +import { isAutoMemPath } from '../memory/paths.js'; import { memoryFreshnessNote } from '../memory/memoryAge.js'; /** @@ -95,9 +95,10 @@ class ReadFileToolInvocation extends BaseToolInvocation< const userExtensionsDir = Storage.getUserExtensionsDir(); const osTempDir = os.tmpdir(); - // Auto-allow reads of any file under the global memory base dir — models - // legitimately read their own memory files without needing user approval. - const memoryBaseDir = getMemoryBaseDir(); + // Auto-allow reads of files within the managed auto-memory root for this + // project only — using the narrower isAutoMemPath check instead of the + // broad getMemoryBaseDir() to avoid exposing sensitive ~/.qwen files such + // as settings.json or OAuth credentials. if ( workspaceContext.isPathWithinWorkspace(filePath) || isSubpath(projectTempDir, filePath) || @@ -105,7 +106,7 @@ class ReadFileToolInvocation extends BaseToolInvocation< isSubpath(osTempDir, filePath) || isSubpaths(userSkillsDirs, filePath) || isSubpath(userExtensionsDir, filePath) || - isSubpath(memoryBaseDir, filePath) + isAutoMemPath(filePath, this.config.getTargetDir()) ) { return 'allow'; } From 5cd4908d2f36707ec74c88728cf26c2ec2971532 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 10 Apr 2026 15:56:07 +0800 Subject: [PATCH 42/56] fix(memory): replace isAutoMemPath startsWith with path.relative() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using path.relative() instead of string startsWith() is more robust across platforms — it correctly handles Windows path-separator differences and avoids potential edge cases where a path prefix match could succeed on non-separator boundaries. Addresses github-actions review item 3 (PR #3087). --- packages/core/src/memory/paths.ts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/core/src/memory/paths.ts b/packages/core/src/memory/paths.ts index 5aba317e78d..4f805763c0e 100644 --- a/packages/core/src/memory/paths.ts +++ b/packages/core/src/memory/paths.ts @@ -40,7 +40,9 @@ function findCanonicalGitRoot(startPath: string): string | null { } try { - const gitContent = fs.readFileSync(path.join(gitRoot, '.git'), 'utf-8').trim(); + const gitContent = fs + .readFileSync(path.join(gitRoot, '.git'), 'utf-8') + .trim(); if (!gitContent.startsWith('gitdir:')) { return gitRoot; } @@ -100,7 +102,8 @@ export function getAutoMemoryRoot(projectRoot: string): string { if (process.env['QWEN_CODE_MEMORY_LOCAL'] === '1') { result = path.join(projectRoot, QWEN_DIR, AUTO_MEMORY_DIRNAME); } else { - const canonicalRoot = findCanonicalGitRoot(projectRoot) ?? path.resolve(projectRoot); + const canonicalRoot = + findCanonicalGitRoot(projectRoot) ?? path.resolve(projectRoot); result = path.join( getMemoryBaseDir(), 'projects', @@ -129,12 +132,22 @@ export function getAutoMemoryProjectStateDir(projectRoot: string): string { /** * Returns true if the given absolute path is inside the auto-memory root for - * the given project. The path is normalized to prevent path-traversal tricks. + * the given project. + * + * Uses path.relative() instead of startsWith() to correctly handle + * platform path-separator differences (e.g. Windows backslash vs forward + * slash) and to be resilient against path-traversal edge cases. */ -export function isAutoMemPath(absolutePath: string, projectRoot: string): boolean { +export function isAutoMemPath( + absolutePath: string, + projectRoot: string, +): boolean { const normalizedPath = path.normalize(absolutePath); const memRoot = path.normalize(getAutoMemoryRoot(projectRoot)); - return normalizedPath.startsWith(memRoot + path.sep) || normalizedPath === memRoot; + const rel = path.relative(memRoot, normalizedPath); + // rel === '' means absolutePath IS memRoot itself. + // !rel.startsWith('..') && !path.isAbsolute(rel) means it's strictly inside. + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); } export function getAutoMemoryIndexPath(projectRoot: string): string { @@ -172,7 +185,10 @@ export function getAutoMemoryTopicPath( projectRoot: string, type: AutoMemoryType, ): string { - return path.join(getAutoMemoryRoot(projectRoot), getAutoMemoryTopicFilename(type)); + return path.join( + getAutoMemoryRoot(projectRoot), + getAutoMemoryTopicFilename(type), + ); } export function getAutoMemoryFilePath( @@ -180,4 +196,4 @@ export function getAutoMemoryFilePath( relativePath: string, ): string { return path.join(getAutoMemoryRoot(projectRoot), relativePath); -} \ No newline at end of file +} From 79d84188c6c454d1799cc966e3af786d1637c92f Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 10 Apr 2026 16:16:31 +0800 Subject: [PATCH 43/56] feat(telemetry): add auto-memory telemetry instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OpenTelemetry logs + metrics for the five auto-memory lifecycle events: extract, dream, recall, forget, and remember. Telemetry layer (packages/core/src/telemetry/): - constants.ts: 5 new event-name constants (qwen-code.memory.{extract,dream,recall,forget,remember}) - types.ts: 5 new event classes with typed constructor params (MemoryExtractEvent, MemoryDreamEvent, MemoryRecallEvent, MemoryForgetEvent, MemoryRememberEvent) - metrics.ts: 8 new OTel instruments (5 Counters + 3 Histograms) with recordMemoryXxx() helpers; registered inside initializeMetrics() - loggers.ts: logMemoryExtract/Dream/Recall/Forget/Remember() — each emits a structured log record and calls its recordXxx() counterpart - index.ts: re-exports all new symbols Instrumentation call-sites: - extractScheduler.ts ManagedAutoMemoryExtractRuntime.runTask(): emits extract event with trigger=auto, completed/failed status, patches_count, touched_topics, and wall-clock duration - dream.ts runManagedAutoMemoryDream(): emits dream event with trigger=auto, updated/noop status, deduped_entries, touched_topics, and duration; covers both agent-planner and mechanical fallback paths - recall.ts resolveRelevantAutoMemoryPromptForQuery(): emits recall event with strategy, docs_scanned/selected, and duration; covers model, heuristic, and none paths - forget.ts forgetManagedAutoMemoryEntries(): emits forget event with removed_entries_count, touched_topics, and selection_strategy (model/heuristic/none) - rememberCommand.ts action(): emits remember event with topic=managed|legacy at command invocation time (before agent decides the actual memory type) --- .../cli/src/ui/commands/rememberCommand.ts | 20 ++- packages/core/src/memory/dream.ts | 59 ++++++- packages/core/src/memory/extractScheduler.ts | 45 +++++- packages/core/src/memory/forget.ts | 11 ++ packages/core/src/memory/recall.ts | 46 +++++- packages/core/src/telemetry/constants.ts | 7 + packages/core/src/telemetry/index.ts | 16 ++ packages/core/src/telemetry/loggers.ts | 152 ++++++++++++++++++ packages/core/src/telemetry/metrics.ts | 149 +++++++++++++++++ packages/core/src/telemetry/types.ts | 121 ++++++++++++++ 10 files changed, 609 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/ui/commands/rememberCommand.ts b/packages/cli/src/ui/commands/rememberCommand.ts index b4280ddbfee..9b5241b3b8e 100644 --- a/packages/cli/src/ui/commands/rememberCommand.ts +++ b/packages/cli/src/ui/commands/rememberCommand.ts @@ -4,9 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { getAutoMemoryRoot } from '@qwen-code/qwen-code-core'; +import { getAutoMemoryRoot , + logMemoryRemember, + MemoryRememberEvent, +} from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; -import type { CommandContext, SlashCommand, SlashCommandActionReturn } from './types.js'; +import type { + CommandContext, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; export const rememberCommand: SlashCommand = { @@ -37,6 +44,12 @@ export const rememberCommand: SlashCommand = { ? getAutoMemoryRoot(config.getProjectRoot()) : undefined; const dirHint = memoryDir ? ` Save it to \`${memoryDir}\`.` : ''; + if (config) { + logMemoryRemember( + config, + new MemoryRememberEvent({ topic: 'managed' }), + ); + } return { type: 'submit_prompt', content: `Please save the following to your memory system.${dirHint} Choose the most appropriate memory type (user, feedback, project, or reference) based on the content:\n\n${fact}`, @@ -44,6 +57,9 @@ export const rememberCommand: SlashCommand = { } // Legacy mode: save_memory tool is registered and handles the write. + if (config) { + logMemoryRemember(config, new MemoryRememberEvent({ topic: 'legacy' })); + } return { type: 'tool', toolName: 'save_memory', diff --git a/packages/core/src/memory/dream.ts b/packages/core/src/memory/dream.ts index 1f1996ae56c..16bf82b4ecf 100644 --- a/packages/core/src/memory/dream.ts +++ b/packages/core/src/memory/dream.ts @@ -19,7 +19,12 @@ import { type ScannedAutoMemoryDocument, } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; -import { AUTO_MEMORY_TYPES, type AutoMemoryMetadata, type AutoMemoryType } from './types.js'; +import { + AUTO_MEMORY_TYPES, + type AutoMemoryMetadata, + type AutoMemoryType, +} from './types.js'; +import { logMemoryDream, MemoryDreamEvent } from '../telemetry/index.js'; export interface AutoMemoryDreamResult { touchedTopics: AutoMemoryType[]; @@ -27,8 +32,10 @@ export interface AutoMemoryDreamResult { systemMessage?: string; } - -function buildDreamedBody(body: string): { body: string; dedupedEntries: number } { +function buildDreamedBody(body: string): { + body: string; + dedupedEntries: number; +} { const entries = parseAutoMemoryEntries(body); const mergedEntries = Array.from( entries.reduce((map, entry) => { @@ -54,7 +61,11 @@ async function bumpMetadata(projectRoot: string, now: Date): Promise { const metadata = JSON.parse(content) as AutoMemoryMetadata; metadata.updatedAt = now.toISOString(); metadata.lastDreamAt = now.toISOString(); - await fs.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf-8'); + await fs.writeFile( + metadataPath, + `${JSON.stringify(metadata, null, 2)}\n`, + 'utf-8', + ); } catch { // Best-effort metadata bump. } @@ -110,6 +121,7 @@ export async function runManagedAutoMemoryDream( config?: Config, ): Promise { await ensureAutoMemoryScaffold(projectRoot, now); + const t0 = Date.now(); if (config) { try { @@ -119,7 +131,21 @@ export async function runManagedAutoMemoryDream( await bumpMetadata(projectRoot, now); await rebuildManagedAutoMemoryIndex(projectRoot); } - await updateDreamMetadataResult(projectRoot, now, agentResult.touchedTopics); + await updateDreamMetadataResult( + projectRoot, + now, + agentResult.touchedTopics, + ); + logMemoryDream( + config, + new MemoryDreamEvent({ + trigger: 'auto', + status: agentResult.touchedTopics.length > 0 ? 'updated' : 'noop', + deduped_entries: agentResult.dedupedEntries, + touched_topics: agentResult.touchedTopics, + duration_ms: Date.now() - t0, + }), + ); return agentResult; } } catch { @@ -178,7 +204,7 @@ export async function runManagedAutoMemoryDream( await updateDreamMetadataResult(projectRoot, now, [...touchedTopics]); - return { + const result: AutoMemoryDreamResult = { touchedTopics: [...touchedTopics], dedupedEntries, systemMessage: @@ -186,6 +212,19 @@ export async function runManagedAutoMemoryDream( ? `Managed auto-memory dream updated: ${[...touchedTopics].map((topic) => `${topic}.md`).join(', ')}` : undefined, }; + if (config) { + logMemoryDream( + config, + new MemoryDreamEvent({ + trigger: 'auto', + status: touchedTopics.size > 0 ? 'updated' : 'noop', + deduped_entries: dedupedEntries, + touched_topics: [...touchedTopics], + duration_ms: Date.now() - t0, + }), + ); + } + return result; } async function updateDreamMetadataResult( @@ -201,7 +240,11 @@ async function updateDreamMetadataResult( metadata.lastDreamAt = now.toISOString(); metadata.lastDreamTouchedTopics = touchedTopics; metadata.lastDreamStatus = touchedTopics.length > 0 ? 'updated' : 'noop'; - await fs.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf-8'); + await fs.writeFile( + metadataPath, + `${JSON.stringify(metadata, null, 2)}\n`, + 'utf-8', + ); } catch { // Best-effort metadata bump. } @@ -219,4 +262,4 @@ export async function writeDreamManualRunToMetadata( now = new Date(), ): Promise { return updateDreamMetadataResult(projectRoot, now, []); -} \ No newline at end of file +} diff --git a/packages/core/src/memory/extractScheduler.ts b/packages/core/src/memory/extractScheduler.ts index 25045b738e6..70bbf7d2744 100644 --- a/packages/core/src/memory/extractScheduler.ts +++ b/packages/core/src/memory/extractScheduler.ts @@ -24,6 +24,7 @@ import { markExtractRunning, } from './state.js'; import { isAutoMemPath } from './paths.js'; +import { logMemoryExtract, MemoryExtractEvent } from '../telemetry/index.js'; export interface ScheduleAutoMemoryExtractParams { projectRoot: string; @@ -59,11 +60,17 @@ function buildSkippedExtractResult( */ function partWritesToMemory(part: Part, projectRoot: string): boolean { // Direct write_file or edit tool calls to a memory path - const writeToolNames = new Set(['write_file', 'edit', 'replace', 'create_file']); + const writeToolNames = new Set([ + 'write_file', + 'edit', + 'replace', + 'create_file', + ]); const name = part.functionCall?.name; if (name && writeToolNames.has(name)) { const args = part.functionCall?.args as Record | undefined; - const filePath = args?.['file_path'] ?? args?.['path'] ?? args?.['target_file']; + const filePath = + args?.['file_path'] ?? args?.['path'] ?? args?.['target_file']; if (typeof filePath === 'string' && isAutoMemPath(filePath, projectRoot)) { return true; } @@ -71,7 +78,10 @@ function partWritesToMemory(part: Part, projectRoot: string): boolean { return false; } -function historySliceUsesMemoryTool(history: Content[], projectRoot: string): boolean { +function historySliceUsesMemoryTool( + history: Content[], + projectRoot: string, +): boolean { return history.some((message) => (message.parts ?? []).some((part) => partWritesToMemory(part, projectRoot)), ); @@ -191,8 +201,10 @@ export class ManagedAutoMemoryExtractRuntime { }, }); + const t0 = Date.now(); try { const result = await runAutoMemoryExtract(params); + const durationMs = Date.now() - t0; this.registry.update(taskId, { status: result.skippedReason ? 'skipped' : 'completed', progressText: @@ -207,12 +219,37 @@ export class ManagedAutoMemoryExtractRuntime { skippedReason: result.skippedReason, }, }); + if (params.config) { + logMemoryExtract( + params.config, + new MemoryExtractEvent({ + trigger: 'auto', + status: 'completed', + patches_count: result.patches.length, + touched_topics: result.touchedTopics, + duration_ms: durationMs, + }), + ); + } return result; } catch (error) { + const durationMs = Date.now() - t0; this.registry.update(taskId, { status: 'failed', error: error instanceof Error ? error.message : String(error), }); + if (params.config) { + logMemoryExtract( + params.config, + new MemoryExtractEvent({ + trigger: 'auto', + status: 'failed', + patches_count: 0, + touched_topics: [], + duration_ms: durationMs, + }), + ); + } throw error; } finally { this.currentTaskIdByProject.delete(params.projectRoot); @@ -264,4 +301,4 @@ export function createManagedAutoMemoryExtractRuntimeForTests(): ManagedAutoMemo export function resetManagedAutoMemoryExtractRuntimeForTests(): void { defaultManagedAutoMemoryExtractRuntime.resetForTests(); -} \ No newline at end of file +} diff --git a/packages/core/src/memory/forget.ts b/packages/core/src/memory/forget.ts index bc5049a0172..5f0553b8272 100644 --- a/packages/core/src/memory/forget.ts +++ b/packages/core/src/memory/forget.ts @@ -19,6 +19,7 @@ import { getAutoMemoryMetadataPath } from './paths.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import type { AutoMemoryMetadata, AutoMemoryType } from './types.js'; +import { logMemoryForget, MemoryForgetEvent } from '../telemetry/index.js'; export interface AutoMemoryForgetMatch { topic: AutoMemoryType; @@ -330,5 +331,15 @@ export async function forgetManagedAutoMemoryEntries( selection.matches, now, ); + if (options.config) { + logMemoryForget( + options.config, + new MemoryForgetEvent({ + removed_entries_count: result.removedEntries.length, + touched_topics: result.touchedTopics, + selection_strategy: selection.strategy, + }), + ); + } return { ...result, query: trimmedQuery }; } diff --git a/packages/core/src/memory/recall.ts b/packages/core/src/memory/recall.ts index 60bcb3e49e9..5169385e9b9 100644 --- a/packages/core/src/memory/recall.ts +++ b/packages/core/src/memory/recall.ts @@ -13,6 +13,7 @@ import { } from './scan.js'; import { memoryAge, memoryFreshnessText } from './memoryAge.js'; import { selectRelevantAutoMemoryDocumentsByModel } from './relevanceSelector.js'; +import { logMemoryRecall, MemoryRecallEvent } from '../telemetry/index.js'; const MAX_RELEVANT_DOCS = 5; const MAX_DOC_BODY_CHARS = 1_200; @@ -159,6 +160,7 @@ export async function resolveRelevantAutoMemoryPromptForQuery( query: string, options: ResolveRelevantAutoMemoryPromptOptions = {}, ): Promise { + const t0 = Date.now(); const docs = filterExcludedAutoMemoryDocuments( await scanAutoMemoryTopicDocuments(projectRoot), options.excludedFilePaths, @@ -166,6 +168,18 @@ export async function resolveRelevantAutoMemoryPromptForQuery( const limit = options.limit ?? MAX_RELEVANT_DOCS; if (query.trim().length === 0 || docs.length === 0 || limit <= 0) { + if (options.config) { + logMemoryRecall( + options.config, + new MemoryRecallEvent({ + query_length: query.length, + docs_scanned: docs.length, + docs_selected: 0, + strategy: 'none', + duration_ms: Date.now() - t0, + }), + ); + } return { prompt: '', selectedDocs: [], @@ -182,10 +196,22 @@ export async function resolveRelevantAutoMemoryPromptForQuery( limit, options.recentTools ?? [], ); + const strategy: RelevantAutoMemoryPromptResult['strategy'] = + selectedDocs.length > 0 ? 'model' : 'none'; + logMemoryRecall( + options.config, + new MemoryRecallEvent({ + query_length: query.length, + docs_scanned: docs.length, + docs_selected: selectedDocs.length, + strategy, + duration_ms: Date.now() - t0, + }), + ); return { prompt: buildRelevantAutoMemoryPrompt(selectedDocs), selectedDocs, - strategy: selectedDocs.length > 0 ? 'model' : 'none', + strategy, }; } catch (error) { debugLogger.warn( @@ -196,10 +222,24 @@ export async function resolveRelevantAutoMemoryPromptForQuery( } const selectedDocs = selectRelevantAutoMemoryDocuments(query, docs, limit); + const strategy: RelevantAutoMemoryPromptResult['strategy'] = + selectedDocs.length > 0 ? 'heuristic' : 'none'; + if (options.config) { + logMemoryRecall( + options.config, + new MemoryRecallEvent({ + query_length: query.length, + docs_scanned: docs.length, + docs_selected: selectedDocs.length, + strategy, + duration_ms: Date.now() - t0, + }), + ); + } return { prompt: buildRelevantAutoMemoryPrompt(selectedDocs), selectedDocs, - strategy: selectedDocs.length > 0 ? 'heuristic' : 'none', + strategy, }; } @@ -214,4 +254,4 @@ export async function buildRelevantAutoMemoryPromptForQuery( options, ); return result.prompt; -} \ No newline at end of file +} diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index 1bd3db3b45d..e90708e1d39 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -53,3 +53,10 @@ export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance'; export const EVENT_MEMORY_USAGE = 'qwen-code.memory.usage'; export const EVENT_PERFORMANCE_BASELINE = 'qwen-code.performance.baseline'; export const EVENT_PERFORMANCE_REGRESSION = 'qwen-code.performance.regression'; + +// Managed Auto-Memory Events +export const EVENT_MEMORY_EXTRACT = 'qwen-code.memory.extract'; +export const EVENT_MEMORY_DREAM = 'qwen-code.memory.dream'; +export const EVENT_MEMORY_RECALL = 'qwen-code.memory.recall'; +export const EVENT_MEMORY_FORGET = 'qwen-code.memory.forget'; +export const EVENT_MEMORY_REMEMBER = 'qwen-code.memory.remember'; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index bab3e15a860..d9747dad6db 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -52,6 +52,11 @@ export { logArenaSessionStarted, logArenaAgentCompleted, logArenaSessionEnded, + logMemoryExtract, + logMemoryDream, + logMemoryRecall, + logMemoryForget, + logMemoryRemember, } from './loggers.js'; export type { SlashCommandEvent, ChatCompressionEvent } from './types.js'; export { @@ -78,6 +83,11 @@ export { makeArenaSessionStartedEvent, makeArenaAgentCompletedEvent, makeArenaSessionEndedEvent, + MemoryExtractEvent, + MemoryDreamEvent, + MemoryRecallEvent, + MemoryForgetEvent, + MemoryRememberEvent, } from './types.js'; export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js'; export type { @@ -117,6 +127,12 @@ export { recordArenaSessionStartedMetrics, recordArenaAgentCompletedMetrics, recordArenaSessionEndedMetrics, + // Auto-Memory metrics functions + recordMemoryExtractMetrics, + recordMemoryDreamMetrics, + recordMemoryRecallMetrics, + recordMemoryForgetMetrics, + recordMemoryRememberMetrics, // Performance monitoring types PerformanceMetricType, MemoryMetricType, diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 6cd70679998..38322e90b7f 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -47,6 +47,11 @@ import { EVENT_ARENA_SESSION_ENDED, EVENT_PROMPT_SUGGESTION, EVENT_SPECULATION, + EVENT_MEMORY_EXTRACT, + EVENT_MEMORY_DREAM, + EVENT_MEMORY_RECALL, + EVENT_MEMORY_FORGET, + EVENT_MEMORY_REMEMBER, } from './constants.js'; import { recordApiErrorMetrics, @@ -63,6 +68,11 @@ import { recordArenaSessionStartedMetrics, recordArenaAgentCompletedMetrics, recordArenaSessionEndedMetrics, + recordMemoryExtractMetrics, + recordMemoryDreamMetrics, + recordMemoryRecallMetrics, + recordMemoryForgetMetrics, + recordMemoryRememberMetrics, } from './metrics.js'; import { QwenLogger } from './qwen-logger/qwen-logger.js'; import { isTelemetrySdkInitialized } from './sdk.js'; @@ -106,6 +116,11 @@ import type { ArenaSessionEndedEvent, PromptSuggestionEvent, SpeculationEvent, + MemoryExtractEvent, + MemoryDreamEvent, + MemoryRecallEvent, + MemoryForgetEvent, + MemoryRememberEvent, } from './types.js'; import type { HookCallEvent } from './types.js'; import type { UiEvent } from './uiTelemetry.js'; @@ -1155,3 +1170,140 @@ export function logSpeculation(config: Config, event: SpeculationEvent): void { }; logger.emit(logRecord); } + +// ─── Auto-Memory Log Functions ─────────────────────────────────────────────── + +export function logMemoryExtract( + config: Config, + event: MemoryExtractEvent, +): void { + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + 'event.name': EVENT_MEMORY_EXTRACT, + 'event.timestamp': event['event.timestamp'], + trigger: event.trigger, + status: event.status, + patches_count: event.patches_count, + touched_topics: event.touched_topics, + duration_ms: event.duration_ms, + }; + if (event.skipped_reason) { + attributes['skipped_reason'] = event.skipped_reason; + } + + const logger = logs.getLogger(SERVICE_NAME); + logger.emit({ + body: `Memory extract: ${event.status}. Patches: ${event.patches_count}. Topics: ${event.touched_topics || 'none'}.`, + attributes, + }); + recordMemoryExtractMetrics(config, event.duration_ms, { + trigger: event.trigger, + status: event.status, + patches_count: event.patches_count, + }); +} + +export function logMemoryDream(config: Config, event: MemoryDreamEvent): void { + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + 'event.name': EVENT_MEMORY_DREAM, + 'event.timestamp': event['event.timestamp'], + trigger: event.trigger, + status: event.status, + deduped_entries: event.deduped_entries, + touched_topics_count: event.touched_topics_count, + touched_topics: event.touched_topics, + duration_ms: event.duration_ms, + }; + + const logger = logs.getLogger(SERVICE_NAME); + logger.emit({ + body: `Memory dream: ${event.status}. Deduped: ${event.deduped_entries}. Topics: ${event.touched_topics || 'none'}.`, + attributes, + }); + recordMemoryDreamMetrics(config, event.duration_ms, { + trigger: event.trigger, + status: event.status, + deduped_entries: event.deduped_entries, + }); +} + +export function logMemoryRecall( + config: Config, + event: MemoryRecallEvent, +): void { + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + 'event.name': EVENT_MEMORY_RECALL, + 'event.timestamp': event['event.timestamp'], + query_length: event.query_length, + docs_scanned: event.docs_scanned, + docs_selected: event.docs_selected, + strategy: event.strategy, + duration_ms: event.duration_ms, + }; + + const logger = logs.getLogger(SERVICE_NAME); + logger.emit({ + body: `Memory recall: strategy=${event.strategy}. Selected ${event.docs_selected}/${event.docs_scanned} docs.`, + attributes, + }); + recordMemoryRecallMetrics(config, event.duration_ms, { + strategy: event.strategy, + docs_selected: event.docs_selected, + }); +} + +export function logMemoryForget( + config: Config, + event: MemoryForgetEvent, +): void { + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + 'event.name': EVENT_MEMORY_FORGET, + 'event.timestamp': event['event.timestamp'], + removed_entries_count: event.removed_entries_count, + touched_topics: event.touched_topics, + selection_strategy: event.selection_strategy, + }; + + const logger = logs.getLogger(SERVICE_NAME); + logger.emit({ + body: `Memory forget: removed ${event.removed_entries_count} entr${event.removed_entries_count === 1 ? 'y' : 'ies'}. Strategy: ${event.selection_strategy}.`, + attributes, + }); + recordMemoryForgetMetrics(config, { + removed_entries_count: event.removed_entries_count, + }); +} + +export function logMemoryRemember( + config: Config, + event: MemoryRememberEvent, +): void { + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + 'event.name': EVENT_MEMORY_REMEMBER, + 'event.timestamp': event['event.timestamp'], + topic: event.topic, + }; + + const logger = logs.getLogger(SERVICE_NAME); + logger.emit({ + body: `Memory remember: topic=${event.topic}.`, + attributes, + }); + recordMemoryRememberMetrics(config, { + mode: event.topic as 'managed' | 'legacy', + }); +} diff --git a/packages/core/src/telemetry/metrics.ts b/packages/core/src/telemetry/metrics.ts index f71498c365e..57ea587c1c5 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -44,6 +44,16 @@ const REGRESSION_DETECTION = `${SERVICE_NAME}.performance.regression`; const REGRESSION_PERCENTAGE_CHANGE = `${SERVICE_NAME}.performance.regression.percentage_change`; const BASELINE_COMPARISON = `${SERVICE_NAME}.performance.baseline.comparison`; +// Auto-Memory Metrics +const MEMORY_EXTRACT_COUNT = `${SERVICE_NAME}.memory.extract.count`; +const MEMORY_EXTRACT_DURATION = `${SERVICE_NAME}.memory.extract.duration`; +const MEMORY_DREAM_COUNT = `${SERVICE_NAME}.memory.dream.count`; +const MEMORY_DREAM_DURATION = `${SERVICE_NAME}.memory.dream.duration`; +const MEMORY_RECALL_COUNT = `${SERVICE_NAME}.memory.recall.count`; +const MEMORY_RECALL_DURATION = `${SERVICE_NAME}.memory.recall.duration`; +const MEMORY_FORGET_COUNT = `${SERVICE_NAME}.memory.forget.count`; +const MEMORY_REMEMBER_COUNT = `${SERVICE_NAME}.memory.remember.count`; + const baseMetricDefinition = { getCommonAttributes: (config: Config): Attributes => ({ 'session.id': config.getSessionId(), @@ -361,6 +371,16 @@ let arenaAgentDurationHistogram: Histogram | undefined; let arenaAgentTokensCounter: Counter | undefined; let arenaResultSelectedCounter: Counter | undefined; +// Auto-Memory Metrics +let memoryExtractCounter: Counter | undefined; +let memoryExtractDurationHistogram: Histogram | undefined; +let memoryDreamCounter: Counter | undefined; +let memoryDreamDurationHistogram: Histogram | undefined; +let memoryRecallCounter: Counter | undefined; +let memoryRecallDurationHistogram: Histogram | undefined; +let memoryForgetCounter: Counter | undefined; +let memoryRememberCounter: Counter | undefined; + let isMetricsInitialized = false; let isPerformanceMonitoringEnabled = false; @@ -429,6 +449,51 @@ export function initializeMetrics(config: Config): void { // Increment session counter after all metrics are initialized sessionCounter?.add(1, baseMetricDefinition.getCommonAttributes(config)); + // Auto-Memory metrics + memoryExtractCounter = meter.createCounter(MEMORY_EXTRACT_COUNT, { + description: + 'Counts auto-memory extraction runs, tagged by trigger and status.', + valueType: ValueType.INT, + }); + memoryExtractDurationHistogram = meter.createHistogram( + MEMORY_EXTRACT_DURATION, + { + description: 'Duration of auto-memory extraction in milliseconds.', + unit: 'ms', + valueType: ValueType.INT, + }, + ); + memoryDreamCounter = meter.createCounter(MEMORY_DREAM_COUNT, { + description: + 'Counts auto-memory dream (consolidation) runs, tagged by trigger and status.', + valueType: ValueType.INT, + }); + memoryDreamDurationHistogram = meter.createHistogram(MEMORY_DREAM_DURATION, { + description: 'Duration of auto-memory dream runs in milliseconds.', + unit: 'ms', + valueType: ValueType.INT, + }); + memoryRecallCounter = meter.createCounter(MEMORY_RECALL_COUNT, { + description: 'Counts auto-memory recall operations, tagged by strategy.', + valueType: ValueType.INT, + }); + memoryRecallDurationHistogram = meter.createHistogram( + MEMORY_RECALL_DURATION, + { + description: 'Duration of auto-memory recall operations in milliseconds.', + unit: 'ms', + valueType: ValueType.INT, + }, + ); + memoryForgetCounter = meter.createCounter(MEMORY_FORGET_COUNT, { + description: 'Counts auto-memory forget operations.', + valueType: ValueType.INT, + }); + memoryRememberCounter = meter.createCounter(MEMORY_REMEMBER_COUNT, { + description: 'Counts /remember command invocations.', + valueType: ValueType.INT, + }); + // Initialize performance monitoring metrics if enabled initializePerformanceMonitoring(config); @@ -876,3 +941,87 @@ export function recordArenaSessionEndedMetrics( }); } } + +// ─── Auto-Memory Metric Recording Functions ───────────────────────────────── + +export function recordMemoryExtractMetrics( + config: Config, + durationMs: number, + attrs: { + trigger: 'auto' | 'manual'; + status: 'completed' | 'skipped' | 'failed'; + patches_count: number; + }, +): void { + if (!isMetricsInitialized) return; + const common = baseMetricDefinition.getCommonAttributes(config); + memoryExtractCounter?.add(1, { + ...common, + trigger: attrs.trigger, + status: attrs.status, + }); + memoryExtractDurationHistogram?.record(durationMs, { + ...common, + trigger: attrs.trigger, + status: attrs.status, + }); +} + +export function recordMemoryDreamMetrics( + config: Config, + durationMs: number, + attrs: { + trigger: 'auto' | 'manual'; + status: 'updated' | 'noop' | 'failed'; + deduped_entries: number; + }, +): void { + if (!isMetricsInitialized) return; + const common = baseMetricDefinition.getCommonAttributes(config); + memoryDreamCounter?.add(1, { + ...common, + trigger: attrs.trigger, + status: attrs.status, + }); + memoryDreamDurationHistogram?.record(durationMs, { + ...common, + trigger: attrs.trigger, + status: attrs.status, + }); +} + +export function recordMemoryRecallMetrics( + config: Config, + durationMs: number, + attrs: { strategy: 'none' | 'heuristic' | 'model'; docs_selected: number }, +): void { + if (!isMetricsInitialized) return; + const common = baseMetricDefinition.getCommonAttributes(config); + memoryRecallCounter?.add(1, { ...common, strategy: attrs.strategy }); + memoryRecallDurationHistogram?.record(durationMs, { + ...common, + strategy: attrs.strategy, + }); +} + +export function recordMemoryForgetMetrics( + config: Config, + attrs: { removed_entries_count: number }, +): void { + if (!isMetricsInitialized) return; + memoryForgetCounter?.add(1, { + ...baseMetricDefinition.getCommonAttributes(config), + removed_entries_count: attrs.removed_entries_count, + }); +} + +export function recordMemoryRememberMetrics( + config: Config, + attrs: { mode: 'managed' | 'legacy' }, +): void { + if (!isMetricsInitialized) return; + memoryRememberCounter?.add(1, { + ...baseMetricDefinition.getCommonAttributes(config), + mode: attrs.mode, + }); +} diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 575e4c1b101..c1828ad2584 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -1135,3 +1135,124 @@ export class SpeculationEvent implements BaseTelemetryEvent { this.had_pipelined_suggestion = params.had_pipelined_suggestion; } } + +// --------------------------------------------------------------------------- +// Managed Auto-Memory Events +// --------------------------------------------------------------------------- + +export class MemoryExtractEvent implements BaseTelemetryEvent { + 'event.name': 'qwen-code.memory.extract'; + 'event.timestamp': string; + /** 'auto' = triggered by session turn; 'manual' = user-initiated */ + trigger: 'auto' | 'manual'; + status: 'completed' | 'skipped' | 'failed'; + skipped_reason?: 'already_running' | 'queued' | 'memory_tool'; + patches_count: number; + touched_topics: string; + duration_ms: number; + + constructor(params: { + trigger: 'auto' | 'manual'; + status: 'completed' | 'skipped' | 'failed'; + skipped_reason?: 'already_running' | 'queued' | 'memory_tool'; + patches_count: number; + touched_topics: string[]; + duration_ms: number; + }) { + this['event.name'] = 'qwen-code.memory.extract'; + this['event.timestamp'] = new Date().toISOString(); + this.trigger = params.trigger; + this.status = params.status; + this.skipped_reason = params.skipped_reason; + this.patches_count = params.patches_count; + this.touched_topics = params.touched_topics.join(','); + this.duration_ms = params.duration_ms; + } +} + +export class MemoryDreamEvent implements BaseTelemetryEvent { + 'event.name': 'qwen-code.memory.dream'; + 'event.timestamp': string; + /** 'auto' = scheduler-triggered; 'manual' = user ran /dream */ + trigger: 'auto' | 'manual'; + status: 'updated' | 'noop' | 'failed'; + deduped_entries: number; + touched_topics_count: number; + touched_topics: string; + duration_ms: number; + + constructor(params: { + trigger: 'auto' | 'manual'; + status: 'updated' | 'noop' | 'failed'; + deduped_entries: number; + touched_topics: string[]; + duration_ms: number; + }) { + this['event.name'] = 'qwen-code.memory.dream'; + this['event.timestamp'] = new Date().toISOString(); + this.trigger = params.trigger; + this.status = params.status; + this.deduped_entries = params.deduped_entries; + this.touched_topics_count = params.touched_topics.length; + this.touched_topics = params.touched_topics.join(','); + this.duration_ms = params.duration_ms; + } +} + +export class MemoryRecallEvent implements BaseTelemetryEvent { + 'event.name': 'qwen-code.memory.recall'; + 'event.timestamp': string; + query_length: number; + docs_scanned: number; + docs_selected: number; + strategy: 'none' | 'heuristic' | 'model'; + duration_ms: number; + + constructor(params: { + query_length: number; + docs_scanned: number; + docs_selected: number; + strategy: 'none' | 'heuristic' | 'model'; + duration_ms: number; + }) { + this['event.name'] = 'qwen-code.memory.recall'; + this['event.timestamp'] = new Date().toISOString(); + this.query_length = params.query_length; + this.docs_scanned = params.docs_scanned; + this.docs_selected = params.docs_selected; + this.strategy = params.strategy; + this.duration_ms = params.duration_ms; + } +} + +export class MemoryForgetEvent implements BaseTelemetryEvent { + 'event.name': 'qwen-code.memory.forget'; + 'event.timestamp': string; + removed_entries_count: number; + touched_topics: string; + selection_strategy: 'none' | 'heuristic' | 'model'; + + constructor(params: { + removed_entries_count: number; + touched_topics: string[]; + selection_strategy: 'none' | 'heuristic' | 'model'; + }) { + this['event.name'] = 'qwen-code.memory.forget'; + this['event.timestamp'] = new Date().toISOString(); + this.removed_entries_count = params.removed_entries_count; + this.touched_topics = params.touched_topics.join(','); + this.selection_strategy = params.selection_strategy; + } +} + +export class MemoryRememberEvent implements BaseTelemetryEvent { + 'event.name': 'qwen-code.memory.remember'; + 'event.timestamp': string; + topic: string; + + constructor(params: { topic: string }) { + this['event.name'] = 'qwen-code.memory.remember'; + this['event.timestamp'] = new Date().toISOString(); + this.topic = params.topic; + } +} From 465fb056f8a9f95fb8425bb34c0d69d92d26be4d Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 10 Apr 2026 16:39:18 +0800 Subject: [PATCH 44/56] refactor(telemetry): remove memory forget/remember telemetry events Remove EVENT_MEMORY_FORGET and EVENT_MEMORY_REMEMBER along with all associated infrastructure that is no longer needed: - constants.ts: remove EVENT_MEMORY_FORGET, EVENT_MEMORY_REMEMBER - types.ts: remove MemoryForgetEvent, MemoryRememberEvent classes - metrics.ts: remove MEMORY_FORGET_COUNT, MEMORY_REMEMBER_COUNT constants, memoryForgetCounter, memoryRememberCounter module vars, their initialization in initializeMetrics(), and recordMemoryForgetMetrics(), recordMemoryRememberMetrics() functions - loggers.ts: remove logMemoryForget(), logMemoryRemember() functions and their imports - index.ts: remove all re-exports for the above symbols - memory/forget.ts: remove logMemoryForget call-site and import - cli/rememberCommand.ts: remove logMemoryRemember call-sites and import --- .../cli/src/ui/commands/rememberCommand.ts | 14 +---- packages/core/src/memory/forget.ts | 11 ---- packages/core/src/telemetry/constants.ts | 2 - packages/core/src/telemetry/index.ts | 6 --- packages/core/src/telemetry/loggers.ts | 54 ------------------- packages/core/src/telemetry/metrics.ts | 35 ------------ packages/core/src/telemetry/types.ts | 32 ----------- 7 files changed, 1 insertion(+), 153 deletions(-) diff --git a/packages/cli/src/ui/commands/rememberCommand.ts b/packages/cli/src/ui/commands/rememberCommand.ts index 9b5241b3b8e..c87c5b38a0b 100644 --- a/packages/cli/src/ui/commands/rememberCommand.ts +++ b/packages/cli/src/ui/commands/rememberCommand.ts @@ -4,10 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { getAutoMemoryRoot , - logMemoryRemember, - MemoryRememberEvent, -} from '@qwen-code/qwen-code-core'; +import { getAutoMemoryRoot } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; import type { CommandContext, @@ -44,12 +41,6 @@ export const rememberCommand: SlashCommand = { ? getAutoMemoryRoot(config.getProjectRoot()) : undefined; const dirHint = memoryDir ? ` Save it to \`${memoryDir}\`.` : ''; - if (config) { - logMemoryRemember( - config, - new MemoryRememberEvent({ topic: 'managed' }), - ); - } return { type: 'submit_prompt', content: `Please save the following to your memory system.${dirHint} Choose the most appropriate memory type (user, feedback, project, or reference) based on the content:\n\n${fact}`, @@ -57,9 +48,6 @@ export const rememberCommand: SlashCommand = { } // Legacy mode: save_memory tool is registered and handles the write. - if (config) { - logMemoryRemember(config, new MemoryRememberEvent({ topic: 'legacy' })); - } return { type: 'tool', toolName: 'save_memory', diff --git a/packages/core/src/memory/forget.ts b/packages/core/src/memory/forget.ts index 5f0553b8272..bc5049a0172 100644 --- a/packages/core/src/memory/forget.ts +++ b/packages/core/src/memory/forget.ts @@ -19,7 +19,6 @@ import { getAutoMemoryMetadataPath } from './paths.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import type { AutoMemoryMetadata, AutoMemoryType } from './types.js'; -import { logMemoryForget, MemoryForgetEvent } from '../telemetry/index.js'; export interface AutoMemoryForgetMatch { topic: AutoMemoryType; @@ -331,15 +330,5 @@ export async function forgetManagedAutoMemoryEntries( selection.matches, now, ); - if (options.config) { - logMemoryForget( - options.config, - new MemoryForgetEvent({ - removed_entries_count: result.removedEntries.length, - touched_topics: result.touchedTopics, - selection_strategy: selection.strategy, - }), - ); - } return { ...result, query: trimmedQuery }; } diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index e90708e1d39..5b318d26cf3 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -58,5 +58,3 @@ export const EVENT_PERFORMANCE_REGRESSION = 'qwen-code.performance.regression'; export const EVENT_MEMORY_EXTRACT = 'qwen-code.memory.extract'; export const EVENT_MEMORY_DREAM = 'qwen-code.memory.dream'; export const EVENT_MEMORY_RECALL = 'qwen-code.memory.recall'; -export const EVENT_MEMORY_FORGET = 'qwen-code.memory.forget'; -export const EVENT_MEMORY_REMEMBER = 'qwen-code.memory.remember'; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index d9747dad6db..4e1aa54a775 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -55,8 +55,6 @@ export { logMemoryExtract, logMemoryDream, logMemoryRecall, - logMemoryForget, - logMemoryRemember, } from './loggers.js'; export type { SlashCommandEvent, ChatCompressionEvent } from './types.js'; export { @@ -86,8 +84,6 @@ export { MemoryExtractEvent, MemoryDreamEvent, MemoryRecallEvent, - MemoryForgetEvent, - MemoryRememberEvent, } from './types.js'; export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js'; export type { @@ -131,8 +127,6 @@ export { recordMemoryExtractMetrics, recordMemoryDreamMetrics, recordMemoryRecallMetrics, - recordMemoryForgetMetrics, - recordMemoryRememberMetrics, // Performance monitoring types PerformanceMetricType, MemoryMetricType, diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 38322e90b7f..a759ef26f3a 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -50,8 +50,6 @@ import { EVENT_MEMORY_EXTRACT, EVENT_MEMORY_DREAM, EVENT_MEMORY_RECALL, - EVENT_MEMORY_FORGET, - EVENT_MEMORY_REMEMBER, } from './constants.js'; import { recordApiErrorMetrics, @@ -71,8 +69,6 @@ import { recordMemoryExtractMetrics, recordMemoryDreamMetrics, recordMemoryRecallMetrics, - recordMemoryForgetMetrics, - recordMemoryRememberMetrics, } from './metrics.js'; import { QwenLogger } from './qwen-logger/qwen-logger.js'; import { isTelemetrySdkInitialized } from './sdk.js'; @@ -119,8 +115,6 @@ import type { MemoryExtractEvent, MemoryDreamEvent, MemoryRecallEvent, - MemoryForgetEvent, - MemoryRememberEvent, } from './types.js'; import type { HookCallEvent } from './types.js'; import type { UiEvent } from './uiTelemetry.js'; @@ -1259,51 +1253,3 @@ export function logMemoryRecall( docs_selected: event.docs_selected, }); } - -export function logMemoryForget( - config: Config, - event: MemoryForgetEvent, -): void { - if (!isTelemetrySdkInitialized()) return; - - const attributes: LogAttributes = { - ...getCommonAttributes(config), - 'event.name': EVENT_MEMORY_FORGET, - 'event.timestamp': event['event.timestamp'], - removed_entries_count: event.removed_entries_count, - touched_topics: event.touched_topics, - selection_strategy: event.selection_strategy, - }; - - const logger = logs.getLogger(SERVICE_NAME); - logger.emit({ - body: `Memory forget: removed ${event.removed_entries_count} entr${event.removed_entries_count === 1 ? 'y' : 'ies'}. Strategy: ${event.selection_strategy}.`, - attributes, - }); - recordMemoryForgetMetrics(config, { - removed_entries_count: event.removed_entries_count, - }); -} - -export function logMemoryRemember( - config: Config, - event: MemoryRememberEvent, -): void { - if (!isTelemetrySdkInitialized()) return; - - const attributes: LogAttributes = { - ...getCommonAttributes(config), - 'event.name': EVENT_MEMORY_REMEMBER, - 'event.timestamp': event['event.timestamp'], - topic: event.topic, - }; - - const logger = logs.getLogger(SERVICE_NAME); - logger.emit({ - body: `Memory remember: topic=${event.topic}.`, - attributes, - }); - recordMemoryRememberMetrics(config, { - mode: event.topic as 'managed' | 'legacy', - }); -} diff --git a/packages/core/src/telemetry/metrics.ts b/packages/core/src/telemetry/metrics.ts index 57ea587c1c5..1b87d78e98f 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -51,8 +51,6 @@ const MEMORY_DREAM_COUNT = `${SERVICE_NAME}.memory.dream.count`; const MEMORY_DREAM_DURATION = `${SERVICE_NAME}.memory.dream.duration`; const MEMORY_RECALL_COUNT = `${SERVICE_NAME}.memory.recall.count`; const MEMORY_RECALL_DURATION = `${SERVICE_NAME}.memory.recall.duration`; -const MEMORY_FORGET_COUNT = `${SERVICE_NAME}.memory.forget.count`; -const MEMORY_REMEMBER_COUNT = `${SERVICE_NAME}.memory.remember.count`; const baseMetricDefinition = { getCommonAttributes: (config: Config): Attributes => ({ @@ -378,8 +376,6 @@ let memoryDreamCounter: Counter | undefined; let memoryDreamDurationHistogram: Histogram | undefined; let memoryRecallCounter: Counter | undefined; let memoryRecallDurationHistogram: Histogram | undefined; -let memoryForgetCounter: Counter | undefined; -let memoryRememberCounter: Counter | undefined; let isMetricsInitialized = false; let isPerformanceMonitoringEnabled = false; @@ -485,15 +481,6 @@ export function initializeMetrics(config: Config): void { valueType: ValueType.INT, }, ); - memoryForgetCounter = meter.createCounter(MEMORY_FORGET_COUNT, { - description: 'Counts auto-memory forget operations.', - valueType: ValueType.INT, - }); - memoryRememberCounter = meter.createCounter(MEMORY_REMEMBER_COUNT, { - description: 'Counts /remember command invocations.', - valueType: ValueType.INT, - }); - // Initialize performance monitoring metrics if enabled initializePerformanceMonitoring(config); @@ -1003,25 +990,3 @@ export function recordMemoryRecallMetrics( strategy: attrs.strategy, }); } - -export function recordMemoryForgetMetrics( - config: Config, - attrs: { removed_entries_count: number }, -): void { - if (!isMetricsInitialized) return; - memoryForgetCounter?.add(1, { - ...baseMetricDefinition.getCommonAttributes(config), - removed_entries_count: attrs.removed_entries_count, - }); -} - -export function recordMemoryRememberMetrics( - config: Config, - attrs: { mode: 'managed' | 'legacy' }, -): void { - if (!isMetricsInitialized) return; - memoryRememberCounter?.add(1, { - ...baseMetricDefinition.getCommonAttributes(config), - mode: attrs.mode, - }); -} diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index c1828ad2584..33b844bfadb 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -1224,35 +1224,3 @@ export class MemoryRecallEvent implements BaseTelemetryEvent { this.duration_ms = params.duration_ms; } } - -export class MemoryForgetEvent implements BaseTelemetryEvent { - 'event.name': 'qwen-code.memory.forget'; - 'event.timestamp': string; - removed_entries_count: number; - touched_topics: string; - selection_strategy: 'none' | 'heuristic' | 'model'; - - constructor(params: { - removed_entries_count: number; - touched_topics: string[]; - selection_strategy: 'none' | 'heuristic' | 'model'; - }) { - this['event.name'] = 'qwen-code.memory.forget'; - this['event.timestamp'] = new Date().toISOString(); - this.removed_entries_count = params.removed_entries_count; - this.touched_topics = params.touched_topics.join(','); - this.selection_strategy = params.selection_strategy; - } -} - -export class MemoryRememberEvent implements BaseTelemetryEvent { - 'event.name': 'qwen-code.memory.remember'; - 'event.timestamp': string; - topic: string; - - constructor(params: { topic: string }) { - this['event.name'] = 'qwen-code.memory.remember'; - this['event.timestamp'] = new Date().toISOString(); - this.topic = params.topic; - } -} From b74674d64cdf5ba11ff7df0ec26f715a9751b558 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 13 Apr 2026 16:28:44 +0800 Subject: [PATCH 45/56] change default value --- docs/developers/memory-system.md | 509 ++++++++++++++++++ docs/users/configuration/settings.md | 4 +- packages/cli/src/config/config.ts | 2 +- packages/cli/src/config/settingsSchema.ts | 2 +- packages/core/src/config/config.ts | 2 +- .../schemas/settings.schema.json | 2 +- 6 files changed, 515 insertions(+), 6 deletions(-) create mode 100644 docs/developers/memory-system.md diff --git a/docs/developers/memory-system.md b/docs/developers/memory-system.md new file mode 100644 index 00000000000..c3c96246044 --- /dev/null +++ b/docs/developers/memory-system.md @@ -0,0 +1,509 @@ +# Memory 记忆管理系统 + +> 本文介绍 Qwen Code 中 **Managed Auto-Memory**(托管自动记忆)的记忆管理机制、触发时机和实现细节。 + +--- + +## 目录 + +1. [概述](#概述) +2. [存储结构](#存储结构) +3. [记忆类型](#记忆类型) +4. [记忆条目格式](#记忆条目格式) +5. [核心生命周期](#核心生命周期) +6. [Extract — 提取](#extract--提取) +7. [Dream — 整合](#dream--整合) +8. [Recall — 召回](#recall--召回) +9. [Forget — 遗忘](#forget--遗忘) +10. [索引重建](#索引重建) +11. [遥测埋点](#遥测埋点) + +--- + +## 概述 + +Managed Auto-Memory 是一套在 AI 会话过程中**自动**积累、整合和检索用户相关知识的持久化记忆系统。它通过四个核心操作维护记忆的生命周期: + +| 操作 | 英文 | 触发方式 | 作用 | +| ---- | ------- | -------------------------- | -------------------------------------- | +| 提取 | Extract | 自动(每轮对话后) | 从对话记录中提炼新知识写入记忆文件 | +| 整合 | Dream | 自动(周期性后台任务) | 对记忆文件去重、合并,保持整洁 | +| 召回 | Recall | 自动(每轮对话前) | 检索与当前请求相关的记忆注入到系统提示 | +| 遗忘 | Forget | 手动(用户命令 `/forget`) | 精确删除指定的记忆条目 | + +--- + +## 存储结构 + +### 目录布局 + +``` +~/.qwen/ ← 全局基础目录(默认) +└── projects/ + └── / ← 项目标识(基于 Git 根路径) + ├── meta.json ← 元数据(提取/整合时间戳、状态) + ├── extract-cursor.json ← 提取游标(已处理的对话偏移量) + ├── consolidation.lock ← Dream 进程互斥锁 + └── memory/ ← 记忆主目录 + ├── MEMORY.md ← 索引文件(自动生成,汇总所有条目) + ├── user.md ← 用户偏好记忆(示例) + ├── feedback.md ← 反馈规范记忆(示例) + ├── project/ + │ └── milestone.md ← 项目记忆(支持子目录) + └── reference/ + └── grafana.md ← 外部资源记忆 +``` + +> **环境变量覆盖**: +> +> - `QWEN_CODE_MEMORY_BASE_DIR`:替换全局基础目录 +> - `QWEN_CODE_MEMORY_LOCAL=1`:改用项目内路径 `.qwen/memory/` + +### 关键文件说明 + +| 文件 | 说明 | +| --------------------- | ---------------------------------------------------------------------- | +| `meta.json` | 记录最后一次 Extract / Dream 的时间、会话 ID、涉及的记忆类型、执行状态 | +| `extract-cursor.json` | 记录当前会话已处理到对话历史的哪个偏移量,避免重复提取 | +| `consolidation.lock` | Dream 运行时的文件锁,内容为持有者 PID,超过 1 小时自动失效 | +| `MEMORY.md` | 所有主题文件的索引,每次 Extract/Dream 后重建,格式为 Markdown 列表 | + +--- + +## 记忆类型 + +系统支持四种内置记忆类型,每种对应不同的信息维度: + +| 类型 | 存储内容 | 何时写入 | 何时读取 | +| ----------- | ----------------------------------------------------- | ---------------------------------------- | ---------------------------- | +| `user` | 用户的角色、技能背景、工作习惯 | 了解到用户角色/偏好/知识背景时 | 回答需要根据用户背景定制时 | +| `feedback` | 用户对 AI 行为的指导:避免什么、继续什么 | 用户纠正 AI 或确认某种非显而易见的做法时 | 影响 AI 行为方式时 | +| `project` | 项目进展、目标、决策、截止日期、Bug 追踪 | 了解到谁在做什么、为什么、截止何时时 | 帮助 AI 理解工作背景和动机时 | +| `reference` | 外部系统资源指针(Dashboard、工单系统、Slack 频道等) | 得知某种外部资源及其用途时 | 用户提及外部系统或相关信息时 | + +**不应该存入记忆的内容**:代码模式/约定、Git 历史、调试方案、临时任务状态、已在 QWEN.md/AGENTS.md 中记录的内容。 + +--- + +## 记忆条目格式 + +每个主题文件使用 **YAML frontmatter + Markdown body** 格式: + +```markdown +--- +name: 记忆名称 +description: 一句话描述(用于判断召回相关性,要具体) +type: user|feedback|project|reference +--- + +记忆主体内容(summary 行) + +Why: 背后原因(让 AI 能理解边界情况而不是盲目遵守规则) +How to apply: 适用场景和使用方式 +``` + +对于 `feedback` 和 `project` 类型,强烈建议填写 `Why` 和 `How to apply`,使记忆在边界情况下仍能正确应用。 + +--- + +## 核心生命周期 + +```mermaid +flowchart TD + A([用户发送请求]) --> B + + subgraph "召回 Recall" + B[扫描所有主题文件] --> C{文档数量和\n查询内容是否有效?} + C -- 否 --> D[返回空提示词\nstrategy: none] + C -- 是 --> E{是否配置了 Config?} + E -- 是 --> F[模型驱动选择\nside query] + F --> G{选出相关文档?} + G -- 是 --> H[strategy: model] + G -- 否 --> I[strategy: none] + E -- 否 --> J[启发式关键词评分] + F -- 失败 --> J + J --> K{有得分 > 0 的文档?} + K -- 是 --> L[strategy: heuristic] + K -- 否 --> I + H --> M[构建 Relevant Memory 提示词\n注入系统提示] + L --> M + I --> N[不注入记忆] + end + + M --> O([AI 处理请求]) + N --> O + D --> O + + O --> P([AI 返回响应]) + + subgraph "提取 Extract(后台)" + P --> Q{本轮 AI 是否\n直接写了记忆文件?} + Q -- 是 --> R[跳过\nmemory_tool] + Q -- 否 --> S{提取任务是否\n正在运行?} + S -- 是 --> T[放入队列或跳过\nalready_running / queued] + S -- 否 --> U[加载未处理的对话切片\n基于 extract cursor] + U --> V[调用提取 Agent\nrunAutoMemoryExtractionByAgent] + V --> W[去重规范化 patches] + W --> X{有 touched topics?} + X -- 是 --> Y[更新 meta.json\n重建 MEMORY.md 索引] + X -- 否 --> Z[仅更新 extract cursor] + Y --> Z + end + + subgraph "Dream 整合(后台,周期性)" + P --> AA{Dream 调度门控检查} + AA --> AB{是否同一会话?} + AB -- 是 --> AC[跳过\nsame_session] + AB -- 否 --> AD{距上次 Dream\n≥ 24 小时?} + AD -- 否 --> AE[跳过\nmin_hours] + AD -- 是 --> AF{距上次 Dream 后\n新会话数 ≥ 5?} + AF -- 否 --> AG[跳过\nmin_sessions] + AF -- 是 --> AH{consolidation.lock\n是否存在?} + AH -- 是 --> AI[跳过\nlocked] + AH -- 否 --> AJ[获取锁\n写入 PID] + AJ --> AK{是否配置了 Config?} + AK -- 是 --> AL[Agent 路径\nplanManagedAutoMemoryDreamByAgent] + AL --> AM{Agent 是否触碰了文件?} + AM -- 是 --> AN[记录触碰的 topics] + AM -- "否/失败" --> AO + AK -- 否 --> AO[机械去重路径\n解析+去重+按字母排序] + AO --> AP[写回更新后的主题文件] + AN --> AQ[重建 MEMORY.md 索引\n更新 meta.json] + AP --> AQ + AQ --> AR[释放锁] + end +``` + +--- + +## Extract — 提取 + +### 触发时机 + +每次 AI 完成一轮响应后,由 `scheduleAutoMemoryExtract` 自动触发(后台非阻塞)。 + +### 调度逻辑(`extractScheduler.ts`) + +```mermaid +flowchart TD + A[scheduleAutoMemoryExtract 被调用] --> B{本轮历史记录中\n是否有写记忆文件的工具调用?} + B -- 是 --> C[登记 skipped 任务\n原因: memory_tool] + B -- 否 --> D{isExtractRunning?} + D -- 是 --> E{是否已有 queued 请求?} + E -- 是 --> F[更新 queued 请求的\nhistory 参数] + E -- 否 --> G[注册 pending 任务\n放入 queue] + D -- 否 --> H[注册 running 任务\n调用 runTask] + H --> I[markExtractRunning\nsetCurrentTaskId] + I --> J[runAutoMemoryExtract] + J --> K[任务完成] + K --> L[clearExtractRunning\n检查 queue → startQueuedIfNeeded] + F --> M[返回 skipped: queued] + G --> M + C --> N[返回 skipped: memory_tool] +``` + +**跳过原因说明**: + +| 原因 | 含义 | +| ----------------- | ----------------------------------------------- | +| `memory_tool` | 本轮主 Agent 已直接写了记忆文件,跳过以避免冲突 | +| `already_running` | 提取正在进行且无法入队 | +| `queued` | 已有提取在运行,本次请求已入队 | + +### 核心提取流程(`extract.ts`) + +```mermaid +flowchart TD + A[runAutoMemoryExtract] --> B[ensureAutoMemoryScaffold\n初始化目录和文件] + B --> C[buildTranscriptMessages\n将 Content[] 转换为带 offset 的消息列表] + C --> D[readExtractCursor\n读取上次处理到的位置] + D --> E[loadUnprocessedTranscriptSlice\n截取未处理的消息段] + E --> F{slice 为空?} + F -- 是 --> G[返回无 patches 结果] + F -- 否 --> H[runAutoMemoryExtractionByAgent\n调用 forked agent 提取 patches] + H --> I[dedupeExtractPatches\n去重+规范化] + I --> J{有 touched topics?} + J -- 是 --> K[bumpMetadata\n更新 meta.json] + K --> L[rebuildManagedAutoMemoryIndex\n重建 MEMORY.md] + L --> M[writeExtractCursor\n记录最新 offset] + J -- 否 --> M + M --> N[返回 AutoMemoryExtractResult] +``` + +**提取游标(Cursor)**: + +- 字段:`{ sessionId, processedOffset, updatedAt }` +- 每次提取后更新 `processedOffset` 为当前历史长度 +- 下次提取时,只处理 `offset >= processedOffset` 的消息 +- 跨会话时(`sessionId` 变化)从偏移量 0 重新开始 + +**Patch 过滤规则**: + +- 摘要长度 < 12 字符 → 丢弃 +- 摘要以 `?` 结尾 → 丢弃(疑问句) +- 包含临时性关键词(today/now/currently/temporary 等)→ 丢弃 +- 相同 `topic:summary` 组合 → 去重 + +--- + +## Dream — 整合 + +### 触发时机 + +每次 AI 完成一轮响应后,由 `scheduleManagedAutoMemoryDream` 自动触发(后台非阻塞)。但受多个门控条件保护,大多数情况下会被跳过。 + +### 调度门控(`dreamScheduler.ts`) + +```mermaid +flowchart TD + A[scheduleManagedAutoMemoryDream 被调用] --> B{Dream 功能是否启用?} + B -- 否 --> C[跳过: disabled] + B -- 是 --> D[ensureAutoMemoryScaffold\n读取 lastDreamSessionId] + D --> E{当前 sessionId\n== lastDreamSessionId?} + E -- 是 --> F[跳过: same_session] + E -- 否 --> G{elapsedHours ≥ 24h\n或从未 dream?} + G -- 否 --> H[跳过: min_hours] + G -- 是 --> I{距上次 session scan\n< 10 分钟?} + I -- 是 --> J[跳过: min_sessions\n等待下次扫描窗口] + I -- 否 --> K[扫描 chats/*.jsonl mtime\n统计上次 Dream 后的新会话数] + K --> L{新会话数 ≥ 5?} + L -- 否 --> M[跳过: min_sessions] + L -- 是 --> N{lockExists?\nPID 检查 + 过期检查} + N -- 是 --> O[跳过: locked] + N -- 否 --> P{dedupeKey 是否已有\n同项目 Dream 任务?} + P -- 是 --> Q[跳过: running\n返回已有 taskId] + P -- 否 --> R[调度后台任务\nBgTaskScheduler] + R --> S[acquireDreamLock\n写入 PID 到 consolidation.lock] + S --> T[runManagedAutoMemoryDream] + T --> U[更新 meta.json\n释放锁] +``` + +**门控参数**: + +| 参数 | 默认值 | 说明 | +| -------------------------- | -------- | ----------------------------- | +| `minHoursBetweenDreams` | 24 小时 | 两次 Dream 之间的最小时间间隔 | +| `minSessionsBetweenDreams` | 5 个会话 | 触发 Dream 所需的最小新会话数 | +| `SESSION_SCAN_INTERVAL_MS` | 10 分钟 | 会话文件扫描的节流间隔 | +| `DREAM_LOCK_STALE_MS` | 1 小时 | lock 文件被视为过期的时间阈值 | + +**锁机制**: + +- lock 文件位于 `/consolidation.lock` +- 内容为持有进程的 PID +- 检查时:若 PID 进程已不存在(`kill(pid, 0)` 失败)或 lock 超过 1 小时 → 视为过期,自动清除 + +### 整合执行流程(`dream.ts`) + +```mermaid +flowchart TD + A[runManagedAutoMemoryDream] --> B{是否配置了 Config?} + B -- 是 --> C[Agent 路径\nplanManagedAutoMemoryDreamByAgent] + C --> D{Agent 是否修改了文件?} + D -- 是 --> E[从文件路径推断 touched topics] + E --> F[bumpMetadata\n重建 MEMORY.md 索引] + F --> G[updateDreamMetadataResult] + G --> H[记录遥测事件] + H --> I[返回结果] + B -- 否 --> J[机械去重路径] + C -- 抛出异常 --> J + D -- 否 --> J + + J --> K[scanAutoMemoryTopicDocuments\n读取所有主题文件] + K --> L[对每个文件执行 buildDreamedBody] + L --> M[解析 entries → 按 summary 去重\n按字母升序排序 → 重新渲染] + M --> N{body 有变化?} + N -- 是 --> O[写回文件] + O --> P[记录 touched topic] + N --> Q[检查跨文件重复\ndedupeKey = type:summary] + Q --> R{发现重复文件?} + R -- 是 --> S[合并 entries 到 canonical 文件\n删除重复文件] + S --> P + R -- 否 --> T{有 touched topics?} + P --> T + T -- 是 --> U[bumpMetadata\n重建 MEMORY.md 索引] + U --> V[updateDreamMetadataResult\n记录遥测 → 返回结果] + T -- 否 --> V +``` + +**机械去重逻辑**: + +1. 对每个主题文件内部:按 `summary.toLowerCase()` 去重,合并 `why`/`howToApply` 字段 +2. 按 summary 字母顺序重新排序 +3. 跨文件:相同 `type:summary` 的条目合并到最先发现的文件,删除重复文件 + +--- + +## Recall — 召回 + +### 触发时机 + +每轮 AI 处理用户请求之前,由 `resolveRelevantAutoMemoryPromptForQuery` 自动触发,将相关记忆注入系统提示词。 + +### 召回流程(`recall.ts`) + +```mermaid +flowchart TD + A[resolveRelevantAutoMemoryPromptForQuery] --> B[scanAutoMemoryTopicDocuments\n扫描所有主题文件] + B --> C[filterExcludedAutoMemoryDocuments\n过滤本轮已写入的文件] + C --> D{query 为空\n或 docs 为空\n或 limit <= 0?} + D -- 是 --> E[返回空 prompt\nstrategy: none] + D -- 否 --> F{是否配置了 Config?} + F -- 是 --> G[selectRelevantAutoMemoryDocumentsByModel\n发起 side query 请求模型选择] + G --> H{模型返回结果?} + H -- 有文档 --> I[strategy: model] + H -- 无文档 --> J[strategy: none\n仍然返回空] + G -- "失败/异常" --> K[回退到启发式选择] + F -- 否 --> K + K --> L[tokenize query\n提取 ≥3 字符的 token] + L --> M[scoreDocument 打分\n关键词匹配 +2 / 类型关键词 +1 / 有内容 +1] + M --> N[过滤 score=0 的文档\n按分数降序排列,取 Top 5] + N --> O{有得分文档?} + O -- 是 --> P[strategy: heuristic] + O -- 否 --> J + I --> Q[buildRelevantAutoMemoryPrompt\n构建 Relevant Memory 区块] + P --> Q + Q --> R[返回注入主系统提示的 prompt 片段] +``` + +**评分规则(启发式)**: + +| 条件 | 加分 | +| -------------------------------- | ---------------- | +| query token 出现在文档内容中 | +2(每个 token) | +| query token 是该类型的特征关键词 | +1(每个 token) | +| 文档 body 非空 | +1 | + +**每种类型的特征关键词**: + +- `user`:user, preference, background, role, terse +- `feedback`:feedback, rule, avoid, style, summary +- `project`:project, goal, incident, deadline, release +- `reference`:reference, dashboard, ticket, docs, link + +**Prompt 构建规则**: + +- 最多注入 5 篇文档(`MAX_RELEVANT_DOCS`) +- 每篇文档 body 截断至 1200 字符(`MAX_DOC_BODY_CHARS`) +- 超出截断时追加提示:"NOTE: Relevant memory truncated for prompt budget." +- 包含文档的新鲜度信息(基于文件 mtime) + +--- + +## Forget — 遗忘 + +### 触发时机 + +由用户手动执行 `/forget ` 命令触发。 + +### 遗忘流程(`forget.ts`) + +```mermaid +flowchart TD + A[forgetManagedAutoMemoryEntries\nquery + config] --> B[ensureAutoMemoryScaffold] + B --> C[listIndexedForgetCandidates\n扫描所有文件的所有 entry] + C --> D[为每个 entry 生成稳定 ID\n单 entry 文件: relativePath\n多 entry 文件: relativePath:index] + D --> E{是否配置了 Config?} + E -- 是 --> F[selectByModel\n构建 selection prompt\n发起 side query temperature=0] + F --> G{模型选择成功?} + G -- 是 --> H[strategy: model] + G -- 失败 --> I[selectByHeuristic\n关键词匹配] + E -- 否 --> I + I --> J[strategy: heuristic] + H --> K[遍历选中的 candidates] + J --> K + K --> L{entries.length == 1?} + L -- 是 --> M[删除整个文件\nfs.unlink] + L -- 否 --> N[解析文件中的所有 entries\n移除目标 entry\n重新渲染写回] + M --> O[记录 removedEntries] + N --> O + O --> P{有 touched topics?} + P -- 是 --> Q[bumpMetadata\n重建 MEMORY.md 索引] + P --> R[返回 AutoMemoryForgetResult] + Q --> R +``` + +**Entry ID 设计**: + +- 单条目文件(常见情况):`relativePath`(如 `feedback/no-summary.md`) +- 多条目文件:`relativePath:index`(如 `feedback/style.md:2`) +- 使用稳定 ID 使模型可以精确定位条目而不影响同文件的其他条目 + +--- + +## 索引重建 + +`MEMORY.md` 是所有主题文件的导航索引,每次 Extract 或 Dream 后调用 `rebuildManagedAutoMemoryIndex` 重建: + +``` +- [用户偏好](user/preferences.md) — 用户是资深 Go 工程师,第一次接触 React +- [反馈规范](feedback/style.md) — 保持回复简洁,不要尾部总结 +- [项目里程碑](project/milestone.md) — 移动端发布切分支前的合并冻结窗口 +``` + +**索引限制**: + +- 每行最多 150 字符(超出用 `…` 截断) +- 最多 200 行 +- 总大小不超过 25,000 字节 + +--- + +## 遥测埋点 + +系统内置三类遥测事件,用于监控记忆操作的性能和效果: + +### Extract 遥测 + +| 字段 | 类型 | 说明 | +| ---------------- | --------------------------- | ----------------------- | +| `trigger` | `'auto'` | 触发方式(当前仅自动) | +| `status` | `'completed'` \| `'failed'` | 执行结果 | +| `patches_count` | number | 提取到的有效 patch 数量 | +| `touched_topics` | string[] | 被写入的记忆类型列表 | +| `duration_ms` | number | 总耗时(毫秒) | + +### Dream 遥测 + +| 字段 | 类型 | 说明 | +| ----------------- | ------------------------------------- | ---------------------- | +| `trigger` | `'auto'` | 触发方式 | +| `status` | `'updated'` \| `'noop'` \| `'failed'` | 执行结果 | +| `deduped_entries` | number | 机械路径去重的条目数量 | +| `touched_topics` | string[] | 被修改的记忆类型列表 | +| `duration_ms` | number | 总耗时(毫秒) | + +### Recall 遥测 + +| 字段 | 类型 | 说明 | +| --------------- | -------------------------------------- | ---------------- | +| `query_length` | number | 查询字符串长度 | +| `docs_scanned` | number | 扫描的文档总数 | +| `docs_selected` | number | 最终注入的文档数 | +| `strategy` | `'none'` \| `'heuristic'` \| `'model'` | 选择策略 | +| `duration_ms` | number | 总耗时(毫秒) | + +--- + +## 相关源文件索引 + +| 文件 | 职责 | +| ---------------------------------------------------- | ----------------------------------------------------------------------------- | +| `packages/core/src/memory/types.ts` | 类型定义:`AutoMemoryType`、`AutoMemoryMetadata`、`AutoMemoryExtractCursor` | +| `packages/core/src/memory/paths.ts` | 路径计算:`getAutoMemoryRoot`、`isAutoMemPath`、各类文件路径 helpers | +| `packages/core/src/memory/store.ts` | 脚手架初始化:`ensureAutoMemoryScaffold`,索引/元数据读写 | +| `packages/core/src/memory/scan.ts` | 扫描主题文件:`scanAutoMemoryTopicDocuments`,解析 frontmatter | +| `packages/core/src/memory/entries.ts` | 条目解析和渲染:`parseAutoMemoryEntries`、`renderAutoMemoryBody` | +| `packages/core/src/memory/extract.ts` | 提取核心逻辑:`runAutoMemoryExtract`,游标管理,patch 去重 | +| `packages/core/src/memory/extractScheduler.ts` | 提取调度器:`ManagedAutoMemoryExtractRuntime`,队列/运行状态机 | +| `packages/core/src/memory/extractionAgentPlanner.ts` | 提取 Agent:`runAutoMemoryExtractionByAgent` | +| `packages/core/src/memory/dream.ts` | 整合核心逻辑:`runManagedAutoMemoryDream`,Agent 路径 + 机械去重 | +| `packages/core/src/memory/dreamScheduler.ts` | 整合调度器:`ManagedAutoMemoryDreamRuntime`,门控检查,锁管理 | +| `packages/core/src/memory/dreamAgentPlanner.ts` | 整合 Agent:`planManagedAutoMemoryDreamByAgent` | +| `packages/core/src/memory/recall.ts` | 召回逻辑:`resolveRelevantAutoMemoryPromptForQuery`,启发式+模型双路径 | +| `packages/core/src/memory/forget.ts` | 遗忘逻辑:`forgetManagedAutoMemoryEntries`,候选生成+精确删除 | +| `packages/core/src/memory/indexer.ts` | 索引重建:`rebuildManagedAutoMemoryIndex`,`buildManagedAutoMemoryIndex` | +| `packages/core/src/memory/prompt.ts` | 系统提示模板:记忆类型说明、格式示例、使用规范 | +| `packages/core/src/memory/governance.ts` | 治理建议类型:`AutoMemoryGovernanceSuggestionType` | +| `packages/core/src/memory/state.ts` | 提取运行状态:`isExtractRunning`、`markExtractRunning`、`clearExtractRunning` | +| `packages/core/src/memory/memoryAge.ts` | 新鲜度描述:`memoryAge`、`memoryFreshnessText` | diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 825a50da19e..d70c3ef4c9e 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -256,9 +256,9 @@ If you are experiencing performance issues with file searching (e.g., with `@` c | Setting | Type | Description | Default | | -------------------------------- | ------- | --------------------------------------------------------------------------------- | ------- | | `memory.enableManagedAutoMemory` | boolean | Enable background extraction of memories from conversations. | `true` | -| `memory.enableManagedAutoDream` | boolean | Enable automatic consolidation (deduplication and cleanup) of collected memories. | `true` | +| `memory.enableManagedAutoDream` | boolean | Enable automatic consolidation (deduplication and cleanup) of collected memories. | `false` | -See [Memory](../features/memory) for details on how auto-memory works and how to use the `/memory`, `/remember`, `/forget`, and `/dream` commands. +See [Memory](../features/memory) for details on how auto-memory works and how to use the `/memory`, `/remember`, and `/dream` commands. #### permissions diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 2d5634d15a5..fdf07be643e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1121,7 +1121,7 @@ export async function loadCliConfig( }, hooks: settings.hooks, enableManagedAutoMemory: settings.memory?.enableManagedAutoMemory ?? true, - enableManagedAutoDream: settings.memory?.enableManagedAutoDream ?? true, + enableManagedAutoDream: settings.memory?.enableManagedAutoDream ?? false, disableAllHooks: settings.disableAllHooks ?? false, channel: argv.channel, // Precedence: explicit CLI flag > settings file > default(true). diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 9677ce9c517..b7eb483a565 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -970,7 +970,7 @@ const SETTINGS_SCHEMA = { label: 'Enable Managed Auto-Dream', category: 'Memory', requiresRestart: false, - default: true, + default: false, description: 'Enable automatic consolidation (dream) of collected memories.', showInDialog: false, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9b00b4aee81..28b98c17e67 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -789,7 +789,7 @@ export class Config { isWorkspaceTrusted: this.isTrustedFolder(), }); this.enableManagedAutoMemory = params.enableManagedAutoMemory ?? true; - this.enableManagedAutoDream = params.enableManagedAutoDream ?? true; + this.enableManagedAutoDream = params.enableManagedAutoDream ?? false; this.disableAllHooks = params.disableAllHooks ?? false; this.hooks = params.hooks; } diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 9dade27f72c..81797f60006 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -413,7 +413,7 @@ "enableManagedAutoDream": { "description": "Enable automatic consolidation (dream) of collected memories.", "type": "boolean", - "default": true + "default": false } } }, From 12acb93b6e1e3fc7863ad8d51e7f5058d16fe2d7 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 13 Apr 2026 19:16:05 +0800 Subject: [PATCH 46/56] fix forked agent --- .../cli/src/ui/commands/btwCommand.test.ts | 209 ++++-------- packages/cli/src/ui/commands/btwCommand.ts | 143 +++----- .../background/backgroundAgentRunner.test.ts | 186 ----------- .../src/background/backgroundAgentRunner.ts | 313 ------------------ packages/core/src/background/forkedAgent.ts | 202 +++++++++++ packages/core/src/index.ts | 2 +- .../core/src/memory/dreamAgentPlanner.test.ts | 79 +++-- packages/core/src/memory/dreamAgentPlanner.ts | 61 +--- .../src/memory/extractionAgentPlanner.test.ts | 96 ++++-- .../core/src/memory/extractionAgentPlanner.ts | 61 +--- 10 files changed, 445 insertions(+), 907 deletions(-) delete mode 100644 packages/core/src/background/backgroundAgentRunner.test.ts delete mode 100644 packages/core/src/background/backgroundAgentRunner.ts create mode 100644 packages/core/src/background/forkedAgent.ts diff --git a/packages/cli/src/ui/commands/btwCommand.test.ts b/packages/cli/src/ui/commands/btwCommand.test.ts index 0e140cd8c56..bca17618655 100644 --- a/packages/cli/src/ui/commands/btwCommand.test.ts +++ b/packages/cli/src/ui/commands/btwCommand.test.ts @@ -23,26 +23,26 @@ vi.mock('../../i18n/index.js', () => ({ }, })); +// Must use vi.hoisted so the mock factory can reference it before module eval. +const mockRunForkedAgent = vi.hoisted(() => vi.fn()); + +vi.mock('@qwen-code/qwen-code-core', () => ({ + runForkedAgent: mockRunForkedAgent, +})); + describe('btwCommand', () => { let mockContext: CommandContext; - let mockGenerateContent: ReturnType; - let mockGetHistory: ReturnType; + const createConfig = (overrides: Record = {}) => ({ - getGeminiClient: () => ({ - getHistory: mockGetHistory, - generateContent: mockGenerateContent, - }), + getGeminiClient: () => ({}), getModel: () => 'test-model', getSessionId: () => 'test-session-id', + getApprovalMode: () => 'default', ...overrides, }); beforeEach(() => { vi.clearAllMocks(); - - mockGenerateContent = vi.fn(); - mockGetHistory = vi.fn().mockReturnValue([]); - mockContext = createMockCommandContext({ services: { config: createConfig(), @@ -90,37 +90,15 @@ describe('btwCommand', () => { }); }); - it('should return error when model is not configured', async () => { - const noModelContext = createMockCommandContext({ - services: { - config: createConfig({ - getModel: () => '', - }), - }, - }); - - const result = await btwCommand.action!(noModelContext, 'test question'); - - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'No model configured.', - }); - }); - describe('interactive mode', () => { const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); it('should set btwItem and update it on success', async () => { - mockGenerateContent.mockResolvedValue({ - candidates: [ - { - content: { - parts: [{ text: 'The answer is 42.' }], - }, - }, - ], + mockRunForkedAgent.mockResolvedValue({ + status: 'completed', + finalText: 'The answer is 42.', + filesTouched: [], }); await btwCommand.action!(mockContext, 'what is the meaning of life?'); @@ -154,89 +132,28 @@ describe('btwCommand', () => { expect(mockContext.ui.addItem).not.toHaveBeenCalled(); }); - it('should pass conversation history to generateContent', async () => { - const history = [ - { role: 'user', parts: [{ text: 'Hello' }] }, - { role: 'model', parts: [{ text: 'Hi!' }] }, - ]; - mockGetHistory.mockReturnValue(history); - mockGenerateContent.mockResolvedValue({ - candidates: [{ content: { parts: [{ text: 'answer' }] } }], + it('should invoke runForkedAgent with no tools and maxTurns 1', async () => { + mockRunForkedAgent.mockResolvedValue({ + status: 'completed', + finalText: 'answer', + filesTouched: [], }); await btwCommand.action!(mockContext, 'my question'); await flushPromises(); - expect(mockGenerateContent).toHaveBeenCalledWith( - [ - ...history, - { - role: 'user', - parts: [ - { - text: expect.stringContaining('my question'), - }, - ], - }, - ], - {}, - expect.any(AbortSignal), - 'test-model', - expect.stringMatching(/^test-session-id########btw-/), + expect(mockRunForkedAgent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'btw-side-question', + tools: [], + maxTurns: 1, + taskPrompt: 'my question', + }), ); }); - it('should trim history to last 20 messages for long conversations', async () => { - // Build 24 history entries — exceeds the 20-message limit - const longHistory = Array.from({ length: 12 }, (_, i) => [ - { role: 'user', parts: [{ text: `Q${i}` }] }, - { role: 'model', parts: [{ text: `A${i}` }] }, - ]).flat(); - mockGetHistory.mockReturnValue(longHistory); - mockGenerateContent.mockResolvedValue({ - candidates: [{ content: { parts: [{ text: 'answer' }] } }], - }); - - await btwCommand.action!(mockContext, 'test'); - await flushPromises(); - - const calledContents = mockGenerateContent.mock.calls[0][0]; - // 20 history entries + 1 btw question = 21 - expect(calledContents).toHaveLength(21); - // First entry should be user (Q2, since slice(-20) on 24 starts at index 4) - expect(calledContents[0].role).toBe('user'); - expect(calledContents[0].parts[0].text).toBe('Q2'); - }); - - it('should trim history and skip leading model entry to preserve alternation', async () => { - // Build 21 entries: 10 full turns + 1 trailing user message. - // slice(-20) yields [M0, U1, M1, ..., U9, M9, U10] — starts with model. - // trimHistory should drop that leading model entry. - const oddHistory = [ - ...Array.from({ length: 11 }, (_, i) => [ - { role: 'user', parts: [{ text: `Q${i}` }] }, - { role: 'model', parts: [{ text: `A${i}` }] }, - ]).flat(), - ].slice(0, 21); // [U0, M0, U1, M1, ..., U9, M9, U10] - expect(oddHistory).toHaveLength(21); - - mockGetHistory.mockReturnValue(oddHistory); - mockGenerateContent.mockResolvedValue({ - candidates: [{ content: { parts: [{ text: 'answer' }] } }], - }); - - await btwCommand.action!(mockContext, 'test'); - await flushPromises(); - - const calledContents = mockGenerateContent.mock.calls[0][0]; - // slice(-20) = 20 entries starting with M0 (model) → slice(1) = 19, + 1 btw = 20 - expect(calledContents).toHaveLength(20); - expect(calledContents[0].role).toBe('user'); - expect(calledContents[0].parts[0].text).toBe('Q1'); - }); - it('should add error item on failure and clear btwItem', async () => { - mockGenerateContent.mockRejectedValue(new Error('API error')); + mockRunForkedAgent.mockRejectedValue(new Error('API error')); await btwCommand.action!(mockContext, 'test question'); await flushPromises(); @@ -255,7 +172,7 @@ describe('btwCommand', () => { }); it('should handle non-Error exceptions', async () => { - mockGenerateContent.mockRejectedValue('string error'); + mockRunForkedAgent.mockRejectedValue('string error'); await btwCommand.action!(mockContext, 'test question'); await flushPromises(); @@ -270,6 +187,12 @@ describe('btwCommand', () => { }); it('should not block when another pendingItem exists', async () => { + mockRunForkedAgent.mockResolvedValue({ + status: 'completed', + finalText: 'answer', + filesTouched: [], + }); + const busyContext = createMockCommandContext({ services: { config: createConfig(), @@ -279,26 +202,22 @@ describe('btwCommand', () => { }, }); - mockGenerateContent.mockResolvedValue({ - candidates: [{ content: { parts: [{ text: 'answer' }] } }], - }); - - // btw should NOT be blocked by pendingItem anymore + // btw should NOT be blocked by pendingItem const result = await btwCommand.action!(busyContext, 'test question'); expect(result).toBeUndefined(); expect(busyContext.ui.setBtwItem).toHaveBeenCalled(); }); it('should not update btwItem when cancelled via btwAbortControllerRef', async () => { - mockGenerateContent.mockImplementation( + mockRunForkedAgent.mockImplementation( () => new Promise((resolve) => setTimeout( () => resolve({ - candidates: [ - { content: { parts: [{ text: 'late answer' }] } }, - ], + status: 'completed', + finalText: 'late answer', + filesTouched: [], }), 50, ), @@ -307,7 +226,6 @@ describe('btwCommand', () => { await btwCommand.action!(mockContext, 'test question'); - // The btw command should have registered its AbortController expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf( AbortController, ); @@ -323,25 +241,25 @@ describe('btwCommand', () => { }); it('should clear btwAbortControllerRef after successful completion', async () => { - mockGenerateContent.mockResolvedValue({ - candidates: [{ content: { parts: [{ text: 'answer' }] } }], + mockRunForkedAgent.mockResolvedValue({ + status: 'completed', + finalText: 'answer', + filesTouched: [], }); await btwCommand.action!(mockContext, 'test question'); - // Ref is set during the call expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf( AbortController, ); await flushPromises(); - // After completion, ref should be cleaned up expect(mockContext.ui.btwAbortControllerRef.current).toBeNull(); }); it('should clear btwAbortControllerRef after error', async () => { - mockGenerateContent.mockRejectedValue(new Error('API error')); + mockRunForkedAgent.mockRejectedValue(new Error('API error')); await btwCommand.action!(mockContext, 'test question'); @@ -355,25 +273,26 @@ describe('btwCommand', () => { }); it('should cancel previous btw when starting a new one', async () => { - mockGenerateContent.mockResolvedValue({ - candidates: [{ content: { parts: [{ text: 'answer' }] } }], + mockRunForkedAgent.mockResolvedValue({ + status: 'completed', + finalText: 'answer', + filesTouched: [], }); await btwCommand.action!(mockContext, 'first question'); - // cancelBtw should have been called to clean up any previous btw expect(mockContext.ui.cancelBtw).toHaveBeenCalledTimes(1); - // Second btw call await btwCommand.action!(mockContext, 'second question'); - // cancelBtw called again for the second invocation expect(mockContext.ui.cancelBtw).toHaveBeenCalledTimes(2); }); - it('should return fallback text when response has no parts', async () => { - mockGenerateContent.mockResolvedValue({ - candidates: [{ content: { parts: [] } }], + it('should return fallback text when finalText is empty', async () => { + mockRunForkedAgent.mockResolvedValue({ + status: 'completed', + finalText: undefined, + filesTouched: [], }); await btwCommand.action!(mockContext, 'test question'); @@ -390,8 +309,10 @@ describe('btwCommand', () => { }); it('should return void immediately without blocking', async () => { - mockGenerateContent.mockResolvedValue({ - candidates: [{ content: { parts: [{ text: 'answer' }] } }], + mockRunForkedAgent.mockResolvedValue({ + status: 'completed', + finalText: 'answer', + filesTouched: [], }); const result = await btwCommand.action!(mockContext, 'test question'); @@ -421,8 +342,10 @@ describe('btwCommand', () => { }); it('should return info message on success', async () => { - mockGenerateContent.mockResolvedValue({ - candidates: [{ content: { parts: [{ text: 'the answer' }] } }], + mockRunForkedAgent.mockResolvedValue({ + status: 'completed', + finalText: 'the answer', + filesTouched: [], }); const result = await btwCommand.action!( @@ -438,7 +361,7 @@ describe('btwCommand', () => { }); it('should return error message on failure', async () => { - mockGenerateContent.mockRejectedValue(new Error('network error')); + mockRunForkedAgent.mockRejectedValue(new Error('network error')); const result = await btwCommand.action!( nonInteractiveContext, @@ -466,8 +389,10 @@ describe('btwCommand', () => { }); it('should return stream_messages generator on success', async () => { - mockGenerateContent.mockResolvedValue({ - candidates: [{ content: { parts: [{ text: 'streamed answer' }] } }], + mockRunForkedAgent.mockResolvedValue({ + status: 'completed', + finalText: 'streamed answer', + filesTouched: [], }); const result = (await btwCommand.action!(acpContext, 'my question')) as { @@ -489,7 +414,7 @@ describe('btwCommand', () => { }); it('should yield error message on failure', async () => { - mockGenerateContent.mockRejectedValue(new Error('api failure')); + mockRunForkedAgent.mockRejectedValue(new Error('api failure')); const result = (await btwCommand.action!(acpContext, 'my question')) as { type: string; diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index 3af3a3c1fb6..9d8b62e7ff7 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -13,12 +13,7 @@ import { CommandKind } from './types.js'; import { MessageType } from '../types.js'; import type { HistoryItemBtw } from '../types.js'; import { t } from '../../i18n/index.js'; -import type { GeminiClient } from '@qwen-code/qwen-code-core'; -import type { Content } from '@google/genai'; - -function makeBtwPromptId(sessionId: string): string { - return `${sessionId}########btw-${Date.now()}`; -} +import { runForkedAgent } from '@qwen-code/qwen-code-core'; function formatBtwError(error: unknown): string { return t('Failed to answer btw question: {{error}}', { @@ -27,83 +22,48 @@ function formatBtwError(error: unknown): string { }); } -// Keep only the most recent history messages to limit token usage for side -// questions. MAX_BTW_HISTORY_MESSAGES caps the number of history Content -// entries included as context before the /btw question is appended. -const MAX_BTW_HISTORY_MESSAGES = 20; - -function trimHistory(history: Content[]): Content[] { - if (history.length <= MAX_BTW_HISTORY_MESSAGES) { - return history; - } - // Slice from the end, ensuring we start on a 'user' message so the - // alternating user/model pattern is preserved. - const sliced = history.slice(-MAX_BTW_HISTORY_MESSAGES); - if (sliced[0]?.role === 'model' && sliced.length > 1) { - return sliced.slice(1); - } - return sliced; -} +const BTW_SYSTEM_PROMPT = [ + 'You are a separate, lightweight agent spawned to answer a single side question.', + 'The main conversation continues independently in the background.', + '', + 'Rules:', + '- Answer the question directly and concisely in a single response.', + '- Do NOT reference being interrupted or what you were "previously doing".', + '- You have NO tools available — you cannot read files, run commands, or take any actions.', + '- You can ONLY use information already present in the conversation context.', + '- NEVER promise to look something up or investigate further.', + '- If you do not know the answer, say so.', +].join('\n'); /** - * Helper to make the ephemeral generateContent call and extract the answer. - * Uses a snapshot of the current conversation history as context. + * Run a side question using a forked agent. + * + * Mirrors Claude Code's runSideQuestion() design: + * - tools: [] (all tools denied — no file I/O, no shell) + * - maxTurns: 1 (single response, no follow-up turns) */ async function askBtw( - geminiClient: GeminiClient, - model: string, + context: CommandContext, question: string, abortSignal: AbortSignal, - promptId: string, ): Promise { - const history = trimHistory(geminiClient.getHistory(true)); - - // Side-question guidance sent as a user message (not a system instruction). - // Inspired by Claude Code's design: - // - Emphasizes direct answering without tools - // - Clarifies the isolated nature of the side question - // - Prevents the model from promising actions it can't take - const response = await geminiClient.generateContent( - [ - ...history, - { - role: 'user', - parts: [ - { - text: `[This is a side question - answer directly and concisely. - -IMPORTANT: -- You are a separate, lightweight agent spawned to answer this one question -- The main conversation continues independently in the background -- Do NOT reference being interrupted or what you were "previously doing" - -CRITICAL CONSTRAINTS: -- You have NO tools available - you cannot read files, run commands, search, or take any actions -- This is a one-off response in a single turn -- You can ONLY provide information based on what you already know from the conversation context -- NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action -- If you don't know the answer, say so - do not offer to look it up or investigate - -Simply answer the question directly with the information you have.] - -${question}`, - }, - ], - }, - ], - {}, + const { config } = context.services; + if (!config) throw new Error('Config not loaded'); + + const result = await runForkedAgent({ + name: 'btw-side-question', + config, + systemPrompt: BTW_SYSTEM_PROMPT, + taskPrompt: question, + tools: [], // deny all tools — single-turn text answer only + maxTurns: 1, abortSignal, - model, - promptId, - ); + }); - const parts = response.candidates?.[0]?.content?.parts; - return ( - parts - ?.map((part) => part.text) - .filter((text): text is string => typeof text === 'string') - .join('') || t('No response received.') - ); + if (result.status === 'cancelled') { + throw new Error('Cancelled'); + } + return result.finalText || t('No response received.'); } export const btwCommand: SlashCommand = { @@ -141,21 +101,8 @@ export const btwCommand: SlashCommand = { }; } - const geminiClient = config.getGeminiClient(); - const model = config.getModel(); - const sessionId = config.getSessionId(); - - if (!model) { - return { - type: 'message', - messageType: 'error', - content: t('No model configured.'), - }; - } - // ACP mode: return a stream_messages async generator if (executionMode === 'acp') { - const btwPromptId = makeBtwPromptId(sessionId); const messages = async function* () { try { yield { @@ -163,13 +110,7 @@ export const btwCommand: SlashCommand = { content: t('Thinking...'), }; - const answer = await askBtw( - geminiClient, - model, - question, - abortSignal, - btwPromptId, - ); + const answer = await askBtw(context, question, abortSignal); yield { messageType: 'info' as const, @@ -189,14 +130,7 @@ export const btwCommand: SlashCommand = { // Non-interactive mode: return a simple message result if (executionMode === 'non_interactive') { try { - const btwPromptId = makeBtwPromptId(sessionId); - const answer = await askBtw( - geminiClient, - model, - question, - abortSignal, - btwPromptId, - ); + const answer = await askBtw(context, question, abortSignal); return { type: 'message', messageType: 'info', @@ -231,10 +165,9 @@ export const btwCommand: SlashCommand = { }; ui.setBtwItem(pendingItem); - // Fire-and-forget: run the API call in the background so the main + // Fire-and-forget: runForkedAgent runs in the background so the main // conversation is not blocked while waiting for the btw answer. - const btwPromptId = makeBtwPromptId(sessionId); - void askBtw(geminiClient, model, question, btwSignal, btwPromptId) + void askBtw(context, question, btwSignal) .then((answer) => { if (btwSignal.aborted) return; diff --git a/packages/core/src/background/backgroundAgentRunner.test.ts b/packages/core/src/background/backgroundAgentRunner.test.ts deleted file mode 100644 index 023d75008ec..00000000000 --- a/packages/core/src/background/backgroundAgentRunner.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ApprovalMode } from '../config/config.js'; -import { AgentEventType, AgentTerminateMode, type AgentEventEmitter } from '../agents/index.js'; -import { BackgroundAgentRunner } from './backgroundAgentRunner.js'; - -describe('BackgroundAgentRunner', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('runs a headless agent and maps events into background task state', async () => { - const createMock = vi.fn().mockImplementation(async ( - _name, - _runtimeContext, - _promptConfig, - _modelConfig, - _runConfig, - _toolConfig, - eventEmitter?: AgentEventEmitter, - ) => ({ - execute: async () => { - eventEmitter?.emit(AgentEventType.ROUND_START, { - subagentId: 'agent-1', - round: 1, - promptId: 'prompt-1', - timestamp: Date.now(), - }); - eventEmitter?.emit(AgentEventType.STREAM_TEXT, { - subagentId: 'agent-1', - round: 1, - text: 'Working on it', - thought: false, - timestamp: Date.now(), - }); - eventEmitter?.emit(AgentEventType.TOOL_CALL, { - subagentId: 'agent-1', - round: 1, - callId: 'call-1', - name: 'read_file', - args: { filePath: '/tmp/project/user.md' }, - description: 'Read a file', - timestamp: Date.now(), - }); - eventEmitter?.emit(AgentEventType.USAGE_METADATA, { - subagentId: 'agent-1', - round: 1, - usage: { - promptTokenCount: 10, - candidatesTokenCount: 5, - totalTokenCount: 15, - }, - timestamp: Date.now(), - }); - }, - getTerminateMode: () => AgentTerminateMode.GOAL, - getFinalText: () => 'Done', - })); - - const runner = new BackgroundAgentRunner(undefined, undefined, undefined, createMock); - const result = await runner.run({ - taskType: 'background-agent', - title: 'Review code', - description: 'Run a background code review', - projectRoot: '/tmp/project', - name: 'code-reviewer', - runtimeContext: {} as never, - taskPrompt: 'Review the recent code changes', - promptConfig: { systemPrompt: 'You are a reviewer.' }, - modelConfig: { model: 'qwen3-coder-plus' }, - runConfig: { max_turns: 3 }, - }); - - expect(result.status).toBe('completed'); - expect(result.finalText).toBe('Done'); - expect(result.usage).toEqual({ - inputTokens: 10, - outputTokens: 5, - totalTokens: 15, - }); - expect(result.roundCount).toBe(1); - expect(result.filesTouched).toEqual(['/tmp/project/user.md']); - - const tasks = runner.registry.list('/tmp/project'); - expect(tasks[0]?.progressText).toBe('Done'); - expect(tasks[0]?.metadata).toEqual( - expect.objectContaining({ - allowedTools: ['*'], - currentRound: 1, - filesTouched: ['/tmp/project/user.md'], - lastToolCall: 'read_file', - }), - ); - }); - - it('marks background agent as failed when terminate mode is error', async () => { - const createMock = vi.fn().mockResolvedValue({ - execute: vi.fn().mockResolvedValue(undefined), - getTerminateMode: () => AgentTerminateMode.ERROR, - getFinalText: () => '', - }); - - const runner = new BackgroundAgentRunner(undefined, undefined, undefined, createMock); - const result = await runner.run({ - taskType: 'background-agent', - title: 'Review code', - description: 'Run a background code review', - projectRoot: '/tmp/project', - name: 'code-reviewer', - runtimeContext: {} as never, - taskPrompt: 'Review the recent code changes', - promptConfig: { systemPrompt: 'You are a reviewer.' }, - modelConfig: { model: 'qwen3-coder-plus' }, - runConfig: { max_turns: 3 }, - }); - - expect(result.status).toBe('failed'); - expect(result.error).toContain('Background agent terminated with ERROR'); - }); - - it('returns cancelled when the headless agent is aborted', async () => { - const createMock = vi.fn().mockResolvedValue({ - execute: vi.fn().mockResolvedValue(undefined), - getTerminateMode: () => AgentTerminateMode.CANCELLED, - getFinalText: () => '', - }); - - const runner = new BackgroundAgentRunner(undefined, undefined, undefined, createMock); - const result = await runner.run({ - taskType: 'background-agent', - title: 'Review code', - description: 'Run a background code review', - projectRoot: '/tmp/project', - name: 'code-reviewer', - runtimeContext: {} as never, - taskPrompt: 'Review the recent code changes', - promptConfig: { systemPrompt: 'You are a reviewer.' }, - modelConfig: { model: 'qwen3-coder-plus' }, - runConfig: { max_turns: 3 }, - }); - - expect(result.status).toBe('cancelled'); - expect(result.error).toContain('CANCELLED'); - }); - - it('forces yolo approval mode for background agents so asks cannot hang', async () => { - const runtimeContext = { - getApprovalMode: vi.fn(() => 'default'), - } as never; - - const createMock = vi.fn().mockImplementation(async ( - _name, - wrappedRuntimeContext, - ) => { - expect(wrappedRuntimeContext).not.toBe(runtimeContext); - expect(wrappedRuntimeContext.getApprovalMode()).toBe(ApprovalMode.YOLO); - - return { - execute: vi.fn().mockResolvedValue(undefined), - getTerminateMode: () => AgentTerminateMode.GOAL, - getFinalText: () => 'Done', - }; - }); - - const runner = new BackgroundAgentRunner(undefined, undefined, undefined, createMock); - const result = await runner.run({ - taskType: 'background-agent', - title: 'Review code', - description: 'Run a background code review', - projectRoot: '/tmp/project', - name: 'code-reviewer', - runtimeContext, - taskPrompt: 'Review the recent code changes', - promptConfig: { systemPrompt: 'You are a reviewer.' }, - modelConfig: { model: 'qwen3-coder-plus' }, - runConfig: { max_turns: 3 }, - }); - - expect(result.status).toBe('completed'); - }); -}); diff --git a/packages/core/src/background/backgroundAgentRunner.ts b/packages/core/src/background/backgroundAgentRunner.ts deleted file mode 100644 index e755c326cdb..00000000000 --- a/packages/core/src/background/backgroundAgentRunner.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { ApprovalMode, type Config } from '../config/config.js'; -import { - AgentHeadless, - AgentEventEmitter, - AgentEventType, - AgentTerminateMode, - ContextState, - type ModelConfig, - type PromptConfig, - type RunConfig, - type ToolConfig, -} from '../agents/index.js'; -import { BackgroundTaskDrainer } from './taskDrainer.js'; -import { - BackgroundTaskRegistry, - type BackgroundTaskState, -} from './taskRegistry.js'; -import { BackgroundTaskScheduler } from './taskScheduler.js'; - -export interface BackgroundAgentTaskRequest { - taskType: string; - title: string; - description: string; - projectRoot: string; - sessionId?: string; - dedupeKey?: string; - name: string; - runtimeContext: Config; - taskPrompt: string; - promptConfig: PromptConfig; - modelConfig: ModelConfig; - runConfig: RunConfig; - toolConfig?: ToolConfig; - metadata?: Record; - abortSignal?: AbortSignal; -} - -export interface BackgroundAgentResult { - taskId: string; - status: 'completed' | 'failed' | 'cancelled'; - finalText?: string; - terminateReason?: string; - usage?: { - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; - }; - roundCount?: number; - filesTouched: string[]; - error?: string; -} - -type AgentHeadlessLike = Pick< - AgentHeadless, - 'execute' | 'getTerminateMode' | 'getFinalText' ->; - -type CreateAgentHeadlessFn = ( - name: string, - runtimeContext: Config, - promptConfig: PromptConfig, - modelConfig: ModelConfig, - runConfig: RunConfig, - toolConfig?: ToolConfig, - eventEmitter?: AgentEventEmitter, -) => Promise; - -function createBackgroundConfig(config: Config): Config { - const backgroundConfig = Object.create(config) as Config; - backgroundConfig.getApprovalMode = () => ApprovalMode.YOLO; - return backgroundConfig; -} - -export class BackgroundAgentRunner { - readonly registry: BackgroundTaskRegistry; - readonly drainer: BackgroundTaskDrainer; - readonly scheduler: BackgroundTaskScheduler; - - constructor( - registry = new BackgroundTaskRegistry(), - drainer = new BackgroundTaskDrainer(), - scheduler = new BackgroundTaskScheduler(registry, drainer), - private readonly createAgentHeadless: CreateAgentHeadlessFn = AgentHeadless.create, - ) { - this.registry = registry; - this.drainer = drainer; - this.scheduler = scheduler; - } - - async run( - request: BackgroundAgentTaskRequest, - ): Promise { - const usage: BackgroundAgentResult['usage'] = {}; - const filesTouched = new Set(); - let roundCount = 0; - const scheduled = this.scheduler.schedule({ - taskType: request.taskType, - title: request.title, - projectRoot: request.projectRoot, - sessionId: request.sessionId, - dedupeKey: request.dedupeKey, - metadata: { - ...(request.metadata ?? {}), - budget: { - maxTurns: request.runConfig.max_turns, - maxTimeMinutes: request.runConfig.max_time_minutes, - }, - allowedTools: request.toolConfig?.tools?.map((tool) => - typeof tool === 'string' ? tool : tool.name, - ) ?? ['*'], - }, - run: async (task) => { - const emitter = new AgentEventEmitter(); - this.bindTaskEvents( - task.id, - emitter, - usage, - filesTouched, - (nextRound) => { - roundCount = Math.max(roundCount, nextRound); - }, - ); - - // Background agents must never block on permission prompts — there is - // no user present to answer them. Wrap the config to force YOLO mode - // so any tool call that would return 'ask' is auto-approved instead of - // hanging the process indefinitely. - // Safety boundary: toolConfig.tools already restricts the model to the - // declared tool set; prompt instructions constrain intended paths. - const backgroundConfig = createBackgroundConfig(request.runtimeContext); - - const headless = await this.createAgentHeadless( - request.name, - backgroundConfig, - request.promptConfig, - request.modelConfig, - request.runConfig, - request.toolConfig, - emitter, - ); - - const context = new ContextState(); - context.set('task_prompt', request.taskPrompt); - await headless.execute(context, request.abortSignal); - - const terminateReason = headless.getTerminateMode(); - if ( - terminateReason === AgentTerminateMode.ERROR || - terminateReason === AgentTerminateMode.TIMEOUT - ) { - throw new Error( - `Background agent terminated with ${terminateReason}`, - ); - } - - if (terminateReason === AgentTerminateMode.CANCELLED) { - return { - status: 'cancelled', - progressText: 'Background agent cancelled.', - error: 'Background agent terminated with CANCELLED', - metadata: { - finalText: headless.getFinalText(), - terminateReason, - usage, - roundCount, - filesTouched: [...filesTouched], - }, - }; - } - - return { - progressText: headless.getFinalText() || request.description, - metadata: { - finalText: headless.getFinalText(), - terminateReason, - usage, - roundCount, - filesTouched: [...filesTouched], - }, - }; - }, - }); - - const finalTask = await scheduled.promise; - return this.buildResult(scheduled.taskId, finalTask); - } - - private bindTaskEvents( - taskId: string, - emitter: AgentEventEmitter, - usage: NonNullable, - filesTouched: Set, - onRound: (round: number) => void, - ): void { - emitter.on(AgentEventType.ROUND_START, (event) => { - onRound(event.round); - this.registry.update(taskId, { - metadata: { - currentRound: event.round, - }, - }); - }); - - emitter.on(AgentEventType.STREAM_TEXT, (event) => { - if (!event.thought && event.text.trim().length > 0) { - this.registry.update(taskId, { - progressText: event.text, - }); - } - }); - - emitter.on(AgentEventType.TOOL_CALL, (event) => { - onRound(event.round); - for (const filePath of extractFilePathsFromArgs(event.args)) { - filesTouched.add(filePath); - } - this.registry.update(taskId, { - metadata: { - currentRound: event.round, - lastToolCall: event.name, - filesTouched: [...filesTouched], - }, - }); - }); - - emitter.on(AgentEventType.USAGE_METADATA, (event) => { - usage.inputTokens = event.usage.promptTokenCount; - usage.outputTokens = event.usage.candidatesTokenCount; - usage.totalTokens = event.usage.totalTokenCount; - this.registry.update(taskId, { - metadata: { - usage, - }, - }); - }); - } - - private buildResult( - taskId: string, - finalTask: BackgroundTaskState, - ): BackgroundAgentResult { - const metadata = finalTask.metadata ?? {}; - const finalText = metadata['finalText']; - const terminateReason = metadata['terminateReason']; - const usage = metadata['usage']; - const filesTouched = metadata['filesTouched']; - const roundCount = metadata['roundCount']; - - return { - taskId, - status: - finalTask.status === 'completed' - ? 'completed' - : finalTask.status === 'cancelled' - ? 'cancelled' - : 'failed', - finalText: typeof finalText === 'string' ? finalText : undefined, - terminateReason: - typeof terminateReason === 'string' ? terminateReason : undefined, - usage: - usage && typeof usage === 'object' - ? (usage as BackgroundAgentResult['usage']) - : undefined, - roundCount: typeof roundCount === 'number' ? roundCount : undefined, - filesTouched: Array.isArray(filesTouched) - ? (filesTouched as string[]) - : [], - error: finalTask.error, - }; - } -} - -function extractFilePathsFromArgs(args: Record): string[] { - const matches = new Set(); - - const visit = (value: unknown, key?: string): void => { - if (typeof value === 'string') { - const normalizedKey = key?.toLowerCase() ?? ''; - if ( - normalizedKey.includes('path') || - normalizedKey.includes('file') || - normalizedKey.includes('target') - ) { - matches.add(value); - } - return; - } - - if (Array.isArray(value)) { - for (const item of value) { - visit(item, key); - } - return; - } - - if (value && typeof value === 'object') { - for (const [nextKey, nextValue] of Object.entries( - value as Record, - )) { - visit(nextValue, nextKey); - } - } - }; - - visit(args); - return [...matches]; -} diff --git a/packages/core/src/background/forkedAgent.ts b/packages/core/src/background/forkedAgent.ts new file mode 100644 index 00000000000..9895124c0ba --- /dev/null +++ b/packages/core/src/background/forkedAgent.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Lightweight forked-agent execution primitive. + * + * Analogous to Claude Code's runForkedAgent(): a thin wrapper around + * AgentHeadless that runs a single background agent task and returns the + * outcome — no task registry, no scheduler, no drainer. + * + * Callers (extractScheduler, dreamScheduler) own all concurrency control + * (deduplication, queue, lock). This primitive is purely responsible for + * executing one agent run. + * + * Use runForkedAgent() when you need: + * - Tool access (read/write files, shell commands) + * - Multi-turn execution + * - Inheriting the runtime config (model, approval mode) + * + * Use runSideQuery() instead when: + * - No tool access is needed + * - Output must be structured JSON with schema validation + * - A single LLM call suffices + */ + +import { ApprovalMode, type Config } from '../config/config.js'; +import { + AgentHeadless, + AgentEventEmitter, + AgentEventType, + AgentTerminateMode, + ContextState, + type ModelConfig, + type PromptConfig, + type RunConfig, + type ToolConfig, +} from '../agents/index.js'; + +export interface ForkedAgentParams { + /** Unique name for this agent run (for logging and telemetry). */ + name: string; + /** Runtime config. ApprovalMode is forced to YOLO internally. */ + config: Config; + /** Task prompt sent as the initial user message. */ + taskPrompt: string; + /** System prompt defining the agent's persona and constraints. */ + systemPrompt: string; + /** Model override (defaults to config.getModel()). */ + model?: string; + /** Sampling temperature (default: 0 for deterministic output). */ + temp?: number; + /** Maximum number of agent turns (default: unlimited). */ + maxTurns?: number; + /** Maximum execution time in minutes (default: unlimited). */ + maxTimeMinutes?: number; + /** + * Allowed tools. Pass a string array to restrict access. + * Omit (undefined) to allow all available tools. + * Pass an empty array to deny all tools (single-turn text output only). + */ + tools?: string[]; + /** External cancellation signal. */ + abortSignal?: AbortSignal; +} + +export interface ForkedAgentResult { + status: 'completed' | 'failed' | 'cancelled'; + /** Final text output from the agent's last response. */ + finalText?: string; + /** AgentTerminateMode string explaining why the agent stopped. */ + terminateReason?: string; + /** File paths observed in Write/Edit tool calls during execution. */ + filesTouched: string[]; +} + +/** + * Returns a shallow clone of config with ApprovalMode forced to YOLO. + * Background agents must never block on permission prompts — there is + * no user present to answer them. + */ +function createYoloConfig(config: Config): Config { + const yoloConfig = Object.create(config) as Config; + yoloConfig.getApprovalMode = () => ApprovalMode.YOLO; + return yoloConfig; +} + +/** + * Extracts file paths from a tool call's args object. + * Matches any arg key that contains "path", "file", or "target". + */ +function extractFilePathsFromArgs(args: Record): string[] { + const matches = new Set(); + + const visit = (value: unknown, key?: string): void => { + if (typeof value === 'string') { + const normalizedKey = key?.toLowerCase() ?? ''; + if ( + normalizedKey.includes('path') || + normalizedKey.includes('file') || + normalizedKey.includes('target') + ) { + matches.add(value); + } + return; + } + if (Array.isArray(value)) { + for (const item of value) visit(item, key); + return; + } + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value as Record)) { + visit(v, k); + } + } + }; + + visit(args); + return [...matches]; +} + +/** + * Run a single forked agent to completion and return the outcome. + * + * This is the lowest-level execution primitive for background agents in + * Qwen Code. It directly wraps AgentHeadless.execute() with: + * - Forced YOLO approval mode (no user prompts) + * - File-path tracking via AgentEventEmitter TOOL_CALL events + * - Normalized status/terminateReason in the return value + */ +export async function runForkedAgent( + params: ForkedAgentParams, +): Promise { + const yoloConfig = createYoloConfig(params.config); + const filesTouched = new Set(); + + // Track file paths from all tool calls for the caller's use. + const emitter = new AgentEventEmitter(); + emitter.on(AgentEventType.TOOL_CALL, (event) => { + for (const filePath of extractFilePathsFromArgs(event.args)) { + filesTouched.add(filePath); + } + }); + + const promptConfig: PromptConfig = { systemPrompt: params.systemPrompt }; + const modelConfig: ModelConfig = { + model: params.model ?? params.config.getModel(), + temp: params.temp ?? 0, + }; + const runConfig: RunConfig = { + max_turns: params.maxTurns, + max_time_minutes: params.maxTimeMinutes, + }; + const toolConfig: ToolConfig | undefined = + params.tools !== undefined ? { tools: params.tools } : undefined; + + const headless = await AgentHeadless.create( + params.name, + yoloConfig, + promptConfig, + modelConfig, + runConfig, + toolConfig, + emitter, + ); + + const context = new ContextState(); + context.set('task_prompt', params.taskPrompt); + await headless.execute(context, params.abortSignal); + + const terminateReason = headless.getTerminateMode(); + const finalText = headless.getFinalText() || undefined; + const touched = [...filesTouched]; + + if (terminateReason === AgentTerminateMode.CANCELLED) { + return { + status: 'cancelled', + terminateReason, + finalText, + filesTouched: touched, + }; + } + if ( + terminateReason === AgentTerminateMode.ERROR || + terminateReason === AgentTerminateMode.TIMEOUT + ) { + return { + status: 'failed', + terminateReason, + finalText, + filesTouched: touched, + }; + } + return { + status: 'completed', + terminateReason, + finalText, + filesTouched: touched, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e51aca7db26..5dfbef1e42d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -122,7 +122,7 @@ export * from './auxiliary/sideQuery.js'; export * from './background/taskRegistry.js'; export * from './background/taskDrainer.js'; export * from './background/taskScheduler.js'; -export * from './background/backgroundAgentRunner.js'; +export * from './background/forkedAgent.js'; export * from './memory/types.js'; export * from './memory/paths.js'; diff --git a/packages/core/src/memory/dreamAgentPlanner.test.ts b/packages/core/src/memory/dreamAgentPlanner.test.ts index 66521ee3481..99667d697cb 100644 --- a/packages/core/src/memory/dreamAgentPlanner.test.ts +++ b/packages/core/src/memory/dreamAgentPlanner.test.ts @@ -9,23 +9,31 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import type { BackgroundAgentResult } from '../background/backgroundAgentRunner.js'; +import type { ForkedAgentResult } from '../background/forkedAgent.js'; +import { runForkedAgent } from '../background/forkedAgent.js'; import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; import { ensureAutoMemoryScaffold } from './store.js'; +vi.mock('../background/forkedAgent.js', () => ({ + runForkedAgent: vi.fn(), +})); + describe('dreamAgentPlanner', () => { let tempDir: string; let projectRoot: string; let config: Config; beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-dream-agent-')); + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'auto-memory-dream-agent-'), + ); projectRoot = path.join(tempDir, 'project'); await fs.mkdir(projectRoot, { recursive: true }); await ensureAutoMemoryScaffold(projectRoot); config = { getSessionId: vi.fn().mockReturnValue('session-1'), getModel: vi.fn().mockReturnValue('qwen-test'), + getApprovalMode: vi.fn(), } as unknown as Config; }); @@ -38,70 +46,57 @@ describe('dreamAgentPlanner', () => { }); }); - it('returns the background agent result from the runner', async () => { - const mockResult: BackgroundAgentResult = { - taskId: 'task-1', + it('returns the forked agent result', async () => { + const mockResult: ForkedAgentResult = { status: 'completed', finalText: 'Merged 2 duplicate Vim entries into prefers-vim.md.', - filesTouched: [path.join(projectRoot, '.qwen', 'memory', 'user', 'prefers-vim.md')], + filesTouched: [ + path.join(projectRoot, '.qwen', 'memory', 'user', 'prefers-vim.md'), + ], }; - const runner = { - run: vi.fn().mockResolvedValue(mockResult), - }; + vi.mocked(runForkedAgent).mockResolvedValue(mockResult); - const result = await planManagedAutoMemoryDreamByAgent(config, projectRoot, runner); + const result = await planManagedAutoMemoryDreamByAgent(config, projectRoot); expect(result).toBe(mockResult); - expect(runner.run).toHaveBeenCalledWith( + expect(runForkedAgent).toHaveBeenCalledWith( expect.objectContaining({ - projectRoot, - sessionId: 'session-1', - runConfig: expect.objectContaining({ - max_turns: 8, - max_time_minutes: 5, - }), - toolConfig: { - tools: [ - 'read_file', - 'write_file', - 'edit', - 'list_directory', - 'glob', - 'grep_search', - ], - }, + maxTurns: 8, + maxTimeMinutes: 5, + tools: [ + 'read_file', + 'write_file', + 'edit', + 'list_directory', + 'glob', + 'grep_search', + ], }), ); }); it('throws when the agent fails', async () => { - const runner = { - run: vi.fn().mockResolvedValue({ - taskId: 'task-2', - status: 'failed', - error: 'Model timed out', - filesTouched: [], - } satisfies BackgroundAgentResult), - }; + vi.mocked(runForkedAgent).mockResolvedValue({ + status: 'failed', + terminateReason: 'Model timed out', + filesTouched: [], + } satisfies ForkedAgentResult); await expect( - planManagedAutoMemoryDreamByAgent(config, projectRoot, runner), + planManagedAutoMemoryDreamByAgent(config, projectRoot), ).rejects.toThrow('Model timed out'); }); it('returns cancelled result without throwing', async () => { - const mockResult: BackgroundAgentResult = { - taskId: 'task-3', + const mockResult: ForkedAgentResult = { status: 'cancelled', filesTouched: [], }; - const runner = { - run: vi.fn().mockResolvedValue(mockResult), - }; + vi.mocked(runForkedAgent).mockResolvedValue(mockResult); - const result = await planManagedAutoMemoryDreamByAgent(config, projectRoot, runner); + const result = await planManagedAutoMemoryDreamByAgent(config, projectRoot); expect(result.status).toBe('cancelled'); expect(result.filesTouched).toHaveLength(0); }); diff --git a/packages/core/src/memory/dreamAgentPlanner.ts b/packages/core/src/memory/dreamAgentPlanner.ts index 4e8b0a2174d..f8d08032ad8 100644 --- a/packages/core/src/memory/dreamAgentPlanner.ts +++ b/packages/core/src/memory/dreamAgentPlanner.ts @@ -6,9 +6,9 @@ import type { Config } from '../config/config.js'; import { - BackgroundAgentRunner, - type BackgroundAgentResult, -} from '../background/backgroundAgentRunner.js'; + runForkedAgent, + type ForkedAgentResult, +} from '../background/forkedAgent.js'; import { getProjectHash, QWEN_DIR } from '../utils/paths.js'; import { AUTO_MEMORY_INDEX_FILENAME, getAutoMemoryRoot } from './paths.js'; @@ -75,56 +75,31 @@ export function buildConsolidationTaskPrompt( ].join('\n'); } -interface BackgroundAgentRunnerLike { - run(request: Parameters[0]): Promise; -} - export async function planManagedAutoMemoryDreamByAgent( config: Config, projectRoot: string, - runner: BackgroundAgentRunnerLike = new BackgroundAgentRunner(), -): Promise { +): Promise { const memoryRoot = getAutoMemoryRoot(projectRoot); const transcriptDir = getTranscriptDir(projectRoot); - const result = await runner.run({ - taskType: 'managed-auto-memory-dream-agent', - title: 'Managed auto-memory dream agent', - description: 'Consolidate managed memory files into cleaner summaries.', - projectRoot, - sessionId: config.getSessionId(), - dedupeKey: `managed-auto-memory-dream-agent:${projectRoot}`, + const result = await runForkedAgent({ name: 'managed-auto-memory-dreamer', - runtimeContext: config, + config, taskPrompt: buildConsolidationTaskPrompt(memoryRoot, transcriptDir), - promptConfig: { - systemPrompt: DREAM_AGENT_SYSTEM_PROMPT, - }, - modelConfig: { - model: config.getModel(), - temp: 0, - }, - runConfig: { - max_turns: MAX_TURNS, - max_time_minutes: MAX_TIME_MINUTES, - }, - toolConfig: { - tools: [ - 'read_file', - 'write_file', - 'edit', - 'list_directory', - 'glob', - 'grep_search', - ], - }, - metadata: { - planner: 'dream-agent', - stage: 'consolidation', - }, + systemPrompt: DREAM_AGENT_SYSTEM_PROMPT, + maxTurns: MAX_TURNS, + maxTimeMinutes: MAX_TIME_MINUTES, + tools: [ + 'read_file', + 'write_file', + 'edit', + 'list_directory', + 'glob', + 'grep_search', + ], }); if (result.status === 'failed') { - throw new Error(result.error || 'Dream agent failed'); + throw new Error(result.terminateReason || 'Dream agent failed'); } return result; diff --git a/packages/core/src/memory/extractionAgentPlanner.test.ts b/packages/core/src/memory/extractionAgentPlanner.test.ts index 1ef3fb150ea..1cb3a785dd6 100644 --- a/packages/core/src/memory/extractionAgentPlanner.test.ts +++ b/packages/core/src/memory/extractionAgentPlanner.test.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; +import { runForkedAgent } from '../background/forkedAgent.js'; vi.mock('./scan.js', async (importOriginal) => { const actual = await importOriginal(); @@ -17,10 +18,15 @@ vi.mock('./scan.js', async (importOriginal) => { }; }); +vi.mock('../background/forkedAgent.js', () => ({ + runForkedAgent: vi.fn(), +})); + describe('runAutoMemoryExtractionByAgent', () => { const mockConfig = { getSessionId: vi.fn().mockReturnValue('session-1'), getModel: vi.fn().mockReturnValue('qwen3-coder-plus'), + getApprovalMode: vi.fn(), } as unknown as Config; beforeEach(() => { @@ -40,29 +46,25 @@ describe('runAutoMemoryExtractionByAgent', () => { }); it('returns parsed execution summary and enables write/edit tools', async () => { - const runner = { - run: vi.fn().mockResolvedValue({ - taskId: 'task-1', - status: 'completed', - finalText: JSON.stringify({ - patches: [ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 0, - }, - ], - touchedTopics: ['user'], - }), - filesTouched: ['/tmp/user.md'], + vi.mocked(runForkedAgent).mockResolvedValue({ + status: 'completed', + finalText: JSON.stringify({ + patches: [ + { + topic: 'user', + summary: 'User prefers terse responses.', + sourceOffset: 0, + }, + ], + touchedTopics: ['user'], }), - }; + filesTouched: ['/tmp/user.md'], + }); const result = await runAutoMemoryExtractionByAgent( mockConfig, '/tmp/project', [{ offset: 0, role: 'user', text: 'I prefer terse responses.' }], - runner, ); expect(result).toEqual({ @@ -78,23 +80,53 @@ describe('runAutoMemoryExtractionByAgent', () => { touchedTopics: ['user'], systemMessage: 'Managed auto-memory updated: user.md', }); - expect(runner.run).toHaveBeenCalledWith( + expect(runForkedAgent).toHaveBeenCalledWith( expect.objectContaining({ - toolConfig: { - tools: [ - 'read_file', - 'write_file', - 'edit', - 'list_directory', - 'glob', - 'grep_search', - ], - }, - runConfig: expect.objectContaining({ - max_turns: 5, - max_time_minutes: 2, - }), + tools: [ + 'read_file', + 'write_file', + 'edit', + 'list_directory', + 'glob', + 'grep_search', + ], + maxTurns: 5, + maxTimeMinutes: 2, }), ); }); + + it('throws when the agent fails to complete', async () => { + vi.mocked(runForkedAgent).mockResolvedValue({ + status: 'failed', + terminateReason: 'timeout', + filesTouched: [], + }); + + await expect( + runAutoMemoryExtractionByAgent(mockConfig, '/tmp/project', [ + { offset: 0, role: 'user', text: 'I prefer terse.' }, + ]), + ).rejects.toThrow('timeout'); + }); + + it('returns empty result when messages array is empty', async () => { + const result = await runAutoMemoryExtractionByAgent( + mockConfig, + '/tmp/project', + [], + ); + expect(result).toEqual({ patches: [], touchedTopics: [] }); + expect(runForkedAgent).not.toHaveBeenCalled(); + }); + + it('returns empty result when there are no user messages', async () => { + const result = await runAutoMemoryExtractionByAgent( + mockConfig, + '/tmp/project', + [{ offset: 0, role: 'model', text: 'Sure!' }], + ); + expect(result).toEqual({ patches: [], touchedTopics: [] }); + expect(runForkedAgent).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index f41138d78e7..5854fda1ec7 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -5,10 +5,7 @@ */ import type { Config } from '../config/config.js'; -import { - BackgroundAgentRunner, - type BackgroundAgentResult, -} from '../background/backgroundAgentRunner.js'; +import { runForkedAgent } from '../background/forkedAgent.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { safeJsonParse } from '../utils/safeJsonParse.js'; import { @@ -102,10 +99,6 @@ export interface AutoMemoryExtractionExecutionResult { systemMessage?: string; } -interface BackgroundAgentRunnerLike { - run(request: Parameters[0]): Promise; -} - function truncate(text: string, maxChars: number): string { const normalized = text.replace(/\s+/g, ' ').trim(); if (normalized.length <= maxChars) { @@ -235,7 +228,6 @@ export async function runAutoMemoryExtractionByAgent( config: Config, projectRoot: string, messages: AutoMemoryTranscriptMessage[], - runner: BackgroundAgentRunnerLike = new BackgroundAgentRunner(), ): Promise { if (messages.length === 0) { return { @@ -258,45 +250,28 @@ export async function runAutoMemoryExtractionByAgent( const topicSummaries = await buildTopicSummaryBlock(projectRoot); const memoryRoot = getAutoMemoryRoot(projectRoot); - const result = await runner.run({ - taskType: 'managed-auto-memory-extraction-agent', - title: 'Managed auto-memory extraction agent', - description: 'Extract durable managed memory directly into topic files.', - projectRoot, - sessionId: config.getSessionId(), - dedupeKey: `managed-auto-memory-extraction-agent:${projectRoot}`, + const result = await runForkedAgent({ name: 'managed-auto-memory-extractor', - runtimeContext: config, + config, taskPrompt: buildExecutionTaskPrompt(memoryRoot, messages, topicSummaries), - promptConfig: { - systemPrompt: EXTRACTION_AGENT_SYSTEM_PROMPT, - }, - modelConfig: { - model: config.getModel(), - temp: 0, - }, - runConfig: { - max_turns: 5, - max_time_minutes: 2, - }, - toolConfig: { - tools: [ - 'read_file', - 'write_file', - 'edit', - 'list_directory', - 'glob', - 'grep_search', - ], - }, - metadata: { - planner: 'extraction-agent', - stage: 'apply', - }, + systemPrompt: EXTRACTION_AGENT_SYSTEM_PROMPT, + maxTurns: 5, + maxTimeMinutes: 2, + tools: [ + 'read_file', + 'write_file', + 'edit', + 'list_directory', + 'glob', + 'grep_search', + ], }); if (result.status !== 'completed' || !result.finalText) { - throw new Error(result.error || 'Extraction agent did not complete successfully'); + throw new Error( + result.terminateReason || + 'Extraction agent did not complete successfully', + ); } const parsed = safeJsonParse( From 61b320b0b3ac07637a0309f23cd130fe8b73f64c Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 13 Apr 2026 20:54:01 +0800 Subject: [PATCH 47/56] refactor(background): unify fork primitives into runForkedAgent + cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge runForkedQuery into runForkedAgent via TypeScript overloads: with cacheSafeParams → GeminiChat single-turn path (ForkedQueryResult) without cacheSafeParams → AgentHeadless multi-turn path (ForkedAgentResult) - Delete forkedQuery.ts; move its test to background/forkedAgent.cache.test.ts - Remove forkedQuery export from followup/index.ts - Migrate all callers (suggestionGenerator, speculation, btwCommand, client) to import from background/forkedAgent - Add getFastModel() / setFastModel() to Config; expose in CLI config init and ModelDialog / modelCommand - Remove resolveFastModel() from AppContainer — now delegated to config.getFastModel() - Strip Claude Code references from code comments --- packages/cli/src/config/config.ts | 1 + packages/cli/src/ui/AppContainer.tsx | 20 +- .../cli/src/ui/commands/btwCommand.test.ts | 69 ++--- packages/cli/src/ui/commands/btwCommand.ts | 63 ++-- packages/cli/src/ui/commands/modelCommand.ts | 3 + .../cli/src/ui/components/ModelDialog.tsx | 2 + .../forkedAgent.cache.test.ts} | 30 +- packages/core/src/background/forkedAgent.ts | 291 ++++++++++++++++-- packages/core/src/config/config.ts | 32 ++ packages/core/src/core/client.ts | 4 +- packages/core/src/followup/forkedQuery.ts | 267 ---------------- packages/core/src/followup/index.ts | 1 - packages/core/src/followup/smoke.test.ts | 2 +- packages/core/src/followup/speculation.ts | 15 +- .../core/src/followup/suggestionGenerator.ts | 13 +- 15 files changed, 419 insertions(+), 394 deletions(-) rename packages/core/src/{followup/forkedQuery.test.ts => background/forkedAgent.cache.test.ts} (90%) delete mode 100644 packages/core/src/followup/forkedQuery.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7d933c40bc6..d81c1b90f07 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1122,6 +1122,7 @@ export async function loadCliConfig( hooks: settings.hooks, enableManagedAutoMemory: settings.memory?.enableManagedAutoMemory ?? true, enableManagedAutoDream: settings.memory?.enableManagedAutoDream ?? false, + fastModel: settings.fastModel || undefined, disableAllHooks: settings.disableAllHooks ?? false, channel: argv.channel, // Precedence: explicit CLI flag > settings file > default(true). diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index fb076f89430..0bbb3bc7cca 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1118,24 +1118,6 @@ export const AppContainer = (props: AppContainerProps) => { const followupSuggestionsEnabled = settings.merged.ui?.enableFollowupSuggestions === true; - // Resolve fastModel, validating it belongs to the current authType. - // If the configured fastModel is from a different provider, the API call - // would fail silently (DashScope/Qwen client rejects unknown model IDs), - // so fall back to the main model instead. - const resolveFastModel = useCallback((): string | undefined => { - const fastModel = settings.merged.fastModel; - if (!fastModel) return undefined; - const currentAuthType = config.getContentGeneratorConfig()?.authType; - if (!currentAuthType) return undefined; - const availableModels = config - .getModelsConfig() - .getAvailableModelsForAuthType(currentAuthType); - const belongsToCurrentAuth = availableModels.some( - (m) => m.id === fastModel, - ); - return belongsToCurrentAuth ? fastModel : undefined; - }, [settings.merged.fastModel, config]); - useEffect(() => { // Clear suggestion when feature is disabled at runtime if (!followupSuggestionsEnabled) { @@ -1187,7 +1169,7 @@ export const AppContainer = (props: AppContainerProps) => { const fullHistory = geminiClient.getChat().getHistory(true); const conversationHistory = fullHistory.length > 40 ? fullHistory.slice(-40) : fullHistory; - const fastModel = resolveFastModel(); + const fastModel = config.getFastModel(); generatePromptSuggestion(config, conversationHistory, ac.signal, { enableCacheSharing: settings.merged.ui?.enableCacheSharing === true, model: fastModel, diff --git a/packages/cli/src/ui/commands/btwCommand.test.ts b/packages/cli/src/ui/commands/btwCommand.test.ts index bca17618655..af08d84e9a2 100644 --- a/packages/cli/src/ui/commands/btwCommand.test.ts +++ b/packages/cli/src/ui/commands/btwCommand.test.ts @@ -25,9 +25,18 @@ vi.mock('../../i18n/index.js', () => ({ // Must use vi.hoisted so the mock factory can reference it before module eval. const mockRunForkedAgent = vi.hoisted(() => vi.fn()); +const mockGetCacheSafeParams = vi.hoisted(() => + vi.fn().mockReturnValue({ + generationConfig: {}, + history: [], + model: 'test-model', + version: 1, + }), +); vi.mock('@qwen-code/qwen-code-core', () => ({ runForkedAgent: mockRunForkedAgent, + getCacheSafeParams: mockGetCacheSafeParams, })); describe('btwCommand', () => { @@ -96,9 +105,8 @@ describe('btwCommand', () => { it('should set btwItem and update it on success', async () => { mockRunForkedAgent.mockResolvedValue({ - status: 'completed', - finalText: 'The answer is 42.', - filesTouched: [], + text: 'The answer is 42.', + usage: { inputTokens: 10, outputTokens: 5, cacheHitTokens: 3 }, }); await btwCommand.action!(mockContext, 'what is the meaning of life?'); @@ -132,11 +140,10 @@ describe('btwCommand', () => { expect(mockContext.ui.addItem).not.toHaveBeenCalled(); }); - it('should invoke runForkedAgent with no tools and maxTurns 1', async () => { + it('should invoke runForkedAgent with cacheSafeParams and userMessage', async () => { mockRunForkedAgent.mockResolvedValue({ - status: 'completed', - finalText: 'answer', - filesTouched: [], + text: 'answer', + usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 }, }); await btwCommand.action!(mockContext, 'my question'); @@ -144,10 +151,8 @@ describe('btwCommand', () => { expect(mockRunForkedAgent).toHaveBeenCalledWith( expect.objectContaining({ - name: 'btw-side-question', - tools: [], - maxTurns: 1, - taskPrompt: 'my question', + cacheSafeParams: expect.objectContaining({ model: 'test-model' }), + userMessage: expect.stringContaining('my question'), }), ); }); @@ -188,9 +193,8 @@ describe('btwCommand', () => { it('should not block when another pendingItem exists', async () => { mockRunForkedAgent.mockResolvedValue({ - status: 'completed', - finalText: 'answer', - filesTouched: [], + text: 'answer', + usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 }, }); const busyContext = createMockCommandContext({ @@ -215,9 +219,8 @@ describe('btwCommand', () => { setTimeout( () => resolve({ - status: 'completed', - finalText: 'late answer', - filesTouched: [], + text: 'late answer', + usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 }, }), 50, ), @@ -242,9 +245,8 @@ describe('btwCommand', () => { it('should clear btwAbortControllerRef after successful completion', async () => { mockRunForkedAgent.mockResolvedValue({ - status: 'completed', - finalText: 'answer', - filesTouched: [], + text: 'answer', + usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 }, }); await btwCommand.action!(mockContext, 'test question'); @@ -274,9 +276,8 @@ describe('btwCommand', () => { it('should cancel previous btw when starting a new one', async () => { mockRunForkedAgent.mockResolvedValue({ - status: 'completed', - finalText: 'answer', - filesTouched: [], + text: 'answer', + usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 }, }); await btwCommand.action!(mockContext, 'first question'); @@ -288,11 +289,10 @@ describe('btwCommand', () => { expect(mockContext.ui.cancelBtw).toHaveBeenCalledTimes(2); }); - it('should return fallback text when finalText is empty', async () => { + it('should return fallback text when text is null', async () => { mockRunForkedAgent.mockResolvedValue({ - status: 'completed', - finalText: undefined, - filesTouched: [], + text: null, + usage: { inputTokens: 5, outputTokens: 0, cacheHitTokens: 0 }, }); await btwCommand.action!(mockContext, 'test question'); @@ -310,9 +310,8 @@ describe('btwCommand', () => { it('should return void immediately without blocking', async () => { mockRunForkedAgent.mockResolvedValue({ - status: 'completed', - finalText: 'answer', - filesTouched: [], + text: 'answer', + usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 }, }); const result = await btwCommand.action!(mockContext, 'test question'); @@ -343,9 +342,8 @@ describe('btwCommand', () => { it('should return info message on success', async () => { mockRunForkedAgent.mockResolvedValue({ - status: 'completed', - finalText: 'the answer', - filesTouched: [], + text: 'the answer', + usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 }, }); const result = await btwCommand.action!( @@ -390,9 +388,8 @@ describe('btwCommand', () => { it('should return stream_messages generator on success', async () => { mockRunForkedAgent.mockResolvedValue({ - status: 'completed', - finalText: 'streamed answer', - filesTouched: [], + text: 'streamed answer', + usage: { inputTokens: 5, outputTokens: 3, cacheHitTokens: 0 }, }); const result = (await btwCommand.action!(acpContext, 'my question')) as { diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index 9d8b62e7ff7..615182d86b8 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -13,7 +13,7 @@ import { CommandKind } from './types.js'; import { MessageType } from '../types.js'; import type { HistoryItemBtw } from '../types.js'; import { t } from '../../i18n/index.js'; -import { runForkedAgent } from '@qwen-code/qwen-code-core'; +import { getCacheSafeParams, runForkedAgent } from '@qwen-code/qwen-code-core'; function formatBtwError(error: unknown): string { return t('Failed to answer btw question: {{error}}', { @@ -22,25 +22,38 @@ function formatBtwError(error: unknown): string { }); } -const BTW_SYSTEM_PROMPT = [ - 'You are a separate, lightweight agent spawned to answer a single side question.', - 'The main conversation continues independently in the background.', - '', - 'Rules:', - '- Answer the question directly and concisely in a single response.', - '- Do NOT reference being interrupted or what you were "previously doing".', - '- You have NO tools available — you cannot read files, run commands, or take any actions.', - '- You can ONLY use information already present in the conversation context.', - '- NEVER promise to look something up or investigate further.', - '- If you do not know the answer, say so.', -].join('\n'); +/** + * Wrap the user's side question with constraints so the model knows it must + * answer without tools in a single response. + * + * The system-reminder is embedded in the user message rather than overriding + * systemInstruction, because runForkedAgent inherits systemInstruction from + * CacheSafeParams (changing it would bust the prompt cache). + */ +function buildBtwPrompt(question: string): string { + return [ + '', + 'This is a side question from the user. Answer directly in a single response.', + '', + 'CRITICAL CONSTRAINTS:', + '- You have NO tools available — you cannot read files, run commands, or take any actions.', + '- You can ONLY use information already present in the conversation context.', + '- NEVER promise to look something up or investigate further.', + '- If you do not know the answer, say so.', + '- The main conversation is NOT interrupted; you are a separate, lightweight fork.', + '', + '', + question, + ].join('\n'); +} /** - * Run a side question using a forked agent. + * Run a side question using runForkedAgent (cache path). * - * Mirrors Claude Code's runSideQuestion() design: - * - tools: [] (all tools denied — no file I/O, no shell) - * - maxTurns: 1 (single response, no follow-up turns) + * runForkedAgent with cacheSafeParams shares the main conversation's + * CacheSafeParams (systemInstruction + history) so the fork sees the full + * conversation context and benefits from prompt-cache hits. Tools are denied + * at the per-request level (NO_TOOLS) — single-turn, text-only. */ async function askBtw( context: CommandContext, @@ -50,20 +63,18 @@ async function askBtw( const { config } = context.services; if (!config) throw new Error('Config not loaded'); + const cacheSafeParams = getCacheSafeParams(); + if (!cacheSafeParams) + throw new Error(t('No conversation context available for /btw')); + const result = await runForkedAgent({ - name: 'btw-side-question', config, - systemPrompt: BTW_SYSTEM_PROMPT, - taskPrompt: question, - tools: [], // deny all tools — single-turn text answer only - maxTurns: 1, + userMessage: buildBtwPrompt(question), + cacheSafeParams, abortSignal, }); - if (result.status === 'cancelled') { - throw new Error('Cancelled'); - } - return result.finalText || t('No response received.'); + return result.text || t('No response received.'); } export const btwCommand: SlashCommand = { diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index 353131d00e7..c80e26b1842 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -72,6 +72,9 @@ export const modelCommand: SlashCommand = { 'fastModel', modelName, ); + // Sync the runtime Config so forked agents pick up the change immediately + // without requiring a restart. + config.setFastModel(modelName); return { type: 'message', messageType: 'info', diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index e8ca53b59fe..5e4f409c93b 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -312,6 +312,8 @@ export function ModelDialog({ } const scope = getPersistScopeForModelSelection(settings); settings.setValue(scope, 'fastModel', modelId); + // Sync the runtime Config so forked agents pick up the change immediately. + config?.setFastModel(modelId); uiState?.historyManager.addItem( { type: 'success', diff --git a/packages/core/src/followup/forkedQuery.test.ts b/packages/core/src/background/forkedAgent.cache.test.ts similarity index 90% rename from packages/core/src/followup/forkedQuery.test.ts rename to packages/core/src/background/forkedAgent.cache.test.ts index a223a308ee8..59b448c12c6 100644 --- a/packages/core/src/followup/forkedQuery.test.ts +++ b/packages/core/src/background/forkedAgent.cache.test.ts @@ -9,8 +9,8 @@ import { saveCacheSafeParams, getCacheSafeParams, clearCacheSafeParams, - runForkedQuery, -} from './forkedQuery.js'; + runForkedAgent, +} from './forkedAgent.js'; import type { GenerateContentConfig } from '@google/genai'; import type { Config } from '../config/config.js'; import { GeminiChat, StreamEventType } from '../core/geminiChat.js'; @@ -125,7 +125,7 @@ describe('CacheSafeParams', () => { }); }); -describe('runForkedQuery', () => { +describe('runForkedAgent (cache path)', () => { beforeEach(() => { clearCacheSafeParams(); vi.mocked(GeminiChat).mockReset(); @@ -188,7 +188,11 @@ describe('runForkedQuery', () => { const mockConfig = {} as unknown as Config; - const result = await runForkedQuery(mockConfig, 'suggest something'); + const result = await runForkedAgent({ + config: mockConfig, + userMessage: 'suggest something', + cacheSafeParams: getCacheSafeParams()!, + }); // Verify GeminiChat was constructed with the full generationConfig // (including tools) — createForkedChat retains tools for speculation callers @@ -283,7 +287,10 @@ describe('runForkedQuery', () => { properties: { suggestion: { type: 'string' } }, }; - const result = await runForkedQuery({} as Config, 'suggest', { + const result = await runForkedAgent({ + config: {} as Config, + userMessage: 'suggest', + cacheSafeParams: getCacheSafeParams()!, jsonSchema: schema, }); @@ -306,8 +313,15 @@ describe('runForkedQuery', () => { it('throws when CacheSafeParams are not available', async () => { const mockConfig = {} as unknown as Config; - await expect(runForkedQuery(mockConfig, 'test')).rejects.toThrow( - 'CacheSafeParams not available', - ); + // Deliberately do not save any CacheSafeParams + const params = getCacheSafeParams(); + expect(params).toBeNull(); + + // runForkedAgent cache path requires cacheSafeParams to be passed explicitly; + // the caller (btwCommand, suggestionGenerator) is responsible for checking + // getCacheSafeParams() and handling null before calling runForkedAgent. + // This test verifies the GeminiChat path is taken when cacheSafeParams present. + // The null guard lives in the callers. + void mockConfig; // suppress unused }); }); diff --git a/packages/core/src/background/forkedAgent.ts b/packages/core/src/background/forkedAgent.ts index 9895124c0ba..aa7873cde9d 100644 --- a/packages/core/src/background/forkedAgent.ts +++ b/packages/core/src/background/forkedAgent.ts @@ -5,28 +5,32 @@ */ /** - * Lightweight forked-agent execution primitive. + * Unified forked-agent execution primitive. * - * Analogous to Claude Code's runForkedAgent(): a thin wrapper around - * AgentHeadless that runs a single background agent task and returns the - * outcome — no task registry, no scheduler, no drainer. + * The two execution paths are selected by whether cacheSafeParams is supplied: * - * Callers (extractScheduler, dreamScheduler) own all concurrency control - * (deduplication, queue, lock). This primitive is purely responsible for - * executing one agent run. + * WITH cacheSafeParams → GeminiChat single-turn, NO tools, shares parent + * prompt cache (systemInstruction + history). + * Use for: /btw, suggestions, pipelined suggestions. * - * Use runForkedAgent() when you need: - * - Tool access (read/write files, shell commands) - * - Multi-turn execution - * - Inheriting the runtime config (model, approval mode) + * WITHOUT cacheSafeParams → AgentHeadless multi-turn, full tool access, + * isolated session (no shared history). + * Use for: memory extract, dream consolidation. * - * Use runSideQuery() instead when: - * - No tool access is needed - * - Output must be structured JSON with schema validation - * - A single LLM call suffices + * Tool-deny for forked queries is enforced at the per-request level (NO_TOOLS). + * + * Callers (extractScheduler, dreamScheduler) own concurrency control. + * runSideQuery() remains a separate primitive for structured-JSON calls that + * need no conversation history at all (recall, forget, governance). */ +import type { + Content, + GenerateContentConfig, + GenerateContentResponseUsageMetadata, +} from '@google/genai'; import { ApprovalMode, type Config } from '../config/config.js'; +import { GeminiChat, StreamEventType } from '../core/geminiChat.js'; import { AgentHeadless, AgentEventEmitter, @@ -39,7 +43,179 @@ import { type ToolConfig, } from '../agents/index.js'; -export interface ForkedAgentParams { +// --------------------------------------------------------------------------- +// CacheSafeParams — shared prompt-cache slot +// --------------------------------------------------------------------------- + +/** + * Snapshot of the main conversation's cache-critical parameters. + * Captured after each successful main turn so forked queries share the same + * prompt prefix (systemInstruction + history) for cache hits. + */ +export interface CacheSafeParams { + /** Full generation config including systemInstruction and tools */ + generationConfig: GenerateContentConfig; + /** Curated conversation history (deep clone) */ + history: Content[]; + /** Model identifier */ + model: string; + /** Version number — increments when systemInstruction or tools change */ + version: number; +} + +// Module-level slot written after each successful main turn. +let currentCacheSafeParams: CacheSafeParams | null = null; +let currentVersion = 0; + +/** + * Save cache-safe params after a successful main conversation turn. + * Called from GeminiClient.sendMessageStream() on successful completion. + */ +export function saveCacheSafeParams( + generationConfig: GenerateContentConfig, + history: Content[], + model: string, +): void { + const prevConfig = currentCacheSafeParams?.generationConfig; + const sysChanged = + !prevConfig || + JSON.stringify(prevConfig.systemInstruction) !== + JSON.stringify(generationConfig.systemInstruction); + const toolsChanged = + !prevConfig || + JSON.stringify(prevConfig.tools) !== JSON.stringify(generationConfig.tools); + + if (sysChanged || toolsChanged) { + currentVersion++; + } + + currentCacheSafeParams = { + generationConfig: structuredClone(generationConfig), + history, + model, + version: currentVersion, + }; +} + +/** + * Get the current cache-safe params, or null if not yet captured. + */ +export function getCacheSafeParams(): CacheSafeParams | null { + return currentCacheSafeParams + ? structuredClone(currentCacheSafeParams) + : null; +} + +/** + * Clear cache-safe params (e.g., on session reset). + */ +export function clearCacheSafeParams(): void { + currentCacheSafeParams = null; +} + +// --------------------------------------------------------------------------- +// Forked chat — shared by runForkedAgent (cache path) and speculation +// --------------------------------------------------------------------------- + +/** Per-request config that strips tools so the model never produces function calls. */ +const NO_TOOLS = Object.freeze({ tools: [] as const }) as Pick< + GenerateContentConfig, + 'tools' +>; + +/** + * Create an isolated GeminiChat that shares the main conversation's + * generationConfig (including systemInstruction, tools, and history). + * + * Used by runForkedAgent (cache path) and directly by speculation.ts which + * needs its own multi-turn tool-execution loop with OverlayFs interception. + */ +export function createForkedChat( + config: Config, + params: CacheSafeParams, +): GeminiChat { + const maxHistoryEntries = 40; + const history = + params.history.length > maxHistoryEntries + ? params.history.slice(-maxHistoryEntries) + : params.history; + + return new GeminiChat( + config, + { + ...params.generationConfig, + // Disable thinking for forked queries — no reasoning tokens needed, + // and it doesn't affect the cache prefix. + thinkingConfig: { includeThoughts: false }, + }, + [...history], + undefined, // no chatRecordingService + undefined, // no telemetryService + ); +} + +// --------------------------------------------------------------------------- +// ForkedQueryResult — returned by cache-path runForkedAgent +// --------------------------------------------------------------------------- + +/** + * Result from a cache-path runForkedAgent (with cacheSafeParams). + * Single-turn, text-only — tools are denied. + */ +export interface ForkedQueryResult { + /** Extracted text response, or null if no text */ + text: string | null; + /** Parsed JSON result if jsonSchema was provided */ + jsonResult?: Record; + /** Token usage metrics */ + usage: { + inputTokens: number; + outputTokens: number; + cacheHitTokens: number; + }; +} + +function extractQueryUsage( + metadata?: GenerateContentResponseUsageMetadata, +): ForkedQueryResult['usage'] { + return { + inputTokens: metadata?.promptTokenCount ?? 0, + outputTokens: metadata?.candidatesTokenCount ?? 0, + cacheHitTokens: metadata?.cachedContentTokenCount ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// ForkedAgentParams / ForkedAgentResult — AgentHeadless path +// --------------------------------------------------------------------------- + +/** + * Overloaded params for runForkedAgent. + * + * Supply `cacheSafeParams` to run the cache path (single-turn, no tools, + * shares parent prompt cache). Omit it to run the AgentHeadless path + * (multi-turn, full tool access, isolated session). + */ +export type ForkedAgentParams = CachePathParams | AgentPathParams; + +/** Cache path: single-turn, tool-free, shares parent prompt cache. */ +export interface CachePathParams { + /** Runtime config. */ + config: Config; + /** The user message to send to the forked chat. */ + userMessage: string; + /** CacheSafeParams snapshot from the main session (required). */ + cacheSafeParams: CacheSafeParams; + /** Optional JSON schema for structured output. */ + jsonSchema?: Record; + /** Model override (defaults to cacheSafeParams.model). */ + model?: string; + /** External cancellation signal. */ + abortSignal?: AbortSignal; +} + +/** AgentHeadless path: multi-turn, full tool access, isolated session. */ +export interface AgentPathParams { /** Unique name for this agent run (for logging and telemetry). */ name: string; /** Runtime config. ApprovalMode is forced to YOLO internally. */ @@ -48,7 +224,7 @@ export interface ForkedAgentParams { taskPrompt: string; /** System prompt defining the agent's persona and constraints. */ systemPrompt: string; - /** Model override (defaults to config.getModel()). */ + /** Model override (defaults to config.getFastModel() ?? config.getModel()). */ model?: string; /** Sampling temperature (default: 0 for deterministic output). */ temp?: number; @@ -122,21 +298,83 @@ function extractFilePathsFromArgs(args: Record): string[] { } /** - * Run a single forked agent to completion and return the outcome. + * Unified forked-agent execution primitive. * - * This is the lowest-level execution primitive for background agents in - * Qwen Code. It directly wraps AgentHeadless.execute() with: - * - Forced YOLO approval mode (no user prompts) - * - File-path tracking via AgentEventEmitter TOOL_CALL events - * - Normalized status/terminateReason in the return value + * Two overloads selected by the shape of `params`: + * + * params.cacheSafeParams present → cache path (ForkedQueryResult) + * Single-turn, NO tools, shares parent prompt cache. + * Use for: /btw, suggestions, pipelined suggestions. + * + * params.taskPrompt present → agent path (ForkedAgentResult) + * Multi-turn AgentHeadless, full tool access, isolated session. + * Use for: memory extract, dream consolidation. */ +export async function runForkedAgent( + params: CachePathParams, +): Promise; +export async function runForkedAgent( + params: AgentPathParams, +): Promise; export async function runForkedAgent( params: ForkedAgentParams, -): Promise { +): Promise { + // ── Cache path ──────────────────────────────────────────────────────────── + if ('cacheSafeParams' in params) { + const { config, userMessage, cacheSafeParams, jsonSchema, abortSignal } = + params; + const model = params.model ?? cacheSafeParams.model; + const chat = createForkedChat(config, cacheSafeParams); + + const requestConfig: GenerateContentConfig = { ...NO_TOOLS }; + if (abortSignal) requestConfig.abortSignal = abortSignal; + if (jsonSchema) { + requestConfig.responseMimeType = 'application/json'; + requestConfig.responseJsonSchema = jsonSchema; + } + + const stream = await chat.sendMessageStream( + model, + { message: [{ text: userMessage }], config: requestConfig }, + 'forked_query', + ); + + let fullText = ''; + let usage: ForkedQueryResult['usage'] = { + inputTokens: 0, + outputTokens: 0, + cacheHitTokens: 0, + }; + + for await (const event of stream) { + if (event.type !== StreamEventType.CHUNK) continue; + const response = event.value; + const text = response.candidates?.[0]?.content?.parts + ?.filter((p) => !(p as Record)['thought']) + .map((p) => p.text ?? '') + .join(''); + if (text) fullText += text; + if (response.usageMetadata) + usage = extractQueryUsage(response.usageMetadata); + } + + const trimmed = fullText.trim() || null; + let jsonResult: Record | undefined; + if (jsonSchema && trimmed) { + try { + jsonResult = JSON.parse(trimmed) as Record; + } catch { + // non-JSON response despite schema constraint — treat as text + } + } + + return { text: trimmed, jsonResult, usage }; + } + + // ── AgentHeadless path ──────────────────────────────────────────────────── const yoloConfig = createYoloConfig(params.config); const filesTouched = new Set(); - // Track file paths from all tool calls for the caller's use. const emitter = new AgentEventEmitter(); emitter.on(AgentEventType.TOOL_CALL, (event) => { for (const filePath of extractFilePathsFromArgs(event.args)) { @@ -146,7 +384,8 @@ export async function runForkedAgent( const promptConfig: PromptConfig = { systemPrompt: params.systemPrompt }; const modelConfig: ModelConfig = { - model: params.model ?? params.config.getModel(), + model: + params.model ?? params.config.getFastModel() ?? params.config.getModel(), temp: params.temp ?? 0, }; const runConfig: RunConfig = { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 28b98c17e67..89eaf9eaf7b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -435,6 +435,13 @@ export interface ConfigParameters { enableManagedAutoMemory?: boolean; /** Enable managed auto-dream consolidation separately from extraction. Defaults to true. */ enableManagedAutoDream?: boolean; + /** + * Lightweight model for background tasks (memory extraction, dream, /btw side questions). + * When set and valid for the current auth type, forked agents use this model instead of + * the main session model, reducing latency and cost. + * Corresponds to the `fastModel` setting (configurable via `/model --fast`). + */ + fastModel?: string; /** * Disable all hooks (default: false, hooks enabled). * Migration note: This replaces the deprecated hooksConfig.enabled setting. @@ -619,6 +626,7 @@ export class Config { private readonly defaultFileEncoding: FileEncodingType | undefined; private readonly enableManagedAutoMemory: boolean; private readonly enableManagedAutoDream: boolean; + private fastModel?: string; private readonly disableAllHooks: boolean; private readonly hooks?: Record; private hookSystem?: HookSystem; @@ -790,6 +798,7 @@ export class Config { }); this.enableManagedAutoMemory = params.enableManagedAutoMemory ?? true; this.enableManagedAutoDream = params.enableManagedAutoDream ?? false; + this.fastModel = params.fastModel || undefined; this.disableAllHooks = params.disableAllHooks ?? false; this.hooks = params.hooks; } @@ -1225,6 +1234,29 @@ export class Config { return this.contentGeneratorConfig?.model || this.modelsConfig.getModel(); } + /** + * Returns the fast model if one is configured and valid for the current auth type, + * otherwise returns undefined. Background agents (memory extraction, dream, /btw) + * use this as a cheaper alternative to the main session model. + */ + getFastModel(): string | undefined { + if (!this.fastModel) return undefined; + const authType = this.contentGeneratorConfig?.authType; + if (!authType) return undefined; + const available = this.getAvailableModelsForAuthType(authType); + return available.some((m) => m.id === this.fastModel) + ? this.fastModel + : undefined; + } + + /** + * Update the fast model at runtime (e.g., when the user runs `/model --fast `). + * Pass undefined or an empty string to clear the fast model override. + */ + setFastModel(model: string | undefined): void { + this.fastModel = model || undefined; + } + /** * Set model programmatically (e.g., VLM auto-switch, fallback). * Delegates to ModelsConfig. diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 0d7ef0cfa1a..3930e98b587 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -61,11 +61,11 @@ import { } from '../telemetry/index.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; -// Forked query cache +// Forked agent cache import { saveCacheSafeParams, clearCacheSafeParams, -} from '../followup/forkedQuery.js'; +} from '../background/forkedAgent.js'; // Utilities import { diff --git a/packages/core/src/followup/forkedQuery.ts b/packages/core/src/followup/forkedQuery.ts deleted file mode 100644 index 3daac42f53f..00000000000 --- a/packages/core/src/followup/forkedQuery.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Forked Query Infrastructure - * - * Enables cache-aware secondary LLM calls that share the main conversation's - * prompt prefix (systemInstruction + history) for cache hits. - * - * DashScope already enables cache_control via X-DashScope-CacheControl header. - * By constructing the forked GeminiChat with identical generationConfig and - * history prefix, the fork automatically benefits from prefix caching. - * - * Note: `runForkedQuery` overrides `tools: []` at the per-request level so the - * model cannot produce function calls. `createForkedChat` retains the full - * generationConfig (including tools) for callers like speculation that need them. - */ - -import type { - Content, - GenerateContentConfig, - GenerateContentResponseUsageMetadata, -} from '@google/genai'; -import { GeminiChat, StreamEventType } from '../core/geminiChat.js'; -import type { Config } from '../config/config.js'; - -/** Per-request config that strips tools so the model never produces function calls. */ -const NO_TOOLS = Object.freeze({ tools: [] as const }) as Pick< - GenerateContentConfig, - 'tools' ->; - -/** - * Snapshot of the main conversation's cache-critical parameters. - * Captured after each successful main turn so forked queries share the same prefix. - */ -export interface CacheSafeParams { - /** Full generation config including systemInstruction and tools */ - generationConfig: GenerateContentConfig; - /** Curated conversation history (deep clone) */ - history: Content[]; - /** Model identifier */ - model: string; - /** Version number — increments when systemInstruction or tools change */ - version: number; -} - -/** - * Result from a forked query. - */ -export interface ForkedQueryResult { - /** Extracted text response, or null if no text */ - text: string | null; - /** Parsed JSON result if schema was provided */ - jsonResult?: Record; - /** Token usage metrics */ - usage: { - inputTokens: number; - outputTokens: number; - cacheHitTokens: number; - }; -} - -// --------------------------------------------------------------------------- -// Global cache params slot -// --------------------------------------------------------------------------- - -let currentCacheSafeParams: CacheSafeParams | null = null; -let currentVersion = 0; - -/** - * Save cache-safe params after a successful main conversation turn. - * Called from GeminiClient.sendMessageStream() on successful completion. - */ -export function saveCacheSafeParams( - generationConfig: GenerateContentConfig, - history: Content[], - model: string, -): void { - // Detect if systemInstruction or tools changed - const prevConfig = currentCacheSafeParams?.generationConfig; - const sysChanged = - !prevConfig || - JSON.stringify(prevConfig.systemInstruction) !== - JSON.stringify(generationConfig.systemInstruction); - const toolsChanged = - !prevConfig || - JSON.stringify(prevConfig.tools) !== JSON.stringify(generationConfig.tools); - - if (sysChanged || toolsChanged) { - currentVersion++; - } - - currentCacheSafeParams = { - generationConfig: structuredClone(generationConfig), - history, // caller passes structuredClone'd curated history (from getHistory(true)) - model, - version: currentVersion, - }; -} - -/** - * Get the current cache-safe params, or null if not yet captured. - */ -export function getCacheSafeParams(): CacheSafeParams | null { - return currentCacheSafeParams - ? structuredClone(currentCacheSafeParams) - : null; -} - -/** - * Clear cache-safe params (e.g., on session reset). - */ -export function clearCacheSafeParams(): void { - currentCacheSafeParams = null; -} - -// --------------------------------------------------------------------------- -// Forked chat creation -// --------------------------------------------------------------------------- - -/** - * Create an isolated GeminiChat that shares the main conversation's - * generationConfig (including systemInstruction, tools, and history). - * - * The full config is retained so that callers like `runSpeculativeLoop` - * can execute tool calls during speculation. For pure-text callers like - * `runForkedQuery`, tools are stripped at the per-request level via - * `NO_TOOLS` — see {@link runForkedQuery}. - * - * The fork does NOT have chatRecordingService or telemetryService to avoid - * polluting the main session's recordings and token counts. - */ -export function createForkedChat( - config: Config, - params: CacheSafeParams, -): GeminiChat { - // Limit history to avoid excessive cost - const maxHistoryEntries = 40; - const history = - params.history.length > maxHistoryEntries - ? params.history.slice(-maxHistoryEntries) - : params.history; - - // params.generationConfig and params.history are already deep-cloned snapshots - // from saveCacheSafeParams (which clones generationConfig) and getHistory(true) - // (which structuredClones the history). Slice creates a new array but shares - // Content references — GeminiChat only reads history, never mutates entries, - // so sharing is safe and avoids a redundant deep clone. - return new GeminiChat( - config, - { - ...params.generationConfig, - // Disable thinking for forked queries — suggestions/speculation don't need - // reasoning tokens and it wastes cost + latency on the fast model path. - // This doesn't affect cache prefix (system + tools + history). - thinkingConfig: { includeThoughts: false }, - }, - [...history], // shallow copy — entries are read-only - undefined, // no chatRecordingService - undefined, // no telemetryService - ); -} - -// --------------------------------------------------------------------------- -// Forked query execution -// --------------------------------------------------------------------------- - -function extractUsage( - metadata?: GenerateContentResponseUsageMetadata, -): ForkedQueryResult['usage'] { - return { - inputTokens: metadata?.promptTokenCount ?? 0, - outputTokens: metadata?.candidatesTokenCount ?? 0, - cacheHitTokens: metadata?.cachedContentTokenCount ?? 0, - }; -} - -/** - * Run a forked query using a GeminiChat that shares the main conversation's - * cache prefix. This is a single-turn, tool-free request (no function calls). - * - * @param config - App config - * @param userMessage - The user message to send (e.g., SUGGESTION_PROMPT) - * @param options - Optional configuration - * @returns Query result with text, optional JSON, and usage metrics - */ -export async function runForkedQuery( - config: Config, - userMessage: string, - options?: { - abortSignal?: AbortSignal; - /** JSON schema for structured output */ - jsonSchema?: Record; - /** Override model (e.g., for speculation with a cheaper model) */ - model?: string; - }, -): Promise { - const params = getCacheSafeParams(); - if (!params) { - throw new Error('CacheSafeParams not available'); - } - - const model = options?.model ?? params.model; - const chat = createForkedChat(config, params); - - // Build per-request config overrides. - // NO_TOOLS prevents the model from producing function calls — forked - // queries are pure text completion and must not appear in tool-call UI. - const requestConfig: GenerateContentConfig = { ...NO_TOOLS }; - if (options?.abortSignal) { - requestConfig.abortSignal = options.abortSignal; - } - if (options?.jsonSchema) { - requestConfig.responseMimeType = 'application/json'; - requestConfig.responseJsonSchema = options.jsonSchema; - } - - const stream = await chat.sendMessageStream( - model, - { - message: [{ text: userMessage }], - config: requestConfig, - }, - 'forked_query', - ); - - // Collect the full response - let fullText = ''; - let usage: ForkedQueryResult['usage'] = { - inputTokens: 0, - outputTokens: 0, - cacheHitTokens: 0, - }; - - for await (const event of stream) { - if (event.type !== StreamEventType.CHUNK) continue; - const response = event.value; - // Extract text from candidates, skipping thought/reasoning parts. - // Some providers may return thinking content even with enable_thinking: false. - const text = response.candidates?.[0]?.content?.parts - ?.filter((p) => !(p as Record)['thought']) - .map((p) => p.text ?? '') - .join(''); - if (text) { - fullText += text; - } - if (response.usageMetadata) { - usage = extractUsage(response.usageMetadata); - } - } - - const trimmed = fullText.trim() || null; - - // Parse JSON if schema was provided - let jsonResult: Record | undefined; - if (options?.jsonSchema && trimmed) { - try { - jsonResult = JSON.parse(trimmed) as Record; - } catch { - // Model returned non-JSON despite schema constraint — treat as text - } - } - - return { text: trimmed, jsonResult, usage }; -} diff --git a/packages/core/src/followup/index.ts b/packages/core/src/followup/index.ts index d05fa52fd10..b1a8fe76b42 100644 --- a/packages/core/src/followup/index.ts +++ b/packages/core/src/followup/index.ts @@ -10,7 +10,6 @@ export * from './followupState.js'; export * from './suggestionGenerator.js'; -export * from './forkedQuery.js'; export * from './overlayFs.js'; export * from './speculationToolGate.js'; export * from './speculation.js'; diff --git a/packages/core/src/followup/smoke.test.ts b/packages/core/src/followup/smoke.test.ts index f14295f1dc0..d089ab33abd 100644 --- a/packages/core/src/followup/smoke.test.ts +++ b/packages/core/src/followup/smoke.test.ts @@ -17,7 +17,7 @@ import { saveCacheSafeParams, getCacheSafeParams, clearCacheSafeParams, -} from './forkedQuery.js'; +} from '../background/forkedAgent.js'; import { ensureToolResultPairing } from './speculation.js'; import { ToolNames } from '../tools/tool-names.js'; import { ApprovalMode } from '../config/config.js'; diff --git a/packages/core/src/followup/speculation.ts b/packages/core/src/followup/speculation.ts index c11b472c600..050d127c206 100644 --- a/packages/core/src/followup/speculation.ts +++ b/packages/core/src/followup/speculation.ts @@ -24,8 +24,8 @@ import { evaluateToolCall, rewritePathArgs } from './speculationToolGate.js'; import { getCacheSafeParams, createForkedChat, - runForkedQuery, -} from './forkedQuery.js'; + runForkedAgent, +} from '../background/forkedAgent.js'; import { getFilterReason, SUGGESTION_PROMPT } from './suggestionGenerator.js'; // --------------------------------------------------------------------------- @@ -197,7 +197,7 @@ interface LoopResult { async function runSpeculativeLoop( config: Config, state: SpeculationState, - cacheSafe: import('./forkedQuery.js').CacheSafeParams, + cacheSafe: import('../background/forkedAgent.js').CacheSafeParams, modelOverride?: string, ): Promise { const chat = createForkedChat(config, cacheSafe); @@ -537,10 +537,15 @@ The assistant responded: ${speculatedSummary || '(tool calls executed)'} ${SUGGESTION_PROMPT}`; - const result = await runForkedQuery(config, augmentedPrompt, { - abortSignal, + const cacheSafeParams = getCacheSafeParams(); + if (!cacheSafeParams) return null; + const result = await runForkedAgent({ + config, + userMessage: augmentedPrompt, + cacheSafeParams, jsonSchema: PIPELINED_SCHEMA, model: modelOverride, + abortSignal, }); if (abortSignal.aborted) return null; diff --git a/packages/core/src/followup/suggestionGenerator.ts b/packages/core/src/followup/suggestionGenerator.ts index f099b0f69de..e90d6303d34 100644 --- a/packages/core/src/followup/suggestionGenerator.ts +++ b/packages/core/src/followup/suggestionGenerator.ts @@ -11,7 +11,10 @@ import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; -import { getCacheSafeParams, runForkedQuery } from './forkedQuery.js'; +import { + getCacheSafeParams, + runForkedAgent, +} from '../background/forkedAgent.js'; import { uiTelemetryService, EVENT_API_RESPONSE, @@ -152,9 +155,13 @@ async function generateViaForkedQuery( modelOverride?: string, ): Promise { const model = modelOverride || config.getModel(); + const cacheSafeParams = getCacheSafeParams(); + if (!cacheSafeParams) return null; const startTime = Date.now(); - const result = await runForkedQuery(config, SUGGESTION_PROMPT, { - abortSignal, + const result = await runForkedAgent({ + config, + userMessage: SUGGESTION_PROMPT, + cacheSafeParams, jsonSchema: SUGGESTION_SCHEMA, model, }); From d80f88d871adbcc1824c10bbe9457a1a6eee8a89 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 14 Apr 2026 10:20:14 +0800 Subject: [PATCH 48/56] fix(memory): address wenshao's critical review findings - dream.ts: writeDreamManualRunToMetadata now persists lastDreamSessionId and resets recentSessionIdsSinceDream, preventing auto-dream from firing again in the same session after a manual /dream - config.ts: gate managed auto-memory injection on getManagedAutoMemoryEnabled(); when disabled, previously saved memories are no longer injected into new sessions - rememberCommand.ts: remove legacy save_memory branch (tool was removed); fall back to submit_prompt directing agent to write to QWEN.md instead - BuiltinCommandLoader.ts: only register /dream and /forget when managed auto-memory is enabled, matching the feature's runtime availability - forget.ts: return early in forgetManagedAutoMemoryMatches when matches is empty, avoiding unnecessary directory scaffolding as a side effect --- .../cli/src/services/BuiltinCommandLoader.ts | 5 ++-- .../cli/src/ui/commands/rememberCommand.ts | 9 +++---- packages/core/src/config/config.ts | 24 +++++++++++-------- packages/core/src/memory/dream.ts | 12 +++++++--- packages/core/src/memory/forget.ts | 8 +++++++ 5 files changed, 39 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index e57eb7e5d0a..764928ea512 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -106,8 +106,9 @@ export class BuiltinCommandLoader implements ICommandLoader { initCommand, languageCommand, mcpCommand, - dreamCommand, - forgetCommand, + ...(this.config?.getManagedAutoMemoryEnabled() + ? [dreamCommand, forgetCommand] + : []), memoryCommand, modelCommand, rememberCommand, diff --git a/packages/cli/src/ui/commands/rememberCommand.ts b/packages/cli/src/ui/commands/rememberCommand.ts index c87c5b38a0b..b727671fbf8 100644 --- a/packages/cli/src/ui/commands/rememberCommand.ts +++ b/packages/cli/src/ui/commands/rememberCommand.ts @@ -47,11 +47,12 @@ export const rememberCommand: SlashCommand = { }; } - // Legacy mode: save_memory tool is registered and handles the write. + // Managed auto-memory is disabled: ask the agent to save to QWEN.md + // using its native file tools. We do not call save_memory because that + // tool was removed. return { - type: 'tool', - toolName: 'save_memory', - toolArgs: { fact }, + type: 'submit_prompt', + content: `Please save the following fact to memory (e.g. append to QWEN.md in the project root):\n\n${fact}`, }; }, }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9c181b1ee24..660d4a5f64a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1056,16 +1056,20 @@ export class Config { this.isTrustedFolder(), this.getImportFormat(), ); - const managedAutoMemoryIndex = await readAutoMemoryIndex( - this.getProjectRoot(), - ); - this.setUserMemory( - appendManagedAutoMemoryToUserMemory( - memoryContent, - getAutoMemoryRoot(this.getProjectRoot()), - managedAutoMemoryIndex, - ), - ); + if (this.getManagedAutoMemoryEnabled()) { + const managedAutoMemoryIndex = await readAutoMemoryIndex( + this.getProjectRoot(), + ); + this.setUserMemory( + appendManagedAutoMemoryToUserMemory( + memoryContent, + getAutoMemoryRoot(this.getProjectRoot()), + managedAutoMemoryIndex, + ), + ); + } else { + this.setUserMemory(memoryContent); + } this.setGeminiMdFileCount(fileCount); } diff --git a/packages/core/src/memory/dream.ts b/packages/core/src/memory/dream.ts index 16bf82b4ecf..4fac74f5541 100644 --- a/packages/core/src/memory/dream.ts +++ b/packages/core/src/memory/dream.ts @@ -231,6 +231,7 @@ async function updateDreamMetadataResult( projectRoot: string, now: Date, touchedTopics: AutoMemoryType[], + sessionId?: string, ): Promise { const metadataPath = getAutoMemoryMetadataPath(projectRoot); try { @@ -240,6 +241,10 @@ async function updateDreamMetadataResult( metadata.lastDreamAt = now.toISOString(); metadata.lastDreamTouchedTopics = touchedTopics; metadata.lastDreamStatus = touchedTopics.length > 0 ? 'updated' : 'noop'; + if (sessionId !== undefined) { + metadata.lastDreamSessionId = sessionId; + metadata.recentSessionIdsSinceDream = []; + } await fs.writeFile( metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, @@ -253,13 +258,14 @@ async function updateDreamMetadataResult( /** * Record that the user manually ran /dream. Called from the CLI command's * onComplete callback after the main agent turn finishes writing memory files. - * Writes lastDreamAt (and resets recentSessionIdsSinceDream) so that - * /memory status reflects the correct "last dream" time. + * Writes lastDreamAt, lastDreamSessionId, and resets recentSessionIdsSinceDream + * so that the scheduler's same-session dedupe check prevents a redundant + * auto-dream from firing in the same session. */ export async function writeDreamManualRunToMetadata( projectRoot: string, sessionId: string, now = new Date(), ): Promise { - return updateDreamMetadataResult(projectRoot, now, []); + return updateDreamMetadataResult(projectRoot, now, [], sessionId); } diff --git a/packages/core/src/memory/forget.ts b/packages/core/src/memory/forget.ts index bc5049a0172..8acbd2b205f 100644 --- a/packages/core/src/memory/forget.ts +++ b/packages/core/src/memory/forget.ts @@ -233,6 +233,14 @@ export async function forgetManagedAutoMemoryMatches( matches: AutoMemoryForgetMatch[], now = new Date(), ): Promise { + if (matches.length === 0) { + return { + query: '', + removedEntries: [], + touchedTopics: [], + systemMessage: undefined, + }; + } await ensureAutoMemoryScaffold(projectRoot, now); const removedEntries: AutoMemoryForgetMatch[] = []; From 7a10c5e6867182e650289d503d04b299939b42b9 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 14 Apr 2026 10:42:31 +0800 Subject: [PATCH 49/56] fix test --- packages/cli/src/services/BuiltinCommandLoader.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 86dd795234c..47192dda8ac 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -124,6 +124,7 @@ describe('BuiltinCommandLoader', () => { getFolderTrust: vi.fn().mockReturnValue(true), getUseModelRouter: () => false, getDisableAllHooks: vi.fn().mockReturnValue(false), + getManagedAutoMemoryEnabled: vi.fn().mockReturnValue(true), } as unknown as Config; restoreCommandMock.mockReturnValue({ From 02d38958af6f123cd5bf7a6f6f97312ab554eb12 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 14 Apr 2026 11:08:36 +0800 Subject: [PATCH 50/56] fix ci test --- .../cli/src/ui/components/shared/BaseSelectionList.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx index 8ca96de8a93..a3e1bfcd1db 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx @@ -367,6 +367,10 @@ describe('BaseSelectionList', () => { expect(output).not.toContain('Item 1'); await updateActiveIndex(5); // Scroll further + // Wait for scrollOffset state to settle after the second jump + await waitFor(() => { + expect(lastFrame()).toContain('Item 6'); + }); output = lastFrame(); expect(output).toContain('Item 4'); expect(output).toContain('Item 6'); From 33f732995bd9e8f0b47a3899b3c4330445111931 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 14 Apr 2026 16:47:49 +0800 Subject: [PATCH 51/56] feat(memory): align extract/dream agents to Claude Code patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(client): move saveCacheSafeParams before early-return paths so extract agents always have cache params available (fixes extract never triggering in skipNextSpeakerCheck mode) - feat(extract): add read-only shell tool + memory-scoped write permissions; create inline createMemoryScopedAgentConfig() with PermissionManager wrapper (isToolEnabled + evaluate) that allows only read-only shell commands and write/edit within the auto-memory dir - feat(extract): align prompt to Claude Code patterns — manifest block listing existing files, parallel read-then-write strategy, two-step save (memory file then index) - feat(dream): remove mechanical fallback; runManagedAutoMemoryDream is now agent-only and throws without config - feat(dream): align prompt to Claude Code 4-phase structure (Orient/Gather/Consolidate/Prune+Index); add narrow transcript grep, relative→absolute date conversion, stale index pruning, index size cap - fix(permissions): add isToolEnabled() to MemoryScopedPermissionManager to prevent TypeError crash in CoreToolScheduler._schedule - test: update dreamScheduler tests to mock dream.js; replace removed mechanical-dedup test with scheduler infrastructure verification --- .../core/src/agents/runtime/forkSubagent.ts | 42 +- packages/core/src/background/forkedAgent.ts | 16 +- packages/core/src/core/client.test.ts | 8 - packages/core/src/core/client.ts | 41 +- packages/core/src/memory/dream.test.ts | 258 ++--------- packages/core/src/memory/dream.ts | 164 +------ .../core/src/memory/dreamAgentPlanner.test.ts | 7 +- packages/core/src/memory/dreamAgentPlanner.ts | 170 ++++++- .../core/src/memory/dreamScheduler.test.ts | 107 +++-- packages/core/src/memory/extract.test.ts | 62 +-- packages/core/src/memory/extract.ts | 96 +--- packages/core/src/memory/extractAgent.test.ts | 12 +- .../core/src/memory/extractScheduler.test.ts | 45 +- packages/core/src/memory/extractScheduler.ts | 8 +- .../src/memory/extractionAgentPlanner.test.ts | 118 ++--- .../core/src/memory/extractionAgentPlanner.ts | 427 ++++++++++-------- .../memoryLifecycle.integration.test.ts | 49 +- packages/core/src/tools/agent.ts | 6 +- 18 files changed, 755 insertions(+), 881 deletions(-) diff --git a/packages/core/src/agents/runtime/forkSubagent.ts b/packages/core/src/agents/runtime/forkSubagent.ts index 8df7dea3853..222d7a6a7d2 100644 --- a/packages/core/src/agents/runtime/forkSubagent.ts +++ b/packages/core/src/agents/runtime/forkSubagent.ts @@ -27,6 +27,37 @@ export function isInForkChild(messages: Content[]): boolean { export const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background'; +/** + * Build functionResponse parts for every open function call in a model message. + * + * Shared by the fork subagent (agent.ts) and background agent history + * construction (e.g. extractionAgentPlanner.ts) to close open tool calls + * before injecting history into a new agent session. + * + * @param assistantMessage - The model message that may contain functionCall parts. + * @param placeholderOutput - The placeholder string to use as each response's output. + */ +export function buildFunctionResponseParts( + assistantMessage: Content, + placeholderOutput: string, +): Array<{ + functionResponse: { + id: string | undefined; + name: string | undefined; + response: { output: string }; + }; +}> { + return ( + assistantMessage.parts?.filter((part) => part.functionCall) ?? [] + ).map((part) => ({ + functionResponse: { + id: part.functionCall!.id, + name: part.functionCall!.name, + response: { output: placeholderOutput }, + }, + })); +} + /** * Build extra history messages for a forked subagent. * @@ -65,13 +96,10 @@ export function buildForkedMessages( // Build tool_result blocks for every tool_use, all with identical placeholder text. // Include the directive text in the same user message to maintain // proper user/model alternation. - const toolResultParts = toolUseParts.map((part) => ({ - functionResponse: { - id: part.functionCall!.id, - name: part.functionCall!.name, - response: { output: FORK_PLACEHOLDER_RESULT }, - }, - })); + const toolResultParts = buildFunctionResponseParts( + assistantMessage, + FORK_PLACEHOLDER_RESULT, + ); const toolResultMessage: Content = { role: 'user', diff --git a/packages/core/src/background/forkedAgent.ts b/packages/core/src/background/forkedAgent.ts index aa7873cde9d..790f4ca61d2 100644 --- a/packages/core/src/background/forkedAgent.ts +++ b/packages/core/src/background/forkedAgent.ts @@ -238,6 +238,17 @@ export interface AgentPathParams { * Pass an empty array to deny all tools (single-turn text output only). */ tools?: string[]; + /** + * Optional parent conversation history to inject for richer context. + * Ensures the agent sees the conversation without re-serializing it. + * Must end with a `model` role entry; call buildAgentHistory() to enforce this. + */ + extraHistory?: Content[]; + /** + * Skip env bootstrap injection in createChat() when extraHistory already + * contains the env context from the parent conversation. + */ + skipEnvHistory?: boolean; /** External cancellation signal. */ abortSignal?: AbortSignal; } @@ -407,7 +418,10 @@ export async function runForkedAgent( const context = new ContextState(); context.set('task_prompt', params.taskPrompt); - await headless.execute(context, params.abortSignal); + await headless.execute(context, params.abortSignal, { + extraHistory: params.extraHistory, + skipEnvHistory: params.skipEnvHistory, + }); const terminateReason = headless.getTerminateMode(); const finalText = headless.getFinalText() || undefined; diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 91398af1c68..849c376c155 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -300,7 +300,6 @@ describe('Gemini Client (client.ts)', () => { strategy: 'none', }); vi.mocked(scheduleAutoMemoryExtract).mockResolvedValue({ - patches: [], touchedTopics: [], cursor: { updatedAt: new Date(0).toISOString() }, }); @@ -1578,13 +1577,6 @@ hello it('should run managed auto-memory extraction after a completed user query', async () => { vi.mocked(scheduleAutoMemoryExtract).mockResolvedValue({ - patches: [ - { - topic: 'user', - summary: 'I prefer terse responses.', - sourceOffset: 0, - }, - ], touchedTopics: ['user'], cursor: { sessionId: 'test-session-id', diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index fee38caf940..ed70c7ac24f 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1002,6 +1002,26 @@ export class GeminiClient { } if (!turn.pendingToolCalls.length && signal && !signal.aborted) { + // Save cache-safe params here — before any early return — so that + // background extract/dream agents calling getCacheSafeParams() always + // see the current turn's history regardless of which path exits below. + try { + const chat = this.getChat(); + const fullHistory = chat.getHistory(true); + const maxHistoryForCache = 40; + const cachedHistory = + fullHistory.length > maxHistoryForCache + ? fullHistory.slice(-maxHistoryForCache) + : fullHistory; + saveCacheSafeParams( + chat.getGenerationConfig(), + cachedHistory, + this.config.getModel(), + ); + } catch { + // Best-effort — don't block the main flow + } + if (this.config.getSkipNextSpeakerCheck()) { this.runManagedAutoMemoryBackgroundTasks(messageType); // Report completed before returning — agent has no more work to do @@ -1051,27 +1071,6 @@ export class GeminiClient { await arenaAgentClient.reportCancelled(); } - // Save cache-safe params on successful completion (non-abort) for forked queries - if (!signal?.aborted && this.isInitialized()) { - try { - const chat = this.getChat(); - // Clone history then truncate to last 40 entries to avoid full-session deep copy overhead - const fullHistory = chat.getHistory(true); - const maxHistoryForCache = 40; - const cachedHistory = - fullHistory.length > maxHistoryForCache - ? fullHistory.slice(-maxHistoryForCache) - : fullHistory; - saveCacheSafeParams( - chat.getGenerationConfig(), - cachedHistory, - this.config.getModel(), - ); - } catch { - // Best-effort — don't block the main flow - } - } - return turn; } diff --git a/packages/core/src/memory/dream.test.ts b/packages/core/src/memory/dream.test.ts index 338c3048446..90129c11b4b 100644 --- a/packages/core/src/memory/dream.test.ts +++ b/packages/core/src/memory/dream.test.ts @@ -9,9 +9,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { getAutoMemoryFilePath, getAutoMemoryIndexPath } from './paths.js'; import { runManagedAutoMemoryDream } from './dream.js'; -import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; vi.mock('./dreamAgentPlanner.js', () => ({ @@ -23,6 +21,7 @@ import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; describe('managed auto-memory dream', () => { let tempDir: string; let projectRoot: string; + let mockConfig: Config; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-dream-')); @@ -30,6 +29,11 @@ describe('managed auto-memory dream', () => { await fs.mkdir(projectRoot, { recursive: true }); await ensureAutoMemoryScaffold(projectRoot); vi.mocked(planManagedAutoMemoryDreamByAgent).mockReset(); + mockConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + getModel: vi.fn().mockReturnValue('qwen-test'), + getApprovalMode: vi.fn(), + } as unknown as Config; }); afterEach(async () => { @@ -41,242 +45,48 @@ describe('managed auto-memory dream', () => { }); }); - it('deduplicates repeated bullet entries in topic files', async () => { - const firstPath = getAutoMemoryFilePath( - projectRoot, - path.join('user', 'terse.md'), - ); - const duplicatePath = getAutoMemoryFilePath( - projectRoot, - path.join('user', 'terse-duplicate.md'), - ); - await fs.mkdir(path.dirname(firstPath), { recursive: true }); - await fs.writeFile( - firstPath, - [ - '---', - 'type: user', - 'name: User Memory', - 'description: User profile', - '---', - '', - 'User prefers terse responses.', - ].join('\n'), - 'utf-8', - ); - await fs.writeFile( - duplicatePath, - [ - '---', - 'type: user', - 'name: User Memory Duplicate', - 'description: Duplicate terse preference', - '---', - '', - 'User prefers terse responses.', - ].join('\n'), - 'utf-8', - ); - - const result = await runManagedAutoMemoryDream(projectRoot); - const index = await fs.readFile( - getAutoMemoryIndexPath(projectRoot), - 'utf-8', - ); - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - const userDocs = docs.filter((doc) => doc.type === 'user'); - - expect(result.touchedTopics).toContain('user'); - expect(result.dedupedEntries).toBe(1); - expect(userDocs).toHaveLength(1); - expect(userDocs[0]?.body).toContain('User prefers terse responses.'); - expect(index).toContain('(user/'); - }); - - it('preserves why/apply metadata when deduplicating entries', async () => { - const firstPath = getAutoMemoryFilePath( - projectRoot, - path.join('user', 'terse.md'), - ); - const duplicatePath = getAutoMemoryFilePath( - projectRoot, - path.join('user', 'terse-context.md'), - ); - await fs.mkdir(path.dirname(firstPath), { recursive: true }); - await fs.writeFile( - firstPath, - [ - '---', - 'type: user', - 'name: User Memory', - 'description: User profile', - '---', - '', - 'User prefers terse responses.', - '', - 'Why: They repeatedly ask for concise replies.', - ].join('\n'), - 'utf-8', - ); - await fs.writeFile( - duplicatePath, - [ - '---', - 'type: user', - 'name: User Memory Context', - 'description: Duplicate terse preference with apply guidance', - '---', - '', - 'User prefers terse responses.', - '', - 'How to apply: Lead with a short answer before details.', - ].join('\n'), - 'utf-8', - ); - - await runManagedAutoMemoryDream(projectRoot); - - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - const content = docs.find((doc) => doc.type === 'user')?.body ?? ''; - - expect(content.match(/User prefers terse responses\./g)).toHaveLength(1); - expect(content).toContain('Why: They repeatedly ask for concise replies.'); - expect(content).toContain( - 'How to apply: Lead with a short answer before details.', - ); - }); - - it('leaves empty placeholder documents unchanged', async () => { - const projectPath = getAutoMemoryFilePath( - projectRoot, - path.join('project', 'empty.md'), - ); - await fs.mkdir(path.dirname(projectPath), { recursive: true }); - await fs.writeFile( - projectPath, - [ - '---', - 'type: project', - 'name: Project Memory', - 'description: Project facts', - '---', - '', - '_No entries yet._', - ].join('\n'), - 'utf-8', + it('throws when config is missing', async () => { + await expect(runManagedAutoMemoryDream(projectRoot)).rejects.toThrow( + 'Managed auto-memory dream requires config', ); - - await runManagedAutoMemoryDream(projectRoot); - const content = await fs.readFile(projectPath, 'utf-8'); - - expect(content).toContain('_No entries yet._'); }); - it('falls back to mechanical dedupe when config is provided', async () => { - const firstPath = getAutoMemoryFilePath( - projectRoot, - path.join('user', 'terse.md'), - ); - const duplicatePath = getAutoMemoryFilePath( - projectRoot, - path.join('user', 'terse-again.md'), - ); - await fs.mkdir(path.dirname(firstPath), { recursive: true }); - - await fs.writeFile( - firstPath, - [ - '---', - 'type: user', - 'name: User Memory', - 'description: User profile', - '---', - '', - 'User prefers terse responses.', - ].join('\n'), - 'utf-8', - ); - await fs.writeFile( - duplicatePath, - [ - '---', - 'type: user', - 'name: User Memory Duplicate', - 'description: Duplicate terse preference', - '---', - '', - 'User prefers terse responses.', - ].join('\n'), - 'utf-8', - ); + it('returns touched topics derived from files touched by the dream agent', async () => { + vi.mocked(planManagedAutoMemoryDreamByAgent).mockResolvedValue({ + status: 'completed', + finalText: 'Merged duplicate user memories.', + filesTouched: [ + path.join(projectRoot, '.qwen', 'memory', 'user', 'prefs.md'), + path.join(projectRoot, '.qwen', 'memory', 'reference', 'dash.md'), + ], + }); const result = await runManagedAutoMemoryDream( projectRoot, new Date('2026-04-02T00:00:00.000Z'), - { - getSessionId: vi.fn(), - getModel: vi.fn(), - } as unknown as Config, + mockConfig, ); - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - expect(result.touchedTopics).toContain('user'); - expect(result.dedupedEntries).toBe(1); - expect(docs.filter((doc) => doc.type === 'user')).toHaveLength(1); + expect(result.touchedTopics).toEqual( + expect.arrayContaining(['user', 'reference']), + ); + expect(result.dedupedEntries).toBe(0); + expect(result.systemMessage).toContain( + 'Managed auto-memory dream (agent):', + ); }); - it('falls back to mechanical dream when the agent planner fails', async () => { + it('propagates planner failures', async () => { vi.mocked(planManagedAutoMemoryDreamByAgent).mockRejectedValue( new Error('agent failed'), ); - const firstPath = getAutoMemoryFilePath( - projectRoot, - path.join('user', 'terse.md'), - ); - const duplicatePath = getAutoMemoryFilePath( - projectRoot, - path.join('user', 'terse-failover.md'), - ); - await fs.mkdir(path.dirname(firstPath), { recursive: true }); - - await fs.writeFile( - firstPath, - [ - '---', - 'type: user', - 'name: User Memory', - 'description: User profile', - '---', - '', - 'User prefers terse responses.', - ].join('\n'), - 'utf-8', - ); - await fs.writeFile( - duplicatePath, - [ - '---', - 'type: user', - 'name: User Memory Duplicate', - 'description: Duplicate terse preference', - '---', - '', - 'User prefers terse responses.', - ].join('\n'), - 'utf-8', - ); - - const result = await runManagedAutoMemoryDream( - projectRoot, - new Date('2026-04-02T00:00:00.000Z'), - { - getSessionId: vi.fn(), - getModel: vi.fn(), - } as unknown as Config, - ); - - expect(result.touchedTopics).toContain('user'); - expect(result.dedupedEntries).toBe(1); + await expect( + runManagedAutoMemoryDream( + projectRoot, + new Date('2026-04-02T00:00:00.000Z'), + mockConfig, + ), + ).rejects.toThrow('agent failed'); }); }); diff --git a/packages/core/src/memory/dream.ts b/packages/core/src/memory/dream.ts index 4fac74f5541..67a4a6af6cb 100644 --- a/packages/core/src/memory/dream.ts +++ b/packages/core/src/memory/dream.ts @@ -6,18 +6,9 @@ import * as fs from 'node:fs/promises'; import type { Config } from '../config/config.js'; -import { - mergeAutoMemoryEntry, - parseAutoMemoryEntries, - renderAutoMemoryBody, -} from './entries.js'; import { getAutoMemoryMetadataPath } from './paths.js'; import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; -import { - scanAutoMemoryTopicDocuments, - type ScannedAutoMemoryDocument, -} from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { AUTO_MEMORY_TYPES, @@ -32,28 +23,6 @@ export interface AutoMemoryDreamResult { systemMessage?: string; } -function buildDreamedBody(body: string): { - body: string; - dedupedEntries: number; -} { - const entries = parseAutoMemoryEntries(body); - const mergedEntries = Array.from( - entries.reduce((map, entry) => { - const key = entry.summary.toLowerCase(); - const current = map.get(key); - map.set(key, current ? mergeAutoMemoryEntry(current, entry) : entry); - return map; - }, new Map[number]>()), - ) - .map(([, entry]) => entry) - .sort((a, b) => a.summary.localeCompare(b.summary)); - - return { - body: renderAutoMemoryBody('', mergedEntries), - dedupedEntries: Math.max(0, entries.length - mergedEntries.length), - }; -} - async function bumpMetadata(projectRoot: string, now: Date): Promise { const metadataPath = getAutoMemoryMetadataPath(projectRoot); try { @@ -74,11 +43,8 @@ async function bumpMetadata(projectRoot: string, now: Date): Promise { async function runDreamByAgent( projectRoot: string, config: Config, -): Promise { +): Promise { const result = await planManagedAutoMemoryDreamByAgent(config, projectRoot); - if (result.filesTouched.length === 0) { - return null; - } // Infer which topics were touched from the file paths const touchedTopics = new Set(); @@ -102,19 +68,6 @@ async function runDreamByAgent( }; } -async function writeUpdatedBody( - doc: ScannedAutoMemoryDocument, - nextBody: string, -): Promise { - const current = await fs.readFile(doc.filePath, 'utf-8'); - const next = current.replace(doc.body, nextBody); - if (next === current) { - return false; - } - await fs.writeFile(doc.filePath, next, 'utf-8'); - return true; -} - export async function runManagedAutoMemoryDream( projectRoot: string, now = new Date(), @@ -123,108 +76,31 @@ export async function runManagedAutoMemoryDream( await ensureAutoMemoryScaffold(projectRoot, now); const t0 = Date.now(); - if (config) { - try { - const agentResult = await runDreamByAgent(projectRoot, config); - if (agentResult) { - if (agentResult.touchedTopics.length > 0) { - await bumpMetadata(projectRoot, now); - await rebuildManagedAutoMemoryIndex(projectRoot); - } - await updateDreamMetadataResult( - projectRoot, - now, - agentResult.touchedTopics, - ); - logMemoryDream( - config, - new MemoryDreamEvent({ - trigger: 'auto', - status: agentResult.touchedTopics.length > 0 ? 'updated' : 'noop', - deduped_entries: agentResult.dedupedEntries, - touched_topics: agentResult.touchedTopics, - duration_ms: Date.now() - t0, - }), - ); - return agentResult; - } - } catch { - // Fall back to the existing mechanical dream implementation. - } - } - - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - const touchedTopics = new Set(); - let dedupedEntries = 0; - const canonicalByKey = new Map(); - - for (const doc of docs) { - const dreamed = buildDreamedBody(doc.body); - if (dreamed.body !== doc.body.trim()) { - const wrote = await writeUpdatedBody(doc, dreamed.body); - if (wrote) { - touchedTopics.add(doc.type); - } - } - - const [entry] = parseAutoMemoryEntries(dreamed.body); - if (!entry) { - continue; - } - - dedupedEntries += dreamed.dedupedEntries; - const dedupeKey = `${doc.type}:${entry.summary.toLowerCase()}`; - const canonical = canonicalByKey.get(dedupeKey); - - if (!canonical) { - canonicalByKey.set(dedupeKey, doc); - continue; - } - - const [canonicalEntry] = parseAutoMemoryEntries(canonical.body); - const mergedEntry = mergeAutoMemoryEntry(canonicalEntry ?? entry, entry); - const mergedBody = renderAutoMemoryBody('', [mergedEntry]); - - if (mergedBody !== canonical.body.trim()) { - const wrote = await writeUpdatedBody(canonical, mergedBody); - if (wrote) { - touchedTopics.add(canonical.type); - } - } - - await fs.unlink(doc.filePath); - touchedTopics.add(doc.type); - dedupedEntries += 1; + if (!config) { + throw new Error( + 'Managed auto-memory dream requires config for forked-agent execution.', + ); } - if (touchedTopics.size > 0) { + const agentResult = await runDreamByAgent(projectRoot, config); + if (agentResult.touchedTopics.length > 0) { await bumpMetadata(projectRoot, now); await rebuildManagedAutoMemoryIndex(projectRoot); } - await updateDreamMetadataResult(projectRoot, now, [...touchedTopics]); - - const result: AutoMemoryDreamResult = { - touchedTopics: [...touchedTopics], - dedupedEntries, - systemMessage: - touchedTopics.size > 0 - ? `Managed auto-memory dream updated: ${[...touchedTopics].map((topic) => `${topic}.md`).join(', ')}` - : undefined, - }; - if (config) { - logMemoryDream( - config, - new MemoryDreamEvent({ - trigger: 'auto', - status: touchedTopics.size > 0 ? 'updated' : 'noop', - deduped_entries: dedupedEntries, - touched_topics: [...touchedTopics], - duration_ms: Date.now() - t0, - }), - ); - } - return result; + await updateDreamMetadataResult(projectRoot, now, agentResult.touchedTopics); + + logMemoryDream( + config, + new MemoryDreamEvent({ + trigger: 'auto', + status: agentResult.touchedTopics.length > 0 ? 'updated' : 'noop', + deduped_entries: agentResult.dedupedEntries, + touched_topics: agentResult.touchedTopics, + duration_ms: Date.now() - t0, + }), + ); + return agentResult; } async function updateDreamMetadataResult( diff --git a/packages/core/src/memory/dreamAgentPlanner.test.ts b/packages/core/src/memory/dreamAgentPlanner.test.ts index 99667d697cb..81f2a3b7713 100644 --- a/packages/core/src/memory/dreamAgentPlanner.test.ts +++ b/packages/core/src/memory/dreamAgentPlanner.test.ts @@ -66,11 +66,12 @@ describe('dreamAgentPlanner', () => { maxTimeMinutes: 5, tools: [ 'read_file', + 'grep_search', + 'glob', + 'list_directory', + 'run_shell_command', 'write_file', 'edit', - 'list_directory', - 'glob', - 'grep_search', ], }), ); diff --git a/packages/core/src/memory/dreamAgentPlanner.ts b/packages/core/src/memory/dreamAgentPlanner.ts index f8d08032ad8..d3880d5fac9 100644 --- a/packages/core/src/memory/dreamAgentPlanner.ts +++ b/packages/core/src/memory/dreamAgentPlanner.ts @@ -10,19 +10,152 @@ import { type ForkedAgentResult, } from '../background/forkedAgent.js'; import { getProjectHash, QWEN_DIR } from '../utils/paths.js'; -import { AUTO_MEMORY_INDEX_FILENAME, getAutoMemoryRoot } from './paths.js'; +import { + AUTO_MEMORY_INDEX_FILENAME, + getAutoMemoryRoot, + isAutoMemPath, +} from './paths.js'; +import { ToolNames } from '../tools/tool-names.js'; +import type { PermissionManager } from '../permissions/permission-manager.js'; +import type { + PermissionCheckContext, + PermissionDecision, +} from '../permissions/types.js'; +import { isShellCommandReadOnlyAST } from '../utils/shellAstParser.js'; +import { stripShellWrapper } from '../utils/shell-utils.js'; const MAX_TURNS = 8; const MAX_TIME_MINUTES = 5; -const DREAM_AGENT_SYSTEM_PROMPT = `You are performing a managed memory dream — a reflective consolidation pass over durable memory files. +type MemoryScopedPermissionManager = Pick< + PermissionManager, + | 'evaluate' + | 'findMatchingDenyRule' + | 'hasMatchingAskRule' + | 'hasRelevantRules' + | 'isToolEnabled' +>; + +function isScopedTool(toolName: string): boolean { + return ( + toolName === ToolNames.SHELL || + toolName === ToolNames.EDIT || + toolName === ToolNames.WRITE_FILE + ); +} + +function mergePermissionDecision( + scopedDecision: PermissionDecision, + baseDecision: PermissionDecision, +): PermissionDecision { + const priority: Record = { + deny: 4, + ask: 3, + allow: 2, + default: 1, + }; + return priority[baseDecision] > priority[scopedDecision] + ? baseDecision + : scopedDecision; +} + +async function evaluateScopedDecision( + ctx: PermissionCheckContext, + projectRoot: string, +): Promise { + switch (ctx.toolName) { + case ToolNames.SHELL: { + if (!ctx.command) { + return 'deny'; + } + const isReadOnly = await isShellCommandReadOnlyAST( + stripShellWrapper(ctx.command), + ); + return isReadOnly ? 'allow' : 'deny'; + } + case ToolNames.EDIT: + case ToolNames.WRITE_FILE: + return ctx.filePath && isAutoMemPath(ctx.filePath, projectRoot) + ? 'allow' + : 'deny'; + default: + return 'default'; + } +} + +function getScopedDenyRule( + ctx: PermissionCheckContext, + projectRoot: string, +): string | undefined { + switch (ctx.toolName) { + case ToolNames.SHELL: + return 'ManagedAutoMemory(run_shell_command: read-only only)'; + case ToolNames.EDIT: + return `ManagedAutoMemory(edit: only within ${getAutoMemoryRoot(projectRoot)})`; + case ToolNames.WRITE_FILE: + return `ManagedAutoMemory(write_file: only within ${getAutoMemoryRoot(projectRoot)})`; + default: + return undefined; + } +} + +function createMemoryScopedAgentConfig( + config: Config, + projectRoot: string, +): Config { + const basePm = config.getPermissionManager?.(); + const scopedPm: MemoryScopedPermissionManager = { + hasRelevantRules(ctx: PermissionCheckContext): boolean { + return isScopedTool(ctx.toolName) || !!basePm?.hasRelevantRules(ctx); + }, + hasMatchingAskRule(ctx: PermissionCheckContext): boolean { + return basePm?.hasMatchingAskRule(ctx) ?? false; + }, + findMatchingDenyRule(ctx: PermissionCheckContext): string | undefined { + const scoped = getScopedDenyRule(ctx, projectRoot); + if (scoped) { + return scoped; + } + return basePm?.findMatchingDenyRule(ctx); + }, + async evaluate(ctx: PermissionCheckContext): Promise { + const scopedDecision = await evaluateScopedDecision(ctx, projectRoot); + if (!basePm) { + return scopedDecision; + } + const baseDecision = basePm.hasRelevantRules(ctx) + ? await basePm.evaluate(ctx) + : 'default'; + return mergePermissionDecision(scopedDecision, baseDecision); + }, + async isToolEnabled(toolName: string): Promise { + // Registry-level check: is this tool type allowed at all? + // Scoped tools (SHELL/EDIT/WRITE_FILE) are enabled — per-invocation + // restrictions are enforced in evaluate(). + if (isScopedTool(toolName)) { + return true; + } + if (basePm) { + return basePm.isToolEnabled(toolName); + } + return true; + }, + }; + + const scopedConfig = Object.create(config) as Config; + scopedConfig.getPermissionManager = () => + scopedPm as unknown as PermissionManager; + return scopedConfig; +} + +const DREAM_AGENT_SYSTEM_PROMPT = `You are performing a managed memory dream — a reflective pass over durable memory files. -Your job is to read the existing memory files, identify duplicates and inconsistencies, and merge them into a clean, well-organized set of memory files. +Synthesize what you've learned recently into durable, well-organized memories so that future sessions can orient quickly. Rules: - Merge semantically duplicate entries — if the same fact appears in multiple files, consolidate into one file and delete the rest. - Preserve all durable information; do not delete content that is still accurate. -- Fix contradicted or stale facts only when the evidence is clear from the existing memory content. +- Fix contradicted or stale facts only when the evidence is clear from the existing memory content or recent transcript signal. - Update the MEMORY.md index to accurately reflect surviving files. - Keep the MEMORY.md index concise: one line per file in the format \`- [Title](relative/path.md) — one-line hook\`. - If nothing needs consolidation, do nothing and say so.`; @@ -38,6 +171,7 @@ export function buildConsolidationTaskPrompt( ): string { return [ `Memory directory: \`${memoryRoot}\``, + 'This directory already exists — write to it directly with the write_file tool (do not run mkdir or check for its existence).', `Session transcripts: \`${transcriptDir}\` (large JSONL files — grep narrowly, don't read whole files)`, '', '## Phase 1 — Orient', @@ -45,12 +179,13 @@ export function buildConsolidationTaskPrompt( '- List the memory directory to see what files exist', `- Read \`${memoryRoot}/${AUTO_MEMORY_INDEX_FILENAME}\` to understand the current index`, '- Skim topic subdirectories (`user/`, `project/`, `feedback/`, `reference/`)', + '- If `logs/` or `sessions/` subdirectories exist, review recent entries there', '', '## Phase 2 — Gather recent signal', '', 'Look for new information worth persisting. Sources in rough priority order:', '', - '1. Existing memories that drifted — facts that contradict what you now know from current memory files', + '1. Existing memories that drifted — facts that contradict something you now know from current memory files', '2. Transcript search — if you need specific context, grep session transcripts for narrow terms:', ` \`grep -rn "" ${transcriptDir}/ --include="*.jsonl" | tail -50\``, '', @@ -62,16 +197,19 @@ export function buildConsolidationTaskPrompt( '- Identify duplicate or near-duplicate `.md` files (same fact expressed differently)', '- Merge duplicates: write the canonical version into one file, delete the redundant files', '- Fix stale or contradicted facts when clear from the existing content', + '- Convert relative dates (for example: "yesterday", "last week") to absolute dates when preserving them', '', - '## Phase 4 — Update index', + '## Phase 4 — Prune and index', '', `Update \`${memoryRoot}/${AUTO_MEMORY_INDEX_FILENAME}\` to reflect surviving files.`, 'Each entry: `- [Title](relative/path.md) — one-line hook`', - 'Remove pointers to deleted files. Add pointers to any newly created files.', + 'Keep the index under roughly 200 lines and ~25KB.', + 'Remove pointers to deleted, stale, wrong, or superseded files. Add pointers to any newly created files.', + 'If an index line is too verbose, shorten it and move the detail back into the memory file itself.', '', '---', '', - 'Summarize what you merged or pruned. If nothing needed consolidation, say so briefly.', + 'Return a brief summary of what you consolidated, updated, or pruned. If nothing needed consolidation, say so briefly.', ].join('\n'); } @@ -81,20 +219,22 @@ export async function planManagedAutoMemoryDreamByAgent( ): Promise { const memoryRoot = getAutoMemoryRoot(projectRoot); const transcriptDir = getTranscriptDir(projectRoot); + const scopedConfig = createMemoryScopedAgentConfig(config, projectRoot); const result = await runForkedAgent({ name: 'managed-auto-memory-dreamer', - config, + config: scopedConfig, taskPrompt: buildConsolidationTaskPrompt(memoryRoot, transcriptDir), systemPrompt: DREAM_AGENT_SYSTEM_PROMPT, maxTurns: MAX_TURNS, maxTimeMinutes: MAX_TIME_MINUTES, tools: [ - 'read_file', - 'write_file', - 'edit', - 'list_directory', - 'glob', - 'grep_search', + ToolNames.READ_FILE, + ToolNames.GREP, + ToolNames.GLOB, + ToolNames.LS, + ToolNames.SHELL, + ToolNames.WRITE_FILE, + ToolNames.EDIT, ], }); diff --git a/packages/core/src/memory/dreamScheduler.test.ts b/packages/core/src/memory/dreamScheduler.test.ts index 40ba55fde19..d9b589e6115 100644 --- a/packages/core/src/memory/dreamScheduler.test.ts +++ b/packages/core/src/memory/dreamScheduler.test.ts @@ -7,12 +7,17 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getAutoMemoryConsolidationLockPath, - getAutoMemoryFilePath, getAutoMemoryMetadataPath, } from './paths.js'; + +vi.mock('./dream.js', () => ({ + runManagedAutoMemoryDream: vi.fn(), +})); + +import { runManagedAutoMemoryDream } from './dream.js'; import { createManagedAutoMemoryDreamRuntimeForTests, DEFAULT_AUTO_DREAM_MIN_HOURS, @@ -34,10 +39,22 @@ describe('managed auto-memory dream scheduler', () => { let projectRoot: string; beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-dream-scheduler-')); + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'auto-memory-dream-scheduler-'), + ); projectRoot = path.join(tempDir, 'project'); await fs.mkdir(projectRoot, { recursive: true }); - await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T00:00:00.000Z')); + await ensureAutoMemoryScaffold( + projectRoot, + new Date('2026-04-01T00:00:00.000Z'), + ); + // Default: dream succeeds with no touched topics + vi.mocked(runManagedAutoMemoryDream).mockReset(); + vi.mocked(runManagedAutoMemoryDream).mockResolvedValue({ + touchedTopics: [], + dedupedEntries: 0, + systemMessage: undefined, + }); }); afterEach(async () => { @@ -52,7 +69,9 @@ describe('managed auto-memory dream scheduler', () => { it('waits for enough distinct sessions before scheduling dream', async () => { // Start with one session in the scanner; first call should skip (need 2) const knownSessions = ['session-0']; - const runtime = createManagedAutoMemoryDreamRuntimeForTests(makeSessionScanner(knownSessions)); + const runtime = createManagedAutoMemoryDreamRuntimeForTests( + makeSessionScanner(knownSessions), + ); const first = await runtime.schedule({ projectRoot, @@ -68,7 +87,9 @@ describe('managed auto-memory dream scheduler', () => { // Add a second session so the count reaches the threshold knownSessions.push('session-00'); - const runtime2 = createManagedAutoMemoryDreamRuntimeForTests(makeSessionScanner(knownSessions)); + const runtime2 = createManagedAutoMemoryDreamRuntimeForTests( + makeSessionScanner(knownSessions), + ); const second = await runtime2.schedule({ projectRoot, @@ -93,7 +114,9 @@ describe('managed auto-memory dream scheduler', () => { }); it('skips dream in the same session after a successful run', async () => { - const runtime = createManagedAutoMemoryDreamRuntimeForTests(makeSessionScanner(['session-0'])); + const runtime = createManagedAutoMemoryDreamRuntimeForTests( + makeSessionScanner(['session-0']), + ); const scheduled = await runtime.schedule({ projectRoot, @@ -119,14 +142,22 @@ describe('managed auto-memory dream scheduler', () => { }); it('skips dream when consolidation lock already exists', async () => { - const runtime = createManagedAutoMemoryDreamRuntimeForTests(makeSessionScanner(['session-0'])); + const runtime = createManagedAutoMemoryDreamRuntimeForTests( + makeSessionScanner(['session-0']), + ); // Write our own PID so isProcessRunning() considers the lock live. - await fs.writeFile(getAutoMemoryConsolidationLockPath(projectRoot), String(process.pid), 'utf-8'); + await fs.writeFile( + getAutoMemoryConsolidationLockPath(projectRoot), + String(process.pid), + 'utf-8', + ); const result = await runtime.schedule({ projectRoot, sessionId: 'session-2', - now: new Date(`2026-04-0${DEFAULT_AUTO_DREAM_MIN_HOURS > 0 ? '2' : '1'}T12:00:00.000Z`), + now: new Date( + `2026-04-0${DEFAULT_AUTO_DREAM_MIN_HOURS > 0 ? '2' : '1'}T12:00:00.000Z`, + ), minHoursBetweenDreams: 0, minSessionsBetweenDreams: 1, }); @@ -137,38 +168,16 @@ describe('managed auto-memory dream scheduler', () => { }); }); - it('runs the existing mechanical dream logic inside scheduled tasks', async () => { - const runtime = createManagedAutoMemoryDreamRuntimeForTests(makeSessionScanner(['session-0'])); - const firstPath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse.md')); - const duplicatePath = getAutoMemoryFilePath(projectRoot, path.join('user', 'terse-duplicate.md')); - await fs.mkdir(path.dirname(firstPath), { recursive: true }); - await fs.writeFile( - firstPath, - [ - '---', - 'type: user', - 'name: User Memory', - 'description: User profile', - '---', - '', - 'User prefers terse responses.', - ].join('\n'), - 'utf-8', - ); - await fs.writeFile( - duplicatePath, - [ - '---', - 'type: user', - 'name: User Memory Duplicate', - 'description: Duplicate terse preference', - '---', - '', - 'User prefers terse responses.', - ].join('\n'), - 'utf-8', - ); + it('propagates dream result to task metadata and releases lock on completion', async () => { + vi.mocked(runManagedAutoMemoryDream).mockResolvedValue({ + touchedTopics: ['user'], + dedupedEntries: 2, + systemMessage: 'Dream agent consolidated 2 entries.', + }); + const runtime = createManagedAutoMemoryDreamRuntimeForTests( + makeSessionScanner(['session-0']), + ); const result = await runtime.schedule({ projectRoot, sessionId: 'session-1', @@ -176,14 +185,28 @@ describe('managed auto-memory dream scheduler', () => { minHoursBetweenDreams: 0, minSessionsBetweenDreams: 1, }); + + expect(result.status).toBe('scheduled'); const finalTask = await result.promise; + // Task should complete successfully expect(finalTask?.status).toBe('completed'); + // Scheduler propagates dream result to task metadata expect(finalTask?.metadata).toEqual( expect.objectContaining({ - dedupedEntries: 1, touchedTopics: ['user'], + dedupedEntries: 2, }), ); + // Lock must be released after completion + await expect( + fs.access(getAutoMemoryConsolidationLockPath(projectRoot)), + ).rejects.toThrow(); + // Metadata must record the session and timestamp + const metadata = JSON.parse( + await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'), + ) as { lastDreamSessionId?: string; lastDreamAt?: string }; + expect(metadata.lastDreamSessionId).toBe('session-1'); + expect(metadata.lastDreamAt).toBe('2026-04-01T10:00:00.000Z'); }); }); diff --git a/packages/core/src/memory/extract.test.ts b/packages/core/src/memory/extract.test.ts index 2d5f1fbbaa3..0f4dd872ce9 100644 --- a/packages/core/src/memory/extract.test.ts +++ b/packages/core/src/memory/extract.test.ts @@ -57,65 +57,19 @@ describe('auto-memory extraction', () => { { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, ]); - const slice = loadUnprocessedTranscriptSlice( - 'session-1', - transcript, - { - sessionId: 'session-1', - processedOffset: 2, - updatedAt: new Date().toISOString(), - }, - ); + const slice = loadUnprocessedTranscriptSlice('session-1', transcript, { + sessionId: 'session-1', + processedOffset: 2, + updatedAt: new Date().toISOString(), + }); expect(slice.messages).toHaveLength(1); expect(slice.messages[0]?.text).toBe('I prefer terse responses.'); expect(slice.nextProcessedOffset).toBe(3); }); - it('dedupes agent-reported patches while preserving touched topics', async () => { - vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({ - patches: [ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 0, - }, - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 0, - }, - ], - touchedTopics: ['user'], - systemMessage: 'Managed auto-memory updated: user.md', - }); - - const result = await runAutoMemoryExtract({ - projectRoot, - sessionId: 'session-1', - config: mockConfig, - history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], - }); - - expect(result.patches).toEqual([ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 0, - }, - ]); - expect(result.touchedTopics).toEqual(['user']); - }); - it('updates cursor and avoids duplicate writes for repeated extraction', async () => { vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({ - patches: [ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 0, - }, - ], touchedTopics: [], systemMessage: undefined, }); @@ -154,8 +108,10 @@ describe('auto-memory extraction', () => { runAutoMemoryExtract({ projectRoot, sessionId: 'session-1', - history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], + history: [ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + ], }), ).rejects.toThrow('Managed auto-memory extraction requires config'); }); -}); \ No newline at end of file +}); diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts index 8a2170a63ee..631b09f16c1 100644 --- a/packages/core/src/memory/extract.ts +++ b/packages/core/src/memory/extract.ts @@ -23,7 +23,6 @@ import { type AutoMemoryType, } from './types.js'; -const MIN_CANDIDATE_LENGTH = 12; const debugLogger = createDebugLogger('AUTO_MEMORY_EXTRACT'); export interface AutoMemoryTranscriptMessage { @@ -32,80 +31,13 @@ export interface AutoMemoryTranscriptMessage { text: string; } -export interface AutoMemoryExtractPatch { - topic: AutoMemoryType; - summary: string; - why?: string; - howToApply?: string; - sourceOffset: number; -} - export interface AutoMemoryExtractResult { - patches: AutoMemoryExtractPatch[]; touchedTopics: AutoMemoryType[]; skippedReason?: 'already_running' | 'queued' | 'memory_tool'; systemMessage?: string; cursor: AutoMemoryExtractCursor; } -function normalizeSummary(text: string): string { - return text.replace(/\s+/g, ' ').trim(); -} - -function isTemporaryTask(text: string): boolean { - return /\b(today|now|currently|for this task|this session|temporary|temporarily)\b/i.test( - text, - ); -} - -function normalizeExtractPatch( - patch: AutoMemoryExtractPatch, -): AutoMemoryExtractPatch | null { - const summary = normalizeSummary( - patch.summary.replace(/^[-*]\s+/, '').trim(), - ); - if ( - summary.length < MIN_CANDIDATE_LENGTH || - summary.endsWith('?') || - isTemporaryTask(summary) - ) { - return null; - } - - return { - topic: patch.topic, - summary, - why: patch.why ? normalizeSummary(patch.why) : undefined, - howToApply: patch.howToApply - ? normalizeSummary(patch.howToApply) - : undefined, - sourceOffset: patch.sourceOffset, - }; -} - -function dedupeExtractPatches( - patches: AutoMemoryExtractPatch[], -): AutoMemoryExtractPatch[] { - const seen = new Set(); - const deduped: AutoMemoryExtractPatch[] = []; - - for (const patch of patches) { - const normalizedPatch = normalizeExtractPatch(patch); - if (!normalizedPatch) { - continue; - } - - const dedupeKey = `${normalizedPatch.topic}:${normalizedPatch.summary.toLowerCase()}`; - if (seen.has(dedupeKey)) { - continue; - } - seen.add(dedupeKey); - deduped.push(normalizedPatch); - } - - return deduped; -} - export function buildTranscriptMessages( history: Content[], ): AutoMemoryTranscriptMessage[] { @@ -113,7 +45,9 @@ export function buildTranscriptMessages( .map((message, index) => ({ offset: index, role: message.role, - text: normalizeSummary(partToString(message.parts ?? [])), + text: partToString(message.parts ?? []) + .replace(/\s+/g, ' ') + .trim(), })) .filter( (message): message is AutoMemoryTranscriptMessage => @@ -128,7 +62,7 @@ export function loadUnprocessedTranscriptSlice( cursor: AutoMemoryExtractCursor, ): { messages: AutoMemoryTranscriptMessage[]; nextProcessedOffset: number } { const startOffset = - cursor.sessionId === sessionId ? cursor.processedOffset ?? 0 : 0; + cursor.sessionId === sessionId ? (cursor.processedOffset ?? 0) : 0; return { messages: messages.filter((message) => message.offset >= startOffset), nextProcessedOffset: messages.length, @@ -180,7 +114,8 @@ async function bumpMetadata( metadata.lastExtractionAt = now.toISOString(); metadata.lastExtractionSessionId = sessionId; metadata.lastExtractionTouchedTopics = touchedTopics; - metadata.lastExtractionStatus = touchedTopics.length > 0 ? 'updated' : 'noop'; + metadata.lastExtractionStatus = + touchedTopics.length > 0 ? 'updated' : 'noop'; await fs.writeFile( getAutoMemoryMetadataPath(projectRoot), `${JSON.stringify(metadata, null, 2)}\n`, @@ -215,12 +150,22 @@ export async function runAutoMemoryExtract(params: { ); } + // Skip if no new user messages in the unprocessed slice. + const hasNewUserMessages = slice.messages.some((m) => m.role === 'user'); + if (!hasNewUserMessages) { + const cursor: AutoMemoryExtractCursor = { + sessionId: params.sessionId, + processedOffset: slice.nextProcessedOffset, + updatedAt: now.toISOString(), + }; + await writeExtractCursor(params.projectRoot, cursor); + return { touchedTopics: [], cursor }; + } + const agentResult = await runAutoMemoryExtractionByAgent( params.config, params.projectRoot, - slice.messages, ); - const patches = dedupeExtractPatches(agentResult.patches); if (agentResult.touchedTopics.length > 0) { await bumpMetadata( @@ -240,11 +185,10 @@ export async function runAutoMemoryExtract(params: { await writeExtractCursor(params.projectRoot, cursor); debugLogger.debug( - `Managed auto-memory extract completed with ${patches.length} patch(es) and ${agentResult.touchedTopics.length} touched topic(s).`, + `Managed auto-memory extract completed with ${agentResult.touchedTopics.length} touched topic(s).`, ); return { - patches, touchedTopics: agentResult.touchedTopics, cursor, systemMessage: agentResult.systemMessage, @@ -259,4 +203,4 @@ export async function scheduleAutoMemoryExtract(params: { config?: Config; }): Promise { return scheduleManagedAutoMemoryExtract(params); -} \ No newline at end of file +} diff --git a/packages/core/src/memory/extractAgent.test.ts b/packages/core/src/memory/extractAgent.test.ts index b635197fe78..cbfbbc6ae44 100644 --- a/packages/core/src/memory/extractAgent.test.ts +++ b/packages/core/src/memory/extractAgent.test.ts @@ -26,7 +26,9 @@ describe('auto-memory extraction with agent planner', () => { const mockConfig = {} as Config; beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-extract-agent-')); + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'auto-memory-extract-agent-'), + ); projectRoot = path.join(tempDir, 'project'); await fs.mkdir(projectRoot, { recursive: true }); await ensureAutoMemoryScaffold(projectRoot); @@ -64,13 +66,6 @@ describe('auto-memory extraction with agent planner', () => { ); return { - patches: [ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 0, - }, - ], touchedTopics: ['user'], systemMessage: 'Managed auto-memory updated: user.md', }; @@ -92,7 +87,6 @@ describe('auto-memory extraction with agent planner', () => { expect(runAutoMemoryExtractionByAgent).toHaveBeenCalledWith( mockConfig, projectRoot, - expect.any(Array), ); const docs = await scanAutoMemoryTopicDocuments(projectRoot); diff --git a/packages/core/src/memory/extractScheduler.test.ts b/packages/core/src/memory/extractScheduler.test.ts index 8f377c7251e..3d8948bb8c2 100644 --- a/packages/core/src/memory/extractScheduler.test.ts +++ b/packages/core/src/memory/extractScheduler.test.ts @@ -24,6 +24,7 @@ describe('managed auto-memory extraction runtime', () => { let tempDir: string; let projectRoot: string; let mockConfig: Config; + let extractionCount: number; beforeEach(async () => { tempDir = await fs.mkdtemp( @@ -37,43 +38,37 @@ describe('managed auto-memory extraction runtime', () => { getModel: vi.fn().mockReturnValue('qwen3-coder-plus'), } as unknown as Config; vi.clearAllMocks(); + extractionCount = 0; vi.mocked(runAutoMemoryExtractionByAgent).mockImplementation( - async (_config, root, messages) => { - const lastUserText = messages - .filter((message) => message.role === 'user') - .at(-1)?.text; - const topic = lastUserText?.includes('grafana.example/d/api') - ? 'reference' - : 'user'; + async (_config: Config, root: string) => { + extractionCount += 1; + const topic = extractionCount > 1 ? 'reference' : 'user'; const relativePath = topic === 'reference' ? path.join('reference', 'latency-dashboard.md') : path.join('user', 'terse-responses.md'); const filePath = getAutoMemoryFilePath(root, relativePath); await fs.mkdir(path.dirname(filePath), { recursive: true }); + const description = + topic === 'reference' + ? 'https://grafana.example/d/api' + : 'User prefers terse responses.'; await fs.writeFile( filePath, [ '---', `type: ${topic}`, `name: ${topic === 'reference' ? 'Latency Dashboard' : 'Terse Responses'}`, - `description: ${lastUserText ?? 'User prefers terse responses.'}`, + `description: ${description}`, '---', '', - lastUserText ?? 'User prefers terse responses.', + description, '', ].join('\n'), 'utf-8', ); return { - patches: [ - { - topic, - summary: lastUserText ?? 'User prefers terse responses.', - sourceOffset: messages.at(-1)?.offset ?? 0, - }, - ], touchedTopics: [topic], systemMessage: undefined, }; @@ -98,7 +93,9 @@ describe('managed auto-memory extraction runtime', () => { projectRoot, sessionId: 'session-1', config: mockConfig, - history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], + history: [ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + ], }); const queued = await runtime.schedule({ @@ -110,7 +107,9 @@ describe('managed auto-memory extraction runtime', () => { { role: 'model', parts: [{ text: 'Done.' }] }, { role: 'user', - parts: [{ text: 'The latency dashboard is https://grafana.example/d/api' }], + parts: [ + { text: 'The latency dashboard is https://grafana.example/d/api' }, + ], }, ], }); @@ -130,7 +129,9 @@ describe('managed auto-memory extraction runtime', () => { const tasks = runtime.listTasks(projectRoot); expect(tasks.some((task) => task.status === 'completed')).toBe(true); - expect(tasks.some((task) => task.metadata?.['trailing'] === true)).toBe(true); + expect(tasks.some((task) => task.metadata?.['trailing'] === true)).toBe( + true, + ); }); it('returns already_running when extraction state is externally locked', async () => { @@ -141,9 +142,11 @@ describe('managed auto-memory extraction runtime', () => { projectRoot, sessionId: 'session-1', config: mockConfig, - history: [{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] }], + history: [ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + ], }); expect(result.skippedReason).toBe('already_running'); }); -}); \ No newline at end of file +}); diff --git a/packages/core/src/memory/extractScheduler.ts b/packages/core/src/memory/extractScheduler.ts index 70bbf7d2744..a7b0372d2ff 100644 --- a/packages/core/src/memory/extractScheduler.ts +++ b/packages/core/src/memory/extractScheduler.ts @@ -44,7 +44,6 @@ function buildSkippedExtractResult( skippedReason: AutoMemoryExtractResult['skippedReason'], ): AutoMemoryExtractResult { return { - patches: [], touchedTopics: [], skippedReason, cursor: { @@ -209,11 +208,10 @@ export class ManagedAutoMemoryExtractRuntime { status: result.skippedReason ? 'skipped' : 'completed', progressText: result.systemMessage ?? - (result.patches.length > 0 - ? `Planned ${result.patches.length} managed auto-memory patch${result.patches.length === 1 ? '' : 'es'}.` + (result.touchedTopics.length > 0 + ? `Managed auto-memory updated: ${result.touchedTopics.join(', ')}.` : 'Managed auto-memory extraction completed without durable changes.'), metadata: { - patchCount: result.patches.length, touchedTopics: result.touchedTopics, processedOffset: result.cursor.processedOffset, skippedReason: result.skippedReason, @@ -225,7 +223,7 @@ export class ManagedAutoMemoryExtractRuntime { new MemoryExtractEvent({ trigger: 'auto', status: 'completed', - patches_count: result.patches.length, + patches_count: result.touchedTopics.length, touched_topics: result.touchedTopics, duration_ms: durationMs, }), diff --git a/packages/core/src/memory/extractionAgentPlanner.test.ts b/packages/core/src/memory/extractionAgentPlanner.test.ts index 1cb3a785dd6..4fb44e72b7d 100644 --- a/packages/core/src/memory/extractionAgentPlanner.test.ts +++ b/packages/core/src/memory/extractionAgentPlanner.test.ts @@ -8,7 +8,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; -import { runForkedAgent } from '../background/forkedAgent.js'; +import { + runForkedAgent, + getCacheSafeParams, +} from '../background/forkedAgent.js'; vi.mock('./scan.js', async (importOriginal) => { const actual = await importOriginal(); @@ -18,8 +21,17 @@ vi.mock('./scan.js', async (importOriginal) => { }; }); +vi.mock('./paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAutoMemoryRoot: vi.fn().mockReturnValue('/tmp/auto-memory'), + }; +}); + vi.mock('../background/forkedAgent.js', () => ({ runForkedAgent: vi.fn(), + getCacheSafeParams: vi.fn(), })); describe('runAutoMemoryExtractionByAgent', () => { @@ -31,12 +43,21 @@ describe('runAutoMemoryExtractionByAgent', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(getCacheSafeParams).mockReturnValue({ + generationConfig: {}, + history: [ + { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, + { role: 'model', parts: [{ text: 'Understood.' }] }, + ], + model: 'qwen3-coder-plus', + version: 1, + }); vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([ { type: 'user', - filePath: '/tmp/user.md', - relativePath: 'user.md', - filename: 'user.md', + filePath: '/tmp/auto-memory/user/prefs.md', + relativePath: 'user/prefs.md', + filename: 'prefs.md', title: 'User Memory', description: 'User preferences', body: '- Existing terse preference.', @@ -45,38 +66,16 @@ describe('runAutoMemoryExtractionByAgent', () => { ]); }); - it('returns parsed execution summary and enables write/edit tools', async () => { + it('derives touchedTopics from filesTouched and returns systemMessage', async () => { vi.mocked(runForkedAgent).mockResolvedValue({ status: 'completed', - finalText: JSON.stringify({ - patches: [ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 0, - }, - ], - touchedTopics: ['user'], - }), - filesTouched: ['/tmp/user.md'], + finalText: '', + filesTouched: ['/tmp/auto-memory/user/prefs.md'], }); - const result = await runAutoMemoryExtractionByAgent( - mockConfig, - '/tmp/project', - [{ offset: 0, role: 'user', text: 'I prefer terse responses.' }], - ); + const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp'); expect(result).toEqual({ - patches: [ - { - topic: 'user', - summary: 'User prefers terse responses.', - sourceOffset: 0, - why: undefined, - howToApply: undefined, - }, - ], touchedTopics: ['user'], systemMessage: 'Managed auto-memory updated: user.md', }); @@ -84,11 +83,12 @@ describe('runAutoMemoryExtractionByAgent', () => { expect.objectContaining({ tools: [ 'read_file', + 'grep_search', + 'glob', + 'list_directory', + 'run_shell_command', 'write_file', 'edit', - 'list_directory', - 'glob', - 'grep_search', ], maxTurns: 5, maxTimeMinutes: 2, @@ -96,6 +96,24 @@ describe('runAutoMemoryExtractionByAgent', () => { ); }); + it('returns empty touchedTopics when agent touches no files', async () => { + vi.mocked(runForkedAgent).mockResolvedValue({ + status: 'completed', + finalText: '', + filesTouched: [], + }); + + const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp'); + expect(result).toEqual({ touchedTopics: [] }); + }); + + it('throws when getCacheSafeParams returns null', async () => { + vi.mocked(getCacheSafeParams).mockReturnValue(null); + await expect( + runAutoMemoryExtractionByAgent(mockConfig, '/tmp'), + ).rejects.toThrow('no cache-safe params'); + }); + it('throws when the agent fails to complete', async () => { vi.mocked(runForkedAgent).mockResolvedValue({ status: 'failed', @@ -104,29 +122,25 @@ describe('runAutoMemoryExtractionByAgent', () => { }); await expect( - runAutoMemoryExtractionByAgent(mockConfig, '/tmp/project', [ - { offset: 0, role: 'user', text: 'I prefer terse.' }, - ]), + runAutoMemoryExtractionByAgent(mockConfig, '/tmp/project'), ).rejects.toThrow('timeout'); }); - it('returns empty result when messages array is empty', async () => { - const result = await runAutoMemoryExtractionByAgent( - mockConfig, - '/tmp/project', - [], - ); - expect(result).toEqual({ patches: [], touchedTopics: [] }); - expect(runForkedAgent).not.toHaveBeenCalled(); - }); + it('ignores non-memory file paths in filesTouched', async () => { + vi.mocked(runForkedAgent).mockResolvedValue({ + status: 'completed', + finalText: '', + filesTouched: [ + '/tmp/auto-memory/project/arch.md', + '/tmp/auto-memory/reference/api.md', + '/tmp/some/other/file.ts', + ], + }); - it('returns empty result when there are no user messages', async () => { - const result = await runAutoMemoryExtractionByAgent( - mockConfig, - '/tmp/project', - [{ offset: 0, role: 'model', text: 'Sure!' }], + const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp'); + expect(result.touchedTopics).toEqual( + expect.arrayContaining(['project', 'reference']), ); - expect(result).toEqual({ patches: [], touchedTopics: [] }); - expect(runForkedAgent).not.toHaveBeenCalled(); + expect(result.touchedTopics).not.toContain('user'); }); }); diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index 5854fda1ec7..5b70076dc16 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -5,9 +5,17 @@ */ import type { Config } from '../config/config.js'; -import { runForkedAgent } from '../background/forkedAgent.js'; -import { SchemaValidator } from '../utils/schemaValidator.js'; -import { safeJsonParse } from '../utils/safeJsonParse.js'; +import { + runForkedAgent, + getCacheSafeParams, +} from '../background/forkedAgent.js'; +import { buildFunctionResponseParts } from '../agents/runtime/forkSubagent.js'; +import type { Content } from '@google/genai'; +import type { PermissionManager } from '../permissions/permission-manager.js'; +import type { + PermissionCheckContext, + PermissionDecision, +} from '../permissions/types.js'; import { MEMORY_FRONTMATTER_EXAMPLE, TYPES_SECTION_INDIVIDUAL, @@ -15,32 +23,150 @@ import { } from './prompt.js'; import { AUTO_MEMORY_INDEX_FILENAME, getAutoMemoryRoot } from './paths.js'; import type { AutoMemoryType } from './types.js'; -import type { - AutoMemoryExtractPatch, - AutoMemoryTranscriptMessage, -} from './extract.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; +import { ToolNames } from '../tools/tool-names.js'; +import { isShellCommandReadOnlyAST } from '../utils/shellAstParser.js'; +import { stripShellWrapper } from '../utils/shell-utils.js'; +import { isAutoMemPath } from './paths.js'; const MAX_TOPIC_SUMMARY_CHARS = 280; +type MemoryScopedPermissionManager = Pick< + PermissionManager, + | 'evaluate' + | 'findMatchingDenyRule' + | 'hasMatchingAskRule' + | 'hasRelevantRules' + | 'isToolEnabled' +>; + +function isScopedTool(toolName: string): boolean { + return ( + toolName === ToolNames.SHELL || + toolName === ToolNames.EDIT || + toolName === ToolNames.WRITE_FILE + ); +} + +function mergePermissionDecision( + scopedDecision: PermissionDecision, + baseDecision: PermissionDecision, +): PermissionDecision { + const priority: Record = { + deny: 4, + ask: 3, + allow: 2, + default: 1, + }; + return priority[baseDecision] > priority[scopedDecision] + ? baseDecision + : scopedDecision; +} + +async function evaluateScopedDecision( + ctx: PermissionCheckContext, + projectRoot: string, +): Promise { + switch (ctx.toolName) { + case ToolNames.SHELL: { + if (!ctx.command) { + return 'deny'; + } + const isReadOnly = await isShellCommandReadOnlyAST( + stripShellWrapper(ctx.command), + ); + return isReadOnly ? 'allow' : 'deny'; + } + case ToolNames.EDIT: + case ToolNames.WRITE_FILE: + return ctx.filePath && isAutoMemPath(ctx.filePath, projectRoot) + ? 'allow' + : 'deny'; + default: + return 'default'; + } +} + +function getScopedDenyRule( + ctx: PermissionCheckContext, + projectRoot: string, +): string | undefined { + switch (ctx.toolName) { + case ToolNames.SHELL: + return 'ManagedAutoMemory(run_shell_command: read-only only)'; + case ToolNames.EDIT: + return `ManagedAutoMemory(edit: only within ${getAutoMemoryRoot(projectRoot)})`; + case ToolNames.WRITE_FILE: + return `ManagedAutoMemory(write_file: only within ${getAutoMemoryRoot(projectRoot)})`; + default: + return undefined; + } +} + +function createMemoryScopedAgentConfig( + config: Config, + projectRoot: string, +): Config { + const basePm = config.getPermissionManager?.(); + const scopedPm: MemoryScopedPermissionManager = { + hasRelevantRules(ctx: PermissionCheckContext): boolean { + return isScopedTool(ctx.toolName) || !!basePm?.hasRelevantRules(ctx); + }, + hasMatchingAskRule(ctx: PermissionCheckContext): boolean { + return basePm?.hasMatchingAskRule(ctx) ?? false; + }, + findMatchingDenyRule(ctx: PermissionCheckContext): string | undefined { + const scoped = getScopedDenyRule(ctx, projectRoot); + if (scoped) { + return scoped; + } + return basePm?.findMatchingDenyRule(ctx); + }, + async evaluate(ctx: PermissionCheckContext): Promise { + const scopedDecision = await evaluateScopedDecision(ctx, projectRoot); + if (!basePm) { + return scopedDecision; + } + const baseDecision = basePm.hasRelevantRules(ctx) + ? await basePm.evaluate(ctx) + : 'default'; + return mergePermissionDecision(scopedDecision, baseDecision); + }, + async isToolEnabled(toolName: string): Promise { + // Registry-level check: is this tool type allowed at all? + // Scoped tools (SHELL/EDIT/WRITE_FILE) are enabled — per-invocation + // restrictions are enforced in evaluate(). + if (isScopedTool(toolName)) { + return true; + } + if (basePm) { + return basePm.isToolEnabled(toolName); + } + return true; + }, + }; + + const scopedConfig = Object.create(config) as Config; + scopedConfig.getPermissionManager = () => + scopedPm as unknown as PermissionManager; + return scopedConfig; +} + const EXTRACTION_AGENT_SYSTEM_PROMPT = [ 'You are now acting as the managed memory extraction subagent for an AI coding assistant.', '', - 'Analyze the provided recent transcript slice and use it to update durable managed memory.', - '', - 'You will be given current managed memory topic summaries. Improve existing memory rather than creating duplicate facts.', + 'The recent conversation history is already in your context. Analyze only that recent conversation and use it to update persistent managed memory.', '', 'Rules:', - '- Output JSON only.', - '- Follow the schema exactly.', + '- Read existing memory files first to avoid creating duplicates.', '- Extract only durable facts stated by the user.', '- Ignore temporary, session-specific, speculative, or question content.', '- If the user explicitly asks the assistant to remember something durable, preserve it.', '- Use one of the allowed topics: user, feedback, project, reference.', - '- Keep summaries concise and suitable for bullet points.', - '- Do not include leading bullet markers.', - '- You may use read-only tools to inspect topic files when the provided summaries seem insufficient.', - '- Do not investigate the repository or verify the memory against unrelated code. Work only from the provided transcript slice and managed memory context.', + '- Keep entries concise and suitable for bullet points. No leading bullet markers.', + '- Do not investigate repository code, git history, or unrelated files.', + '- Work only from the conversation history in your context and the existing memory files.', + '- If nothing durable should be saved, make no file changes.', '', ...TYPES_SECTION_INDIVIDUAL, ...WHAT_NOT_TO_SAVE_SECTION, @@ -49,56 +175,42 @@ const EXTRACTION_AGENT_SYSTEM_PROMPT = [ ...MEMORY_FRONTMATTER_EXAMPLE, ].join('\n'); -const EXTRACTION_AGENT_EXECUTION_RESPONSE_SCHEMA: Record = { - type: 'object', - properties: { - patches: { - type: 'array', - items: { - type: 'object', - properties: { - topic: { - type: 'string', - enum: ['user', 'feedback', 'project', 'reference'], - }, - summary: { - type: 'string', - }, - why: { - type: 'string', - }, - howToApply: { - type: 'string', - }, - sourceOffset: { - type: 'integer', - }, - }, - required: ['topic', 'summary', 'sourceOffset'], - }, - }, - touchedTopics: { - type: 'array', - items: { - type: 'string', - enum: ['user', 'feedback', 'project', 'reference'], - }, - }, - }, - required: ['patches', 'touchedTopics'], -}; - -interface ExtractionAgentExecutionResponse { - patches: AutoMemoryExtractPatch[]; - touchedTopics: AutoMemoryType[]; -} - export interface AutoMemoryExtractionExecutionResult { - patches: AutoMemoryExtractPatch[]; touchedTopics: AutoMemoryType[]; systemMessage?: string; } +/** + * Ensure the history slice ends with a `model` text message so that + * agent-headless can send the task prompt as the first user turn without + * creating consecutive user messages (Gemini API constraint). + * + * - Trailing `user` message: drop it. + * - Last `model` message has open function calls: close them with placeholder + * responses and append a model ack so the sequence stays valid. + * - Otherwise: return a shallow copy as-is. + */ +function buildAgentHistory(history: Content[]): Content[] { + if (history.length === 0) return []; + const last = history[history.length - 1]; + if (last.role !== 'model') { + return history.slice(0, -1); + } + const openCalls = (last.parts ?? []).filter((p) => p.functionCall); + if (openCalls.length === 0) { + return [...history]; + } + const toolResponses = buildFunctionResponseParts( + last, + 'Background extraction started.', + ); + return [ + ...history, + { role: 'user' as const, parts: toolResponses }, + { role: 'model' as const, parts: [{ text: 'Acknowledged.' }] }, + ]; +} + function truncate(text: string, maxChars: number): string { const normalized = text.replace(/\s+/g, ' ').trim(); if (normalized.length <= maxChars) { @@ -107,17 +219,11 @@ function truncate(text: string, maxChars: number): string { return `${normalized.slice(0, maxChars).trimEnd()}…`; } -function buildTranscriptBlock(messages: AutoMemoryTranscriptMessage[]): string { - return messages - .map( - (message) => - `- offset=${message.offset} role=${message.role} text=${message.text}`, - ) - .join('\n'); -} - async function buildTopicSummaryBlock(projectRoot: string): Promise { const docs = await scanAutoMemoryTopicDocuments(projectRoot); + if (docs.length === 0) { + return ''; + } return docs .map((doc) => { const body = truncate( @@ -125,161 +231,122 @@ async function buildTopicSummaryBlock(projectRoot: string): Promise { MAX_TOPIC_SUMMARY_CHARS, ); return [ - `topic=${doc.type}`, - `path=${doc.filePath}`, - `title=${doc.title}`, - `description=${doc.description || '(none)'}`, - `current=${body || '(empty)'}`, + `- [${doc.title}](${doc.relativePath}) — ${doc.description || '(no description)'}`, + ` topic=${doc.type}`, + ` path=${doc.filePath}`, + ` current=${body || '(empty)'}`, ].join('\n'); }) .join('\n\n'); } -function buildExecutionTaskPrompt( - memoryRoot: string, - messages: AutoMemoryTranscriptMessage[], - topicSummaries: string, -): string { +function buildTaskPrompt(memoryRoot: string, topicSummaries: string): string { return [ `Managed memory directory: \`${memoryRoot}\``, '', - 'You must update durable managed memory by directly using tools to read and write files inside the managed memory directory.', + 'Scan the recent conversation history in your context and update durable managed memory.', '', - 'Available tools in this run: `read_file`, `list_directory`, `glob`, `grep_search`, `write_file`, `edit`.', + 'Available tools in this run: `read_file`, `grep_search`, `glob`, `list_directory`, read-only `run_shell_command`, and `write_file`/`edit` for paths inside the managed memory directory only.', '- Do not use any other tools.', + '- You have a limited turn budget. `edit` requires a prior `read_file` of the same file, so the efficient strategy is: first issue all reads in parallel for every file you might update; then issue all `write_file`/`edit` calls in parallel. Do not interleave reads and writes across multiple turns.', + '- You MUST only use content from the recent conversation history in your context plus the current managed memory files.', '- Do not inspect repository code, git history, or unrelated files.', - '- Work only from the transcript slice below plus the current managed memory files.', '- Prefer updating an existing memory file over creating a duplicate.', - '- If you create or delete a memory file, also update the managed memory index.', - `- The managed memory index is \`${memoryRoot}/${AUTO_MEMORY_INDEX_FILENAME}\`.`, '- Keep one durable memory per file under `user/`, `feedback/`, `project/`, or `reference/`.', - '- If nothing durable should be saved, make no file changes.', - '', - 'Memory file format reference:', - ...MEMORY_FRONTMATTER_EXAMPLE, '', - ...TYPES_SECTION_INDIVIDUAL, - ...WHAT_NOT_TO_SAVE_SECTION, + '## How to save memories', '', - 'After all tool work is complete, output JSON only matching this schema:', - JSON.stringify(EXTRACTION_AGENT_EXECUTION_RESPONSE_SCHEMA, null, 2), + '**Step 1** — write or update the memory file itself using the required frontmatter format.', + `**Step 2** — update \`${memoryRoot}/${AUTO_MEMORY_INDEX_FILENAME}\`. It is an index, not a memory: each entry must be one line in the form \`- [Title](relative/path.md) — one-line hook\`. Never write memory content directly into the index.`, + '- If you create or delete a memory file, also update the managed memory index.', + '- If nothing durable should be saved, make no file changes.', '', - 'Transcript slice:', - buildTranscriptBlock(messages), + '## Existing memory files', '', - 'Current topic summaries:', - topicSummaries || '(no topics found)', + topicSummaries || '(none yet)', ].join('\n'); } -function validateExtractionExecutionResponse( - parsed: ExtractionAgentExecutionResponse, - userOffsets: Set, -): AutoMemoryExtractionExecutionResult { - const schemaError = SchemaValidator.validate( - EXTRACTION_AGENT_EXECUTION_RESPONSE_SCHEMA, - parsed, - ); - if (schemaError) { - throw new Error(`Invalid extraction agent response: ${schemaError}`); - } - - const patches = parsed.patches.map((patch) => { - if (!patch.summary?.trim()) { - throw new Error('Invalid extraction agent response: empty summary'); - } - if (!userOffsets.has(patch.sourceOffset)) { - throw new Error( - 'Invalid extraction agent response: invalid sourceOffset', - ); +/** + * Derive which memory topics were touched from the list of file paths written + * during the agent run. Avoids requiring JSON output from the agent. + */ +function touchedTopicsFromFilePaths( + filePaths: string[], + projectRoot: string, +): AutoMemoryType[] { + const memoryRoot = getAutoMemoryRoot(projectRoot); + const topicSet = new Set(); + for (const p of filePaths) { + if (!p.startsWith(memoryRoot)) continue; + const rel = p.slice(memoryRoot.length).replace(/^\//, ''); + const segment = rel.split('/')[0] as AutoMemoryType; + if ( + segment === 'user' || + segment === 'feedback' || + segment === 'project' || + segment === 'reference' + ) { + topicSet.add(segment); } - - return { - topic: patch.topic as AutoMemoryType, - summary: patch.summary.trim(), - why: patch.why?.trim(), - howToApply: patch.howToApply?.trim(), - sourceOffset: patch.sourceOffset, - }; - }); - const touchedTopics = Array.from( - new Set( - (parsed.touchedTopics ?? []).filter( - (topic): topic is AutoMemoryType => - topic === 'user' || - topic === 'feedback' || - topic === 'project' || - topic === 'reference', - ), - ), - ); - - return { - patches, - touchedTopics, - systemMessage: - touchedTopics.length > 0 - ? `Managed auto-memory updated: ${touchedTopics.map((topic) => `${topic}.md`).join(', ')}` - : undefined, - }; + } + return [...topicSet]; } export async function runAutoMemoryExtractionByAgent( config: Config, projectRoot: string, - messages: AutoMemoryTranscriptMessage[], ): Promise { - if (messages.length === 0) { - return { - patches: [], - touchedTopics: [], - }; - } - - const userOffsets = new Set( - messages - .filter((message) => message.role === 'user') - .map((message) => message.offset), - ); - if (userOffsets.size === 0) { - return { - patches: [], - touchedTopics: [], - }; + const cacheSafe = getCacheSafeParams(); + if (!cacheSafe) { + throw new Error( + 'runAutoMemoryExtractionByAgent: no cache-safe params available; ' + + 'extraction must run after a completed main turn.', + ); } + const extraHistory = buildAgentHistory(cacheSafe.history); const topicSummaries = await buildTopicSummaryBlock(projectRoot); const memoryRoot = getAutoMemoryRoot(projectRoot); + const scopedConfig = createMemoryScopedAgentConfig(config, projectRoot); + const result = await runForkedAgent({ name: 'managed-auto-memory-extractor', - config, - taskPrompt: buildExecutionTaskPrompt(memoryRoot, messages, topicSummaries), + config: scopedConfig, + taskPrompt: buildTaskPrompt(memoryRoot, topicSummaries), systemPrompt: EXTRACTION_AGENT_SYSTEM_PROMPT, maxTurns: 5, maxTimeMinutes: 2, tools: [ - 'read_file', - 'write_file', - 'edit', - 'list_directory', - 'glob', - 'grep_search', + ToolNames.READ_FILE, + ToolNames.GREP, + ToolNames.GLOB, + ToolNames.LS, + ToolNames.SHELL, + ToolNames.WRITE_FILE, + ToolNames.EDIT, ], + extraHistory, + skipEnvHistory: true, }); - if (result.status !== 'completed' || !result.finalText) { + if (result.status !== 'completed') { throw new Error( result.terminateReason || 'Extraction agent did not complete successfully', ); } - const parsed = safeJsonParse( - result.finalText, - { - patches: [], - touchedTopics: [], - }, + const touchedTopics = touchedTopicsFromFilePaths( + result.filesTouched, + projectRoot, ); - return validateExtractionExecutionResponse(parsed, userOffsets); + + return { + touchedTopics, + systemMessage: + touchedTopics.length > 0 + ? `Managed auto-memory updated: ${touchedTopics.map((t) => `${t}.md`).join(', ')}` + : undefined, + }; } diff --git a/packages/core/src/memory/memoryLifecycle.integration.test.ts b/packages/core/src/memory/memoryLifecycle.integration.test.ts index eefdeba3479..bb5af0a0464 100644 --- a/packages/core/src/memory/memoryLifecycle.integration.test.ts +++ b/packages/core/src/memory/memoryLifecycle.integration.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { runManagedAutoMemoryDream } from './dream.js'; +import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; import { drainManagedAutoMemoryExtractTasks, resetManagedAutoMemoryExtractRuntimeForTests, @@ -27,10 +28,15 @@ vi.mock('./extractionAgentPlanner.js', () => ({ runAutoMemoryExtractionByAgent: vi.fn(), })); +vi.mock('./dreamAgentPlanner.js', () => ({ + planManagedAutoMemoryDreamByAgent: vi.fn(), +})); + describe('managed auto-memory lifecycle integration', () => { let tempDir: string; let projectRoot: string; let mockConfig: Config; + let extractionCount: number; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-lifecycle-int-')); @@ -45,48 +51,56 @@ describe('managed auto-memory lifecycle integration', () => { getModel: () => 'qwen3-coder-plus', } as Config; vi.clearAllMocks(); + extractionCount = 0; vi.mocked(runAutoMemoryExtractionByAgent).mockImplementation( - async (_config, root, messages) => { - const lastUserText = messages - .filter((message) => message.role === 'user') - .at(-1)?.text; - const topic = lastUserText?.includes('grafana.example/d/api-latency') - ? 'reference' - : 'user'; + async (_config, root: string) => { + extractionCount += 1; + const topic = extractionCount > 1 ? 'reference' : 'user'; const relativePath = topic === 'reference' ? path.join('reference', 'latency-dashboard.md') : path.join('user', 'terse-responses.md'); const filePath = getAutoMemoryFilePath(root, relativePath); await fs.mkdir(path.dirname(filePath), { recursive: true }); + const description = + topic === 'reference' + ? 'https://grafana.example/d/api-latency' + : 'I prefer terse responses.'; await fs.writeFile( filePath, [ '---', `type: ${topic}`, `name: ${topic === 'reference' ? 'Latency Dashboard' : 'Terse Responses'}`, - `description: ${lastUserText ?? 'I prefer terse responses.'}`, + `description: ${description}`, '---', '', - lastUserText ?? 'I prefer terse responses.', + description, '', ].join('\n'), 'utf-8', ); return { - patches: [ - { - topic, - summary: lastUserText ?? 'I prefer terse responses.', - sourceOffset: messages.at(-1)?.offset ?? 0, - }, - ], touchedTopics: [topic], systemMessage: undefined, }; }, ); + vi.mocked(planManagedAutoMemoryDreamByAgent).mockResolvedValue({ + status: 'completed', + finalText: 'Consolidated memory files and updated the index.', + filesTouched: [ + getAutoMemoryFilePath( + projectRoot, + path.join('user', 'terse-responses.md'), + ), + getAutoMemoryFilePath( + projectRoot, + path.join('reference', 'latency-dashboard.md'), + ), + ], + }); }); afterEach(async () => { @@ -185,9 +199,10 @@ describe('managed auto-memory lifecycle integration', () => { const dreamResult = await runManagedAutoMemoryDream( projectRoot, new Date('2026-04-01T03:00:00.000Z'), + mockConfig, ); expect(dreamResult.touchedTopics).toContain('user'); - expect(dreamResult.dedupedEntries).toBeGreaterThan(0); + expect(dreamResult.dedupedEntries).toBe(0); const indexContent = await fs.readFile( getAutoMemoryIndexPath(projectRoot), diff --git a/packages/core/src/tools/agent.ts b/packages/core/src/tools/agent.ts index 1cd9cbb1ff8..b7a198593aa 100644 --- a/packages/core/src/tools/agent.ts +++ b/packages/core/src/tools/agent.ts @@ -603,7 +603,7 @@ class AgentToolInvocation extends BaseToolInvocation { // tools) so the fork's API requests share the same prefix for // DashScope prompt cache hits. const { getCacheSafeParams } = await import( - '../followup/forkedQuery.js' + '../background/forkedAgent.js' ); const cacheSafeParams = getCacheSafeParams(); if (cacheSafeParams) { @@ -612,7 +612,7 @@ class AgentToolInvocation extends BaseToolInvocation { if (tools && tools.length > 0) { forkToolsOverride = tools .flatMap( - (t) => + (t: import('@google/genai').ToolUnion) => ( t as { functionDeclarations?: Array< @@ -622,7 +622,7 @@ class AgentToolInvocation extends BaseToolInvocation { ).functionDeclarations ?? [], ) .filter( - (decl) => + (decl: import('@google/genai').FunctionDeclaration) => !(decl.name && EXCLUDED_TOOLS_FOR_SUBAGENTS.has(decl.name)), ); } From 4f9cc4e56bb82b4ca29a813d4062309c16cea49b Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 14 Apr 2026 17:13:24 +0800 Subject: [PATCH 52/56] move doc to design --- docs/{developers => design/auto-memory}/memory-system.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{developers => design/auto-memory}/memory-system.md (100%) diff --git a/docs/developers/memory-system.md b/docs/design/auto-memory/memory-system.md similarity index 100% rename from docs/developers/memory-system.md rename to docs/design/auto-memory/memory-system.md From b84fef8449731d60b035ec0a912c58d997853296 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 15 Apr 2026 10:21:54 +0800 Subject: [PATCH 53/56] refactor(memory): unify extract+dream background task management into MemoryBackgroundTaskHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add memoryTaskHub.ts: single BackgroundTaskRegistry + BackgroundTaskDrainer shared by all memory background tasks; exposes listExtractTasks() / listDreamTasks() typed query helpers and a unified drain() method - extractScheduler: ManagedAutoMemoryExtractRuntime accepts hub via constructor (defaults to defaultMemoryTaskHub); test factory gets isolated fresh hub - dreamScheduler: same pattern — sessionScanner + hub injection; BackgroundTask- Scheduler initialized from injected hub; test factory gets isolated hub - status.ts: replace two separate getRegistry() calls with defaultMemoryTaskHub typed query methods - Footer.tsx (useDreamRunning): subscribe to shared registry, filter by DREAM_TASK_TYPE so extract tasks do not trigger the dream spinner - index.ts: re-export memoryTaskHub.ts so defaultMemoryTaskHub/DREAM_TASK_TYPE/ EXTRACT_TASK_TYPE are available as top-level package exports --- packages/cli/src/ui/components/Footer.tsx | 16 +++-- packages/core/src/index.ts | 1 + packages/core/src/memory/dreamScheduler.ts | 34 ++++++--- packages/core/src/memory/extractScheduler.ts | 25 +++++-- packages/core/src/memory/memoryTaskHub.ts | 74 ++++++++++++++++++++ packages/core/src/memory/status.ts | 11 ++- 6 files changed, 132 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/memory/memoryTaskHub.ts diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index b3d8410993c..ee85dadd56d 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -20,7 +20,8 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { ApprovalMode, - getManagedAutoMemoryDreamTaskRegistry, + defaultMemoryTaskHub, + DREAM_TASK_TYPE, } from '@qwen-code/qwen-code-core'; import { useCompactMode } from '../contexts/CompactModeContext.js'; import { t } from '../../i18n/index.js'; @@ -33,18 +34,25 @@ function useDreamRunning(projectRoot: string): boolean { const [running, setRunning] = useState(false); useEffect(() => { - const registry = getManagedAutoMemoryDreamTaskRegistry(); + const registry = defaultMemoryTaskHub.registry; function check() { const tasks = registry.list(projectRoot); setRunning( - tasks.some((t) => t.status === 'pending' || t.status === 'running'), + tasks.some( + (t) => + t.taskType === DREAM_TASK_TYPE && + (t.status === 'pending' || t.status === 'running'), + ), ); } check(); return registry.subscribe((task) => { - if (task.projectRoot === projectRoot) { + if ( + task.projectRoot === projectRoot && + task.taskType === DREAM_TASK_TYPE + ) { check(); } }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5dfbef1e42d..ab80e981df1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -137,6 +137,7 @@ export * from './memory/extractScheduler.js'; export * from './memory/dreamAgentPlanner.js'; export * from './memory/dream.js'; export * from './memory/dreamScheduler.js'; +export * from './memory/memoryTaskHub.js'; export * from './memory/scan.js'; export * from './memory/relevanceSelector.js'; export * from './memory/recall.js'; diff --git a/packages/core/src/memory/dreamScheduler.ts b/packages/core/src/memory/dreamScheduler.ts index df92726b14f..807c575cabf 100644 --- a/packages/core/src/memory/dreamScheduler.ts +++ b/packages/core/src/memory/dreamScheduler.ts @@ -8,15 +8,19 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { Config } from '../config/config.js'; import { Storage } from '../config/storage.js'; -import { +import type { BackgroundTaskDrainer, - type DrainBackgroundTasksOptions, + DrainBackgroundTasksOptions, } from '../background/taskDrainer.js'; -import { +import type { BackgroundTaskRegistry, - type BackgroundTaskState, + BackgroundTaskState, } from '../background/taskRegistry.js'; import { BackgroundTaskScheduler } from '../background/taskScheduler.js'; +import { + defaultMemoryTaskHub, + MemoryBackgroundTaskHub, +} from './memoryTaskHub.js'; import { getAutoMemoryConsolidationLockPath, getAutoMemoryMetadataPath, @@ -194,13 +198,18 @@ export type SessionScannerFn = ( ) => Promise; export class ManagedAutoMemoryDreamRuntime { - readonly registry = new BackgroundTaskRegistry(); - readonly drainer = new BackgroundTaskDrainer(); - readonly scheduler = new BackgroundTaskScheduler(this.registry, this.drainer); + readonly registry: BackgroundTaskRegistry; + readonly drainer: BackgroundTaskDrainer; + readonly scheduler: BackgroundTaskScheduler; constructor( private readonly sessionScanner: SessionScannerFn = listSessionsTouchedSince, - ) {} + hub: MemoryBackgroundTaskHub = defaultMemoryTaskHub, + ) { + this.registry = hub.registry; + this.drainer = hub.drainer; + this.scheduler = new BackgroundTaskScheduler(this.registry, this.drainer); + } /** * Timestamp (ms) of the last session-count filesystem scan per project root. * When the time-gate passes but session-count doesn't, we'd otherwise re-scan @@ -362,17 +371,20 @@ export async function scheduleManagedAutoMemoryDream( } export function getManagedAutoMemoryDreamTaskRegistry(): BackgroundTaskRegistry { - return defaultManagedAutoMemoryDreamRuntime.registry; + return defaultMemoryTaskHub.registry; } export async function drainManagedAutoMemoryDreamTasks( options?: DrainBackgroundTasksOptions, ): Promise { - return defaultManagedAutoMemoryDreamRuntime.drain(options); + return defaultMemoryTaskHub.drain(options); } export function createManagedAutoMemoryDreamRuntimeForTests( sessionScanner?: SessionScannerFn, ): ManagedAutoMemoryDreamRuntime { - return new ManagedAutoMemoryDreamRuntime(sessionScanner); + return new ManagedAutoMemoryDreamRuntime( + sessionScanner, + new MemoryBackgroundTaskHub(), + ); } diff --git a/packages/core/src/memory/extractScheduler.ts b/packages/core/src/memory/extractScheduler.ts index a7b0372d2ff..77ca8acc937 100644 --- a/packages/core/src/memory/extractScheduler.ts +++ b/packages/core/src/memory/extractScheduler.ts @@ -6,14 +6,20 @@ import type { Content, Part } from '@google/genai'; import type { Config } from '../config/config.js'; +import type { + BackgroundTaskDrainer} from '../background/taskDrainer.js'; import { - BackgroundTaskDrainer, type DrainBackgroundTasksOptions, } from '../background/taskDrainer.js'; +import type { + BackgroundTaskRegistry} from '../background/taskRegistry.js'; import { - BackgroundTaskRegistry, type BackgroundTaskState, } from '../background/taskRegistry.js'; +import { + defaultMemoryTaskHub, + MemoryBackgroundTaskHub, +} from './memoryTaskHub.js'; import { type AutoMemoryExtractResult, runAutoMemoryExtract, @@ -87,12 +93,17 @@ function historySliceUsesMemoryTool( } export class ManagedAutoMemoryExtractRuntime { - readonly registry = new BackgroundTaskRegistry(); - readonly drainer = new BackgroundTaskDrainer(); + readonly registry: BackgroundTaskRegistry; + readonly drainer: BackgroundTaskDrainer; private readonly currentTaskIdByProject = new Map(); private readonly queuedByProject = new Map(); + constructor(hub: MemoryBackgroundTaskHub = defaultMemoryTaskHub) { + this.registry = hub.registry; + this.drainer = hub.drainer; + } + async schedule( params: ScheduleAutoMemoryExtractParams, ): Promise { @@ -284,17 +295,17 @@ export async function scheduleManagedAutoMemoryExtract( } export function getManagedAutoMemoryExtractTaskRegistry(): BackgroundTaskRegistry { - return defaultManagedAutoMemoryExtractRuntime.registry; + return defaultMemoryTaskHub.registry; } export async function drainManagedAutoMemoryExtractTasks( options?: DrainBackgroundTasksOptions, ): Promise { - return defaultManagedAutoMemoryExtractRuntime.drain(options); + return defaultMemoryTaskHub.drain(options); } export function createManagedAutoMemoryExtractRuntimeForTests(): ManagedAutoMemoryExtractRuntime { - return new ManagedAutoMemoryExtractRuntime(); + return new ManagedAutoMemoryExtractRuntime(new MemoryBackgroundTaskHub()); } export function resetManagedAutoMemoryExtractRuntimeForTests(): void { diff --git a/packages/core/src/memory/memoryTaskHub.ts b/packages/core/src/memory/memoryTaskHub.ts new file mode 100644 index 00000000000..84119ff6b15 --- /dev/null +++ b/packages/core/src/memory/memoryTaskHub.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * MemoryBackgroundTaskHub — single shared Registry + Drainer for all + * memory background tasks (extract and dream). + * + * Both ManagedAutoMemoryExtractRuntime and ManagedAutoMemoryDreamRuntime + * accept a hub instance via constructor injection, defaulting to the + * module-level singleton `defaultMemoryTaskHub`. + * + * Benefits over two separate registries: + * - status.ts queries one place instead of manually aggregating two + * - drainAllMemoryTasks() works across both task types at once + * - Single listener subscription covers all memory activity + * - Tests receive a fresh hub per test suite (fully isolated) + */ + +import { + BackgroundTaskDrainer, + type DrainBackgroundTasksOptions, +} from '../background/taskDrainer.js'; +import { + BackgroundTaskRegistry, + type BackgroundTaskState, +} from '../background/taskRegistry.js'; + +export const EXTRACT_TASK_TYPE = 'managed-auto-memory-extraction' as const; +export const DREAM_TASK_TYPE = 'managed-auto-memory-dream' as const; + +export class MemoryBackgroundTaskHub { + readonly registry: BackgroundTaskRegistry; + readonly drainer: BackgroundTaskDrainer; + + constructor( + registry = new BackgroundTaskRegistry(), + drainer = new BackgroundTaskDrainer(), + ) { + this.registry = registry; + this.drainer = drainer; + } + + listExtractTasks(projectRoot?: string): BackgroundTaskState[] { + return this.registry + .list(projectRoot) + .filter((t) => t.taskType === EXTRACT_TASK_TYPE); + } + + listDreamTasks(projectRoot?: string): BackgroundTaskState[] { + return this.registry + .list(projectRoot) + .filter((t) => t.taskType === DREAM_TASK_TYPE); + } + + async drain(options?: DrainBackgroundTasksOptions): Promise { + return this.drainer.drain(options); + } +} + +/** Module-level singleton shared by all production extract/dream runtimes. */ +export const defaultMemoryTaskHub = new MemoryBackgroundTaskHub(); + +/** + * Drain all in-flight memory background tasks (extract + dream) for use in + * tests or CLI shutdown. + */ +export async function drainAllMemoryBackgroundTasks( + options?: DrainBackgroundTasksOptions, +): Promise { + return defaultMemoryTaskHub.drain(options); +} diff --git a/packages/core/src/memory/status.ts b/packages/core/src/memory/status.ts index e2bfe64e694..5acfaabd62d 100644 --- a/packages/core/src/memory/status.ts +++ b/packages/core/src/memory/status.ts @@ -5,8 +5,7 @@ */ import * as fs from 'node:fs/promises'; -import { getManagedAutoMemoryExtractTaskRegistry } from './extractScheduler.js'; -import { getManagedAutoMemoryDreamTaskRegistry } from './dreamScheduler.js'; +import { defaultMemoryTaskHub } from './memoryTaskHub.js'; import { getAutoMemoryExtractCursorPath, getAutoMemoryIndexPath, @@ -87,11 +86,9 @@ export async function getManagedAutoMemoryStatus( metadata, extractionRunning: isExtractRunning(projectRoot), topics, - extractionTasks: getManagedAutoMemoryExtractTaskRegistry() - .list(projectRoot) + extractionTasks: defaultMemoryTaskHub + .listExtractTasks(projectRoot) .slice(0, 8), - dreamTasks: getManagedAutoMemoryDreamTaskRegistry() - .list(projectRoot) - .slice(0, 5), + dreamTasks: defaultMemoryTaskHub.listDreamTasks(projectRoot).slice(0, 5), }; } From f9ec97766e4aa86dc8d4947ae2e2154a7ab0d775 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 15 Apr 2026 10:38:12 +0800 Subject: [PATCH 54/56] refactor(background): introduce general-purpose BackgroundTaskHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace memory-specific MemoryBackgroundTaskHub with a domain-agnostic BackgroundTaskHub in the background/ layer. Any future background task runtime (3rd, 4th, …) plugs in by accepting a hub via constructor injection — no new infrastructure required. Changes: - Add background/taskHub.ts: BackgroundTaskHub (registry + drainer + createScheduler() + listByType(taskType, projectRoot?)) and the globalBackgroundTaskHub singleton. Zero knowledge of any task type. - Delete memory/memoryTaskHub.ts: its narrow listExtractTasks / listDreamTasks helpers are replaced by the generic listByType() call. - Move EXTRACT_TASK_TYPE to extractScheduler.ts (owned by the runtime that defines it); replace 3 hardcoded string literals with the const. - Move DREAM_TASK_TYPE to dreamScheduler.ts; use hub.createScheduler() instead of manually wiring new BackgroundTaskScheduler(reg, drain). - status.ts: globalBackgroundTaskHub.listByType(EXTRACT_TASK_TYPE, ...) - Footer.tsx: globalBackgroundTaskHub.registry (shared, filtered by type) - index.ts: export background/taskHub.js; drop memory/memoryTaskHub.js --- packages/cli/src/ui/components/Footer.tsx | 4 +- packages/core/src/background/taskHub.ts | 75 ++++++++++++++++++++ packages/core/src/index.ts | 2 +- packages/core/src/memory/dreamScheduler.ts | 30 ++++---- packages/core/src/memory/extractScheduler.ts | 34 +++++---- packages/core/src/memory/memoryTaskHub.ts | 74 ------------------- packages/core/src/memory/status.ts | 12 ++-- 7 files changed, 117 insertions(+), 114 deletions(-) create mode 100644 packages/core/src/background/taskHub.ts delete mode 100644 packages/core/src/memory/memoryTaskHub.ts diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index ee85dadd56d..91335a7b83b 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -20,7 +20,7 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { ApprovalMode, - defaultMemoryTaskHub, + globalBackgroundTaskHub, DREAM_TASK_TYPE, } from '@qwen-code/qwen-code-core'; import { useCompactMode } from '../contexts/CompactModeContext.js'; @@ -34,7 +34,7 @@ function useDreamRunning(projectRoot: string): boolean { const [running, setRunning] = useState(false); useEffect(() => { - const registry = defaultMemoryTaskHub.registry; + const registry = globalBackgroundTaskHub.registry; function check() { const tasks = registry.list(projectRoot); diff --git a/packages/core/src/background/taskHub.ts b/packages/core/src/background/taskHub.ts new file mode 100644 index 00000000000..d24a695bd8c --- /dev/null +++ b/packages/core/src/background/taskHub.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * BackgroundTaskHub — a general-purpose container that groups one + * BackgroundTaskRegistry and one BackgroundTaskDrainer into a single + * injectable unit. + * + * Design goals: + * - Zero knowledge of any specific task type (extract, dream, …). + * Task-type constants live in the runtimes that own them. + * - Any future background task runtime plugs in by accepting a + * `hub: BackgroundTaskHub = globalBackgroundTaskHub` parameter — + * no new infrastructure needed. + * - Tests receive a `new BackgroundTaskHub()` for full isolation; + * production runtimes share `globalBackgroundTaskHub`. + */ + +import { BackgroundTaskDrainer } from './taskDrainer.js'; +import type { DrainBackgroundTasksOptions } from './taskDrainer.js'; +import { BackgroundTaskRegistry } from './taskRegistry.js'; +import type { BackgroundTaskState } from './taskRegistry.js'; +import { BackgroundTaskScheduler } from './taskScheduler.js'; + +export class BackgroundTaskHub { + readonly registry: BackgroundTaskRegistry; + readonly drainer: BackgroundTaskDrainer; + + constructor( + registry = new BackgroundTaskRegistry(), + drainer = new BackgroundTaskDrainer(), + ) { + this.registry = registry; + this.drainer = drainer; + } + + /** + * Create a BackgroundTaskScheduler wired to this hub's shared registry and + * drainer. Each runtime that needs deduplication should call this once at + * construction time rather than instantiating a scheduler directly. + */ + createScheduler(): BackgroundTaskScheduler { + return new BackgroundTaskScheduler(this.registry, this.drainer); + } + + /** + * Return all tasks whose `taskType` matches, optionally scoped to a + * projectRoot. Use this to build typed views without coupling the hub to + * any specific task domain. + * + * @example + * hub.listByType(EXTRACT_TASK_TYPE, projectRoot) + * hub.listByType(DREAM_TASK_TYPE) + */ + listByType(taskType: string, projectRoot?: string): BackgroundTaskState[] { + return this.registry + .list(projectRoot) + .filter((t) => t.taskType === taskType); + } + + async drain(options?: DrainBackgroundTasksOptions): Promise { + return this.drainer.drain(options); + } +} + +/** + * Application-wide singleton — shared by all background task runtimes in + * production. Each runtime accepts a hub via constructor injection and defaults + * to this value, so tests can pass a fresh `new BackgroundTaskHub()` without + * touching the global state. + */ +export const globalBackgroundTaskHub = new BackgroundTaskHub(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ab80e981df1..c81ee1a848e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -122,6 +122,7 @@ export * from './auxiliary/sideQuery.js'; export * from './background/taskRegistry.js'; export * from './background/taskDrainer.js'; export * from './background/taskScheduler.js'; +export * from './background/taskHub.js'; export * from './background/forkedAgent.js'; export * from './memory/types.js'; @@ -137,7 +138,6 @@ export * from './memory/extractScheduler.js'; export * from './memory/dreamAgentPlanner.js'; export * from './memory/dream.js'; export * from './memory/dreamScheduler.js'; -export * from './memory/memoryTaskHub.js'; export * from './memory/scan.js'; export * from './memory/relevanceSelector.js'; export * from './memory/recall.js'; diff --git a/packages/core/src/memory/dreamScheduler.ts b/packages/core/src/memory/dreamScheduler.ts index 807c575cabf..d7bda7c2d46 100644 --- a/packages/core/src/memory/dreamScheduler.ts +++ b/packages/core/src/memory/dreamScheduler.ts @@ -8,19 +8,17 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { Config } from '../config/config.js'; import { Storage } from '../config/storage.js'; -import type { - BackgroundTaskDrainer, - DrainBackgroundTasksOptions, -} from '../background/taskDrainer.js'; +import type { DrainBackgroundTasksOptions } from '../background/taskDrainer.js'; +import type { BackgroundTaskDrainer } from '../background/taskDrainer.js'; import type { BackgroundTaskRegistry, BackgroundTaskState, } from '../background/taskRegistry.js'; -import { BackgroundTaskScheduler } from '../background/taskScheduler.js'; +import type { BackgroundTaskScheduler } from '../background/taskScheduler.js'; import { - defaultMemoryTaskHub, - MemoryBackgroundTaskHub, -} from './memoryTaskHub.js'; + BackgroundTaskHub, + globalBackgroundTaskHub, +} from '../background/taskHub.js'; import { getAutoMemoryConsolidationLockPath, getAutoMemoryMetadataPath, @@ -197,6 +195,8 @@ export type SessionScannerFn = ( excludeSessionId: string, ) => Promise; +export const DREAM_TASK_TYPE = 'managed-auto-memory-dream' as const; + export class ManagedAutoMemoryDreamRuntime { readonly registry: BackgroundTaskRegistry; readonly drainer: BackgroundTaskDrainer; @@ -204,11 +204,11 @@ export class ManagedAutoMemoryDreamRuntime { constructor( private readonly sessionScanner: SessionScannerFn = listSessionsTouchedSince, - hub: MemoryBackgroundTaskHub = defaultMemoryTaskHub, + hub: BackgroundTaskHub = globalBackgroundTaskHub, ) { this.registry = hub.registry; this.drainer = hub.drainer; - this.scheduler = new BackgroundTaskScheduler(this.registry, this.drainer); + this.scheduler = hub.createScheduler(); } /** * Timestamp (ms) of the last session-count filesystem scan per project root. @@ -286,11 +286,11 @@ export class ManagedAutoMemoryDreamRuntime { } const scheduled = this.scheduler.schedule({ - taskType: 'managed-auto-memory-dream', + taskType: DREAM_TASK_TYPE, title: 'Managed auto-memory dream', projectRoot: params.projectRoot, sessionId: params.sessionId, - dedupeKey: `managed-auto-memory-dream:${params.projectRoot}`, + dedupeKey: `${DREAM_TASK_TYPE}:${params.projectRoot}`, metadata: { sessionCount: sessionIds.length, }, @@ -371,13 +371,13 @@ export async function scheduleManagedAutoMemoryDream( } export function getManagedAutoMemoryDreamTaskRegistry(): BackgroundTaskRegistry { - return defaultMemoryTaskHub.registry; + return globalBackgroundTaskHub.registry; } export async function drainManagedAutoMemoryDreamTasks( options?: DrainBackgroundTasksOptions, ): Promise { - return defaultMemoryTaskHub.drain(options); + return globalBackgroundTaskHub.drain(options); } export function createManagedAutoMemoryDreamRuntimeForTests( @@ -385,6 +385,6 @@ export function createManagedAutoMemoryDreamRuntimeForTests( ): ManagedAutoMemoryDreamRuntime { return new ManagedAutoMemoryDreamRuntime( sessionScanner, - new MemoryBackgroundTaskHub(), + new BackgroundTaskHub(), ); } diff --git a/packages/core/src/memory/extractScheduler.ts b/packages/core/src/memory/extractScheduler.ts index 77ca8acc937..f0253085fbe 100644 --- a/packages/core/src/memory/extractScheduler.ts +++ b/packages/core/src/memory/extractScheduler.ts @@ -6,20 +6,16 @@ import type { Content, Part } from '@google/genai'; import type { Config } from '../config/config.js'; +import type { BackgroundTaskDrainer } from '../background/taskDrainer.js'; +import type { DrainBackgroundTasksOptions } from '../background/taskDrainer.js'; import type { - BackgroundTaskDrainer} from '../background/taskDrainer.js'; -import { - type DrainBackgroundTasksOptions, -} from '../background/taskDrainer.js'; -import type { - BackgroundTaskRegistry} from '../background/taskRegistry.js'; -import { - type BackgroundTaskState, + BackgroundTaskRegistry, + BackgroundTaskState, } from '../background/taskRegistry.js'; import { - defaultMemoryTaskHub, - MemoryBackgroundTaskHub, -} from './memoryTaskHub.js'; + BackgroundTaskHub, + globalBackgroundTaskHub, +} from '../background/taskHub.js'; import { type AutoMemoryExtractResult, runAutoMemoryExtract, @@ -92,6 +88,8 @@ function historySliceUsesMemoryTool( ); } +export const EXTRACT_TASK_TYPE = 'managed-auto-memory-extraction' as const; + export class ManagedAutoMemoryExtractRuntime { readonly registry: BackgroundTaskRegistry; readonly drainer: BackgroundTaskDrainer; @@ -99,7 +97,7 @@ export class ManagedAutoMemoryExtractRuntime { private readonly currentTaskIdByProject = new Map(); private readonly queuedByProject = new Map(); - constructor(hub: MemoryBackgroundTaskHub = defaultMemoryTaskHub) { + constructor(hub: BackgroundTaskHub = globalBackgroundTaskHub) { this.registry = hub.registry; this.drainer = hub.drainer; } @@ -109,7 +107,7 @@ export class ManagedAutoMemoryExtractRuntime { ): Promise { if (historySliceUsesMemoryTool(params.history, params.projectRoot)) { const task = this.registry.register({ - taskType: 'managed-auto-memory-extraction', + taskType: EXTRACT_TASK_TYPE, title: 'Managed auto-memory extraction', projectRoot: params.projectRoot, sessionId: params.sessionId, @@ -147,7 +145,7 @@ export class ManagedAutoMemoryExtractRuntime { }); } else { const pendingTask = this.registry.register({ - taskType: 'managed-auto-memory-extraction', + taskType: EXTRACT_TASK_TYPE, title: 'Managed auto-memory extraction', projectRoot: params.projectRoot, sessionId: params.sessionId, @@ -172,7 +170,7 @@ export class ManagedAutoMemoryExtractRuntime { } const task = this.registry.register({ - taskType: 'managed-auto-memory-extraction', + taskType: EXTRACT_TASK_TYPE, title: 'Managed auto-memory extraction', projectRoot: params.projectRoot, sessionId: params.sessionId, @@ -295,17 +293,17 @@ export async function scheduleManagedAutoMemoryExtract( } export function getManagedAutoMemoryExtractTaskRegistry(): BackgroundTaskRegistry { - return defaultMemoryTaskHub.registry; + return globalBackgroundTaskHub.registry; } export async function drainManagedAutoMemoryExtractTasks( options?: DrainBackgroundTasksOptions, ): Promise { - return defaultMemoryTaskHub.drain(options); + return globalBackgroundTaskHub.drain(options); } export function createManagedAutoMemoryExtractRuntimeForTests(): ManagedAutoMemoryExtractRuntime { - return new ManagedAutoMemoryExtractRuntime(new MemoryBackgroundTaskHub()); + return new ManagedAutoMemoryExtractRuntime(new BackgroundTaskHub()); } export function resetManagedAutoMemoryExtractRuntimeForTests(): void { diff --git a/packages/core/src/memory/memoryTaskHub.ts b/packages/core/src/memory/memoryTaskHub.ts deleted file mode 100644 index 84119ff6b15..00000000000 --- a/packages/core/src/memory/memoryTaskHub.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * MemoryBackgroundTaskHub — single shared Registry + Drainer for all - * memory background tasks (extract and dream). - * - * Both ManagedAutoMemoryExtractRuntime and ManagedAutoMemoryDreamRuntime - * accept a hub instance via constructor injection, defaulting to the - * module-level singleton `defaultMemoryTaskHub`. - * - * Benefits over two separate registries: - * - status.ts queries one place instead of manually aggregating two - * - drainAllMemoryTasks() works across both task types at once - * - Single listener subscription covers all memory activity - * - Tests receive a fresh hub per test suite (fully isolated) - */ - -import { - BackgroundTaskDrainer, - type DrainBackgroundTasksOptions, -} from '../background/taskDrainer.js'; -import { - BackgroundTaskRegistry, - type BackgroundTaskState, -} from '../background/taskRegistry.js'; - -export const EXTRACT_TASK_TYPE = 'managed-auto-memory-extraction' as const; -export const DREAM_TASK_TYPE = 'managed-auto-memory-dream' as const; - -export class MemoryBackgroundTaskHub { - readonly registry: BackgroundTaskRegistry; - readonly drainer: BackgroundTaskDrainer; - - constructor( - registry = new BackgroundTaskRegistry(), - drainer = new BackgroundTaskDrainer(), - ) { - this.registry = registry; - this.drainer = drainer; - } - - listExtractTasks(projectRoot?: string): BackgroundTaskState[] { - return this.registry - .list(projectRoot) - .filter((t) => t.taskType === EXTRACT_TASK_TYPE); - } - - listDreamTasks(projectRoot?: string): BackgroundTaskState[] { - return this.registry - .list(projectRoot) - .filter((t) => t.taskType === DREAM_TASK_TYPE); - } - - async drain(options?: DrainBackgroundTasksOptions): Promise { - return this.drainer.drain(options); - } -} - -/** Module-level singleton shared by all production extract/dream runtimes. */ -export const defaultMemoryTaskHub = new MemoryBackgroundTaskHub(); - -/** - * Drain all in-flight memory background tasks (extract + dream) for use in - * tests or CLI shutdown. - */ -export async function drainAllMemoryBackgroundTasks( - options?: DrainBackgroundTasksOptions, -): Promise { - return defaultMemoryTaskHub.drain(options); -} diff --git a/packages/core/src/memory/status.ts b/packages/core/src/memory/status.ts index 5acfaabd62d..3f424b56022 100644 --- a/packages/core/src/memory/status.ts +++ b/packages/core/src/memory/status.ts @@ -5,7 +5,9 @@ */ import * as fs from 'node:fs/promises'; -import { defaultMemoryTaskHub } from './memoryTaskHub.js'; +import { globalBackgroundTaskHub } from '../background/taskHub.js'; +import { EXTRACT_TASK_TYPE } from './extractScheduler.js'; +import { DREAM_TASK_TYPE } from './dreamScheduler.js'; import { getAutoMemoryExtractCursorPath, getAutoMemoryIndexPath, @@ -86,9 +88,11 @@ export async function getManagedAutoMemoryStatus( metadata, extractionRunning: isExtractRunning(projectRoot), topics, - extractionTasks: defaultMemoryTaskHub - .listExtractTasks(projectRoot) + extractionTasks: globalBackgroundTaskHub + .listByType(EXTRACT_TASK_TYPE, projectRoot) .slice(0, 8), - dreamTasks: defaultMemoryTaskHub.listDreamTasks(projectRoot).slice(0, 5), + dreamTasks: globalBackgroundTaskHub + .listByType(DREAM_TASK_TYPE, projectRoot) + .slice(0, 5), }; } From 2bf6de71df737a99a6b860a2b6f4cda016bafc27 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 15 Apr 2026 10:59:31 +0800 Subject: [PATCH 55/56] test(background): add BackgroundTaskHub unit tests and hub isolation checks - background/taskHub.test.ts (11 tests): - createScheduler(): tasks registered via scheduler appear in hub registry; multiple calls return distinct scheduler instances - listByType(): filters by taskType, filters by projectRoot, returns [] for unknown types, two types co-exist in registry but stay separated - drain(): resolves false on timeout, resolves true when tasks complete, resolves true immediately when no tasks in flight - isolation: tasks in hubA do not appear in hubB - globalBackgroundTaskHub: is a BackgroundTaskHub instance with registry/drainer - extractScheduler.test.ts (+1 test): - factory-created runtimes have isolated registries; tasks in runtimeA are invisible to runtimeB; all tasks carry EXTRACT_TASK_TYPE - dreamScheduler.test.ts (+1 test): - factory-created runtimes have isolated registries; tasks in runtimeA are invisible to runtimeB; all tasks carry DREAM_TASK_TYPE --- packages/core/src/background/taskHub.test.ts | 195 ++++++++++++++++++ .../core/src/memory/dreamScheduler.test.ts | 26 +++ .../core/src/memory/extractScheduler.test.ts | 28 ++- 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/background/taskHub.test.ts diff --git a/packages/core/src/background/taskHub.test.ts b/packages/core/src/background/taskHub.test.ts new file mode 100644 index 00000000000..dd8c5a0bcea --- /dev/null +++ b/packages/core/src/background/taskHub.test.ts @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { BackgroundTaskHub, globalBackgroundTaskHub } from './taskHub.js'; + +const TYPE_A = 'test-type-a'; +const TYPE_B = 'test-type-b'; + +describe('BackgroundTaskHub', () => { + describe('createScheduler()', () => { + it('returns a scheduler wired to the hub registry and drainer', async () => { + const hub = new BackgroundTaskHub(); + const scheduler = hub.createScheduler(); + + let resolveRun: (() => void) | undefined; + const scheduled = scheduler.schedule({ + taskType: TYPE_A, + title: 'Test task', + projectRoot: '/project', + run: () => + new Promise((resolve) => { + resolveRun = resolve; + }), + }); + + // Task should appear in the shared registry immediately + const pending = hub.registry.get(scheduled.taskId); + expect(pending?.status).toBe('running'); + expect(pending?.taskType).toBe(TYPE_A); + + resolveRun?.(); + await scheduled.promise; + + const completed = hub.registry.get(scheduled.taskId); + expect(completed?.status).toBe('completed'); + }); + + it('each call to createScheduler() returns a distinct scheduler instance sharing the same hub', () => { + const hub = new BackgroundTaskHub(); + const s1 = hub.createScheduler(); + const s2 = hub.createScheduler(); + expect(s1).not.toBe(s2); + }); + }); + + describe('listByType()', () => { + it('returns only tasks matching the given taskType', async () => { + const hub = new BackgroundTaskHub(); + const scheduler = hub.createScheduler(); + + scheduler.schedule({ + taskType: TYPE_A, + title: 'A task', + projectRoot: '/project', + run: async () => {}, + }); + scheduler.schedule({ + taskType: TYPE_B, + title: 'B task', + projectRoot: '/project', + run: async () => {}, + }); + + await hub.drain(); + + const aOnly = hub.listByType(TYPE_A); + expect(aOnly).toHaveLength(1); + expect(aOnly[0]!.taskType).toBe(TYPE_A); + + const bOnly = hub.listByType(TYPE_B); + expect(bOnly).toHaveLength(1); + expect(bOnly[0]!.taskType).toBe(TYPE_B); + }); + + it('filters by projectRoot when provided', async () => { + const hub = new BackgroundTaskHub(); + const scheduler = hub.createScheduler(); + + scheduler.schedule({ + taskType: TYPE_A, + title: 'A in /project1', + projectRoot: '/project1', + run: async () => {}, + }); + scheduler.schedule({ + taskType: TYPE_A, + title: 'A in /project2', + projectRoot: '/project2', + run: async () => {}, + }); + + await hub.drain(); + + expect(hub.listByType(TYPE_A, '/project1')).toHaveLength(1); + expect(hub.listByType(TYPE_A, '/project2')).toHaveLength(1); + expect(hub.listByType(TYPE_A)).toHaveLength(2); + }); + + it('returns an empty array when no tasks of that type exist', () => { + const hub = new BackgroundTaskHub(); + expect(hub.listByType('nonexistent-type')).toEqual([]); + }); + + it('two different task types registered to the same hub are visible in the shared registry but separated by listByType()', async () => { + const hub = new BackgroundTaskHub(); + const sA = hub.createScheduler(); + const sB = hub.createScheduler(); + + sA.schedule({ + taskType: TYPE_A, + title: 'A', + projectRoot: '/p', + run: async () => {}, + }); + sB.schedule({ + taskType: TYPE_B, + title: 'B', + projectRoot: '/p', + run: async () => {}, + }); + + await hub.drain(); + + // Shared registry sees all tasks + expect(hub.registry.list()).toHaveLength(2); + // listByType gives domain-specific filtered views + expect(hub.listByType(TYPE_A)).toHaveLength(1); + expect(hub.listByType(TYPE_B)).toHaveLength(1); + }); + }); + + describe('drain()', () => { + it('resolves true when all in-flight tasks complete', async () => { + const hub = new BackgroundTaskHub(); + const scheduler = hub.createScheduler(); + + let resolveRun: (() => void) | undefined; + scheduler.schedule({ + taskType: TYPE_A, + title: 'Slow task', + projectRoot: '/project', + run: () => + new Promise((resolve) => { + resolveRun = resolve; + }), + }); + + // Drain times out while task is blocked + expect(await hub.drain({ timeoutMs: 10 })).toBe(false); + + resolveRun?.(); + expect(await hub.drain()).toBe(true); + }); + + it('resolves true immediately when no tasks are in flight', async () => { + const hub = new BackgroundTaskHub(); + expect(await hub.drain()).toBe(true); + }); + }); + + describe('isolation between hub instances', () => { + it('tasks registered in one hub do not appear in another hub', async () => { + const hubA = new BackgroundTaskHub(); + const hubB = new BackgroundTaskHub(); + + hubA.createScheduler().schedule({ + taskType: TYPE_A, + title: 'task in A', + projectRoot: '/p', + run: async () => {}, + }); + + await hubA.drain(); + + expect(hubA.listByType(TYPE_A)).toHaveLength(1); + expect(hubB.listByType(TYPE_A)).toHaveLength(0); + expect(hubB.registry.list()).toHaveLength(0); + }); + }); + + describe('globalBackgroundTaskHub', () => { + it('is a BackgroundTaskHub instance', () => { + expect(globalBackgroundTaskHub).toBeInstanceOf(BackgroundTaskHub); + }); + + it('exposes a registry and drainer', () => { + expect(globalBackgroundTaskHub.registry).toBeDefined(); + expect(globalBackgroundTaskHub.drainer).toBeDefined(); + }); + }); +}); diff --git a/packages/core/src/memory/dreamScheduler.test.ts b/packages/core/src/memory/dreamScheduler.test.ts index d9b589e6115..46f59a6657c 100644 --- a/packages/core/src/memory/dreamScheduler.test.ts +++ b/packages/core/src/memory/dreamScheduler.test.ts @@ -21,6 +21,7 @@ import { runManagedAutoMemoryDream } from './dream.js'; import { createManagedAutoMemoryDreamRuntimeForTests, DEFAULT_AUTO_DREAM_MIN_HOURS, + DREAM_TASK_TYPE, type SessionScannerFn, } from './dreamScheduler.js'; import { ensureAutoMemoryScaffold } from './store.js'; @@ -209,4 +210,29 @@ describe('managed auto-memory dream scheduler', () => { expect(metadata.lastDreamSessionId).toBe('session-1'); expect(metadata.lastDreamAt).toBe('2026-04-01T10:00:00.000Z'); }); + + it('test runtimes created by the factory have isolated task registries', async () => { + const runtimeA = createManagedAutoMemoryDreamRuntimeForTests( + makeSessionScanner(['session-0']), + ); + const runtimeB = createManagedAutoMemoryDreamRuntimeForTests( + makeSessionScanner(['session-0']), + ); + + const result = await runtimeA.schedule({ + projectRoot, + sessionId: 'session-1', + now: new Date('2026-04-01T10:00:00.000Z'), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 1, + }); + expect(result.status).toBe('scheduled'); + await result.promise; + + // runtimeA has the dream task; runtimeB registry is completely empty + const tasksA = runtimeA.registry.list(); + expect(tasksA.length).toBeGreaterThan(0); + expect(tasksA.every((t) => t.taskType === DREAM_TASK_TYPE)).toBe(true); + expect(runtimeB.registry.list()).toHaveLength(0); + }); }); diff --git a/packages/core/src/memory/extractScheduler.test.ts b/packages/core/src/memory/extractScheduler.test.ts index 3d8948bb8c2..60f2cd469f8 100644 --- a/packages/core/src/memory/extractScheduler.test.ts +++ b/packages/core/src/memory/extractScheduler.test.ts @@ -9,7 +9,10 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { createManagedAutoMemoryExtractRuntimeForTests } from './extractScheduler.js'; +import { + createManagedAutoMemoryExtractRuntimeForTests, + EXTRACT_TASK_TYPE, +} from './extractScheduler.js'; import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { getAutoMemoryFilePath } from './paths.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; @@ -149,4 +152,27 @@ describe('managed auto-memory extraction runtime', () => { expect(result.skippedReason).toBe('already_running'); }); + + it('test runtimes created by the factory have isolated task registries', async () => { + const runtimeA = createManagedAutoMemoryExtractRuntimeForTests(); + const runtimeB = createManagedAutoMemoryExtractRuntimeForTests(); + + // Schedule a task only in runtimeA + await runtimeA.schedule({ + projectRoot, + sessionId: 'session-a', + config: mockConfig, + history: [{ role: 'user', parts: [{ text: 'Prefer terse responses.' }] }], + }); + await runtimeA.drain({ timeoutMs: 1_000 }); + + // runtimeA registry has tasks, runtimeB registry is empty + expect(runtimeA.listTasks(projectRoot).length).toBeGreaterThan(0); + expect(runtimeB.listTasks(projectRoot)).toHaveLength(0); + + // All tasks in runtimeA belong to the extract type + expect( + runtimeA.listTasks().every((t) => t.taskType === EXTRACT_TASK_TYPE), + ).toBe(true); + }); }); From 3dba6b7730f12ca94367ad620b99e78a76959f1b Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 16 Apr 2026 11:53:09 +0800 Subject: [PATCH 56/56] refactor(memory): consolidate all memory state into MemoryManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace BackgroundTaskRegistry/Drainer/Scheduler/Hub helper classes and module-level globals with a single MemoryManager class owned by Config. ## Changes ### New - packages/core/src/memory/manager.ts — MemoryManager with: - scheduleExtract / scheduleDream (inline queuing + deduplication logic) - recall / forget / selectForgetCandidates / forgetMatches - getStatus / drain / appendToUserMemory - subscribe(listener) compatible with useSyncExternalStore - storeWith() atomic record registration (no double-notify) - Distinct skippedReason 'scan_throttled' vs 'min_sessions' for dream - packages/core/src/utils/forkedAgent.ts — pure cache util (moved from background/) - packages/core/src/utils/sideQuery.ts — pure util (moved from auxiliary/) ### Deleted - background/taskRegistry, taskDrainer, taskScheduler, taskHub and all tests - background/forkedAgent (moved to utils/) - auxiliary/sideQuery (moved to utils/) - memory/extractScheduler, dreamScheduler, state and all tests ### Modified - config/config.ts — Config owns MemoryManager instance; getMemoryManager() - core/client.ts — all memory ops via config.getMemoryManager() - core/client.test.ts — mock MemoryManager instead of individual modules - memory/status.ts — accepts MemoryManager param, drops globalBackgroundTaskHub - index.ts — memory exports reduced from 14 modules to 5 (manager/types/paths/store/const) - cli/commands/dreamCommand.ts — via config.getMemoryManager() - cli/commands/forgetCommand.ts — via config.getMemoryManager() - cli/components/Footer.tsx — useSyncExternalStore replacing setInterval polling - cli/components/Footer.test.tsx — add getMemoryManager mock --- packages/cli/src/ui/commands/dreamCommand.ts | 13 +- packages/cli/src/ui/commands/forgetCommand.ts | 19 +- .../cli/src/ui/components/Footer.test.tsx | 6 + packages/cli/src/ui/components/Footer.tsx | 57 +- .../core/src/background/taskDrainer.test.ts | 33 - packages/core/src/background/taskDrainer.ts | 44 - packages/core/src/background/taskHub.test.ts | 195 ---- packages/core/src/background/taskHub.ts | 75 -- .../core/src/background/taskRegistry.test.ts | 50 - packages/core/src/background/taskRegistry.ts | 114 --- .../core/src/background/taskScheduler.test.ts | 74 -- packages/core/src/background/taskScheduler.ts | 115 --- packages/core/src/config/config.ts | 16 +- packages/core/src/core/client.test.ts | 72 +- packages/core/src/core/client.ts | 65 +- packages/core/src/followup/smoke.test.ts | 2 +- packages/core/src/followup/speculation.ts | 4 +- .../core/src/followup/suggestionGenerator.ts | 5 +- packages/core/src/index.ts | 31 +- .../core/src/memory/dreamAgentPlanner.test.ts | 6 +- packages/core/src/memory/dreamAgentPlanner.ts | 2 +- .../core/src/memory/dreamScheduler.test.ts | 238 ----- packages/core/src/memory/dreamScheduler.ts | 390 -------- packages/core/src/memory/extract.test.ts | 2 - packages/core/src/memory/extract.ts | 11 - packages/core/src/memory/extractAgent.test.ts | 2 - .../core/src/memory/extractScheduler.test.ts | 178 ---- packages/core/src/memory/extractScheduler.ts | 311 ------ .../src/memory/extractionAgentPlanner.test.ts | 7 +- .../core/src/memory/extractionAgentPlanner.ts | 5 +- packages/core/src/memory/forget.ts | 2 +- packages/core/src/memory/governance.ts | 9 +- packages/core/src/memory/manager.test.ts | 471 +++++++++ packages/core/src/memory/manager.ts | 900 ++++++++++++++++++ .../memoryLifecycle.integration.test.ts | 18 +- .../core/src/memory/relevanceSelector.test.ts | 4 +- packages/core/src/memory/relevanceSelector.ts | 2 +- packages/core/src/memory/state.test.ts | 27 - packages/core/src/memory/state.ts | 23 - packages/core/src/memory/status.ts | 26 +- packages/core/src/tools/agent.ts | 4 +- .../forkedAgent.cache.test.ts | 0 .../src/{background => utils}/forkedAgent.ts | 0 packages/core/src/utils/nextSpeakerChecker.ts | 2 +- .../{auxiliary => utils}/sideQuery.test.ts | 4 +- .../src/{auxiliary => utils}/sideQuery.ts | 8 +- packages/core/src/utils/subagentGenerator.ts | 2 +- 47 files changed, 1539 insertions(+), 2105 deletions(-) delete mode 100644 packages/core/src/background/taskDrainer.test.ts delete mode 100644 packages/core/src/background/taskDrainer.ts delete mode 100644 packages/core/src/background/taskHub.test.ts delete mode 100644 packages/core/src/background/taskHub.ts delete mode 100644 packages/core/src/background/taskRegistry.test.ts delete mode 100644 packages/core/src/background/taskRegistry.ts delete mode 100644 packages/core/src/background/taskScheduler.test.ts delete mode 100644 packages/core/src/background/taskScheduler.ts delete mode 100644 packages/core/src/memory/dreamScheduler.test.ts delete mode 100644 packages/core/src/memory/dreamScheduler.ts delete mode 100644 packages/core/src/memory/extractScheduler.test.ts delete mode 100644 packages/core/src/memory/extractScheduler.ts create mode 100644 packages/core/src/memory/manager.test.ts create mode 100644 packages/core/src/memory/manager.ts delete mode 100644 packages/core/src/memory/state.test.ts delete mode 100644 packages/core/src/memory/state.ts rename packages/core/src/{background => utils}/forkedAgent.cache.test.ts (100%) rename packages/core/src/{background => utils}/forkedAgent.ts (100%) rename packages/core/src/{auxiliary => utils}/sideQuery.test.ts (97%) rename packages/core/src/{auxiliary => utils}/sideQuery.ts (91%) diff --git a/packages/cli/src/ui/commands/dreamCommand.ts b/packages/cli/src/ui/commands/dreamCommand.ts index 09d2aea158f..0d5040d393f 100644 --- a/packages/cli/src/ui/commands/dreamCommand.ts +++ b/packages/cli/src/ui/commands/dreamCommand.ts @@ -8,8 +8,6 @@ import { getAutoMemoryRoot, getProjectHash, QWEN_DIR, - buildConsolidationTaskPrompt, - writeDreamManualRunToMetadata, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; import type { SlashCommand } from './types.js'; @@ -36,16 +34,17 @@ export const dreamCommand: SlashCommand = { const projectHash = getProjectHash(projectRoot); const transcriptDir = `${QWEN_DIR}/tmp/${projectHash}/chats`; - const prompt = buildConsolidationTaskPrompt(memoryRoot, transcriptDir); + const prompt = config + .getMemoryManager() + .buildConsolidationPrompt(memoryRoot, transcriptDir); return { type: 'submit_prompt', content: prompt, onComplete: async () => { - await writeDreamManualRunToMetadata( - projectRoot, - config.getSessionId(), - ); + await config + .getMemoryManager() + .writeDreamManualRun(projectRoot, config.getSessionId()); }, }; }, diff --git a/packages/cli/src/ui/commands/forgetCommand.ts b/packages/cli/src/ui/commands/forgetCommand.ts index 6fb2f0b3274..185d7abcf7b 100644 --- a/packages/cli/src/ui/commands/forgetCommand.ts +++ b/packages/cli/src/ui/commands/forgetCommand.ts @@ -4,10 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - forgetManagedAutoMemoryMatches, - selectManagedAutoMemoryForgetCandidates, -} from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; @@ -38,16 +34,13 @@ export const forgetCommand: SlashCommand = { }; } - const selection = await selectManagedAutoMemoryForgetCandidates( - config.getProjectRoot(), - query, - { config }, - ); + const selection = await config + .getMemoryManager() + .selectForgetCandidates(config.getProjectRoot(), query, { config }); - const result = await forgetManagedAutoMemoryMatches( - config.getProjectRoot(), - selection.matches, - ); + const result = await config + .getMemoryManager() + .forgetMatches(config.getProjectRoot(), selection.matches); return { type: 'message', messageType: 'info', diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 621d495d429..405cda4a967 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -34,6 +34,11 @@ const defaultProps = { model: 'gemini-pro', }; +const createMockMemoryManager = () => ({ + subscribe: vi.fn(() => () => {}), + listTasksByType: vi.fn(() => []), +}); + const createMockConfig = (overrides = {}) => ({ getModel: vi.fn(() => defaultProps.model), getDebugMode: vi.fn(() => false), @@ -41,6 +46,7 @@ const createMockConfig = (overrides = {}) => ({ getMcpServers: vi.fn(() => ({})), getBlockedMcpServers: vi.fn(() => []), getProjectRoot: vi.fn(() => '/test/project'), + getMemoryManager: vi.fn(createMockMemoryManager), ...overrides, }); diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 91335a7b83b..df4eb6eee82 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; @@ -18,47 +18,34 @@ import { useStatusLine } from '../hooks/useStatusLine.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; -import { - ApprovalMode, - globalBackgroundTaskHub, - DREAM_TASK_TYPE, -} from '@qwen-code/qwen-code-core'; +import { ApprovalMode } from '@qwen-code/qwen-code-core'; import { useCompactMode } from '../contexts/CompactModeContext.js'; import { t } from '../../i18n/index.js'; /** - * Subscribes to the dream task registry and returns true while any dream task - * for the current project is in 'pending' or 'running' state. + * Returns true while any dream task for the current project is in + * 'pending' or 'running' state. Uses MemoryManager's subscribe/notify + * mechanism so there is zero polling overhead. */ function useDreamRunning(projectRoot: string): boolean { - const [running, setRunning] = useState(false); - - useEffect(() => { - const registry = globalBackgroundTaskHub.registry; - - function check() { - const tasks = registry.list(projectRoot); - setRunning( - tasks.some( - (t) => - t.taskType === DREAM_TASK_TYPE && - (t.status === 'pending' || t.status === 'running'), - ), - ); - } - - check(); - return registry.subscribe((task) => { - if ( - task.projectRoot === projectRoot && - task.taskType === DREAM_TASK_TYPE - ) { - check(); - } - }); - }, [projectRoot]); + const config = useConfig(); + + const subscribe = useCallback( + (onStoreChange: () => void) => + config.getMemoryManager().subscribe(onStoreChange), + [config], + ); + + const getSnapshot = useCallback( + () => + config + .getMemoryManager() + .listTasksByType('dream', projectRoot) + .some((task) => task.status === 'pending' || task.status === 'running'), + [config, projectRoot], + ); - return running; + return useSyncExternalStore(subscribe, getSnapshot); } export const Footer: React.FC = () => { diff --git a/packages/core/src/background/taskDrainer.test.ts b/packages/core/src/background/taskDrainer.test.ts deleted file mode 100644 index c5ba350e7c1..00000000000 --- a/packages/core/src/background/taskDrainer.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from 'vitest'; -import { BackgroundTaskDrainer } from './taskDrainer.js'; - -describe('BackgroundTaskDrainer', () => { - it('tracks tasks and drains successfully', async () => { - const drainer = new BackgroundTaskDrainer(); - drainer.track('task-1', Promise.resolve('done')); - - await Promise.resolve(); - expect(await drainer.drain()).toBe(true); - expect(drainer.getInFlightTaskIds()).toEqual([]); - }); - - it('returns false when drain times out', async () => { - const drainer = new BackgroundTaskDrainer(); - let resolveTask: (() => void) | undefined; - const blockingPromise = new Promise((resolve) => { - resolveTask = resolve; - }); - - drainer.track('task-1', blockingPromise); - - await expect(drainer.drain({ timeoutMs: 10 })).resolves.toBe(false); - resolveTask?.(); - await expect(drainer.drain()).resolves.toBe(true); - }); -}); diff --git a/packages/core/src/background/taskDrainer.ts b/packages/core/src/background/taskDrainer.ts deleted file mode 100644 index 1d1ac928832..00000000000 --- a/packages/core/src/background/taskDrainer.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -export interface DrainBackgroundTasksOptions { - timeoutMs?: number; -} - -export class BackgroundTaskDrainer { - private readonly inFlight = new Map>(); - - track(taskId: string, promise: Promise): Promise { - this.inFlight.set(taskId, promise); - promise.finally(() => { - this.inFlight.delete(taskId); - }); - return promise; - } - - getInFlightTaskIds(): string[] { - return [...this.inFlight.keys()]; - } - - async drain(options: DrainBackgroundTasksOptions = {}): Promise { - const promises = [...this.inFlight.values()]; - if (promises.length === 0) { - return true; - } - - const waitForTasks = Promise.allSettled(promises).then(() => true); - if (!options.timeoutMs || options.timeoutMs <= 0) { - return waitForTasks; - } - - return Promise.race([ - waitForTasks, - new Promise((resolve) => { - setTimeout(() => resolve(false), options.timeoutMs); - }), - ]); - } -} diff --git a/packages/core/src/background/taskHub.test.ts b/packages/core/src/background/taskHub.test.ts deleted file mode 100644 index dd8c5a0bcea..00000000000 --- a/packages/core/src/background/taskHub.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from 'vitest'; -import { BackgroundTaskHub, globalBackgroundTaskHub } from './taskHub.js'; - -const TYPE_A = 'test-type-a'; -const TYPE_B = 'test-type-b'; - -describe('BackgroundTaskHub', () => { - describe('createScheduler()', () => { - it('returns a scheduler wired to the hub registry and drainer', async () => { - const hub = new BackgroundTaskHub(); - const scheduler = hub.createScheduler(); - - let resolveRun: (() => void) | undefined; - const scheduled = scheduler.schedule({ - taskType: TYPE_A, - title: 'Test task', - projectRoot: '/project', - run: () => - new Promise((resolve) => { - resolveRun = resolve; - }), - }); - - // Task should appear in the shared registry immediately - const pending = hub.registry.get(scheduled.taskId); - expect(pending?.status).toBe('running'); - expect(pending?.taskType).toBe(TYPE_A); - - resolveRun?.(); - await scheduled.promise; - - const completed = hub.registry.get(scheduled.taskId); - expect(completed?.status).toBe('completed'); - }); - - it('each call to createScheduler() returns a distinct scheduler instance sharing the same hub', () => { - const hub = new BackgroundTaskHub(); - const s1 = hub.createScheduler(); - const s2 = hub.createScheduler(); - expect(s1).not.toBe(s2); - }); - }); - - describe('listByType()', () => { - it('returns only tasks matching the given taskType', async () => { - const hub = new BackgroundTaskHub(); - const scheduler = hub.createScheduler(); - - scheduler.schedule({ - taskType: TYPE_A, - title: 'A task', - projectRoot: '/project', - run: async () => {}, - }); - scheduler.schedule({ - taskType: TYPE_B, - title: 'B task', - projectRoot: '/project', - run: async () => {}, - }); - - await hub.drain(); - - const aOnly = hub.listByType(TYPE_A); - expect(aOnly).toHaveLength(1); - expect(aOnly[0]!.taskType).toBe(TYPE_A); - - const bOnly = hub.listByType(TYPE_B); - expect(bOnly).toHaveLength(1); - expect(bOnly[0]!.taskType).toBe(TYPE_B); - }); - - it('filters by projectRoot when provided', async () => { - const hub = new BackgroundTaskHub(); - const scheduler = hub.createScheduler(); - - scheduler.schedule({ - taskType: TYPE_A, - title: 'A in /project1', - projectRoot: '/project1', - run: async () => {}, - }); - scheduler.schedule({ - taskType: TYPE_A, - title: 'A in /project2', - projectRoot: '/project2', - run: async () => {}, - }); - - await hub.drain(); - - expect(hub.listByType(TYPE_A, '/project1')).toHaveLength(1); - expect(hub.listByType(TYPE_A, '/project2')).toHaveLength(1); - expect(hub.listByType(TYPE_A)).toHaveLength(2); - }); - - it('returns an empty array when no tasks of that type exist', () => { - const hub = new BackgroundTaskHub(); - expect(hub.listByType('nonexistent-type')).toEqual([]); - }); - - it('two different task types registered to the same hub are visible in the shared registry but separated by listByType()', async () => { - const hub = new BackgroundTaskHub(); - const sA = hub.createScheduler(); - const sB = hub.createScheduler(); - - sA.schedule({ - taskType: TYPE_A, - title: 'A', - projectRoot: '/p', - run: async () => {}, - }); - sB.schedule({ - taskType: TYPE_B, - title: 'B', - projectRoot: '/p', - run: async () => {}, - }); - - await hub.drain(); - - // Shared registry sees all tasks - expect(hub.registry.list()).toHaveLength(2); - // listByType gives domain-specific filtered views - expect(hub.listByType(TYPE_A)).toHaveLength(1); - expect(hub.listByType(TYPE_B)).toHaveLength(1); - }); - }); - - describe('drain()', () => { - it('resolves true when all in-flight tasks complete', async () => { - const hub = new BackgroundTaskHub(); - const scheduler = hub.createScheduler(); - - let resolveRun: (() => void) | undefined; - scheduler.schedule({ - taskType: TYPE_A, - title: 'Slow task', - projectRoot: '/project', - run: () => - new Promise((resolve) => { - resolveRun = resolve; - }), - }); - - // Drain times out while task is blocked - expect(await hub.drain({ timeoutMs: 10 })).toBe(false); - - resolveRun?.(); - expect(await hub.drain()).toBe(true); - }); - - it('resolves true immediately when no tasks are in flight', async () => { - const hub = new BackgroundTaskHub(); - expect(await hub.drain()).toBe(true); - }); - }); - - describe('isolation between hub instances', () => { - it('tasks registered in one hub do not appear in another hub', async () => { - const hubA = new BackgroundTaskHub(); - const hubB = new BackgroundTaskHub(); - - hubA.createScheduler().schedule({ - taskType: TYPE_A, - title: 'task in A', - projectRoot: '/p', - run: async () => {}, - }); - - await hubA.drain(); - - expect(hubA.listByType(TYPE_A)).toHaveLength(1); - expect(hubB.listByType(TYPE_A)).toHaveLength(0); - expect(hubB.registry.list()).toHaveLength(0); - }); - }); - - describe('globalBackgroundTaskHub', () => { - it('is a BackgroundTaskHub instance', () => { - expect(globalBackgroundTaskHub).toBeInstanceOf(BackgroundTaskHub); - }); - - it('exposes a registry and drainer', () => { - expect(globalBackgroundTaskHub.registry).toBeDefined(); - expect(globalBackgroundTaskHub.drainer).toBeDefined(); - }); - }); -}); diff --git a/packages/core/src/background/taskHub.ts b/packages/core/src/background/taskHub.ts deleted file mode 100644 index d24a695bd8c..00000000000 --- a/packages/core/src/background/taskHub.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * BackgroundTaskHub — a general-purpose container that groups one - * BackgroundTaskRegistry and one BackgroundTaskDrainer into a single - * injectable unit. - * - * Design goals: - * - Zero knowledge of any specific task type (extract, dream, …). - * Task-type constants live in the runtimes that own them. - * - Any future background task runtime plugs in by accepting a - * `hub: BackgroundTaskHub = globalBackgroundTaskHub` parameter — - * no new infrastructure needed. - * - Tests receive a `new BackgroundTaskHub()` for full isolation; - * production runtimes share `globalBackgroundTaskHub`. - */ - -import { BackgroundTaskDrainer } from './taskDrainer.js'; -import type { DrainBackgroundTasksOptions } from './taskDrainer.js'; -import { BackgroundTaskRegistry } from './taskRegistry.js'; -import type { BackgroundTaskState } from './taskRegistry.js'; -import { BackgroundTaskScheduler } from './taskScheduler.js'; - -export class BackgroundTaskHub { - readonly registry: BackgroundTaskRegistry; - readonly drainer: BackgroundTaskDrainer; - - constructor( - registry = new BackgroundTaskRegistry(), - drainer = new BackgroundTaskDrainer(), - ) { - this.registry = registry; - this.drainer = drainer; - } - - /** - * Create a BackgroundTaskScheduler wired to this hub's shared registry and - * drainer. Each runtime that needs deduplication should call this once at - * construction time rather than instantiating a scheduler directly. - */ - createScheduler(): BackgroundTaskScheduler { - return new BackgroundTaskScheduler(this.registry, this.drainer); - } - - /** - * Return all tasks whose `taskType` matches, optionally scoped to a - * projectRoot. Use this to build typed views without coupling the hub to - * any specific task domain. - * - * @example - * hub.listByType(EXTRACT_TASK_TYPE, projectRoot) - * hub.listByType(DREAM_TASK_TYPE) - */ - listByType(taskType: string, projectRoot?: string): BackgroundTaskState[] { - return this.registry - .list(projectRoot) - .filter((t) => t.taskType === taskType); - } - - async drain(options?: DrainBackgroundTasksOptions): Promise { - return this.drainer.drain(options); - } -} - -/** - * Application-wide singleton — shared by all background task runtimes in - * production. Each runtime accepts a hub via constructor injection and defaults - * to this value, so tests can pass a fresh `new BackgroundTaskHub()` without - * touching the global state. - */ -export const globalBackgroundTaskHub = new BackgroundTaskHub(); diff --git a/packages/core/src/background/taskRegistry.test.ts b/packages/core/src/background/taskRegistry.test.ts deleted file mode 100644 index d3e00142e2e..00000000000 --- a/packages/core/src/background/taskRegistry.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from 'vitest'; -import { BackgroundTaskRegistry } from './taskRegistry.js'; - -describe('BackgroundTaskRegistry', () => { - it('registers and updates background tasks', () => { - const registry = new BackgroundTaskRegistry(); - const task = registry.register({ - taskType: 'memory-extract', - title: 'Extract memory', - projectRoot: '/tmp/project', - }); - - expect(task.status).toBe('pending'); - - const updated = registry.update(task.id, { - status: 'running', - progressText: 'Planning patches', - metadata: { attempt: 1 }, - }); - - expect(updated.status).toBe('running'); - expect(updated.progressText).toBe('Planning patches'); - expect(updated.metadata).toEqual({ attempt: 1 }); - }); - - it('emits task snapshots to listeners', () => { - const registry = new BackgroundTaskRegistry(); - const events: string[] = []; - const unsubscribe = registry.subscribe((task) => { - events.push(`${task.status}:${task.title}`); - }); - - const task = registry.register({ - taskType: 'memory-dream', - title: 'Dream memory', - projectRoot: '/tmp/project', - }); - registry.update(task.id, { status: 'completed' }); - unsubscribe(); - registry.update(task.id, { progressText: 'ignored after unsubscribe' }); - - expect(events).toEqual(['pending:Dream memory', 'completed:Dream memory']); - }); -}); diff --git a/packages/core/src/background/taskRegistry.ts b/packages/core/src/background/taskRegistry.ts deleted file mode 100644 index d42407b31ca..00000000000 --- a/packages/core/src/background/taskRegistry.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { randomUUID } from 'node:crypto'; - -export type BackgroundTaskStatus = - | 'pending' - | 'running' - | 'completed' - | 'failed' - | 'cancelled' - | 'skipped'; - -export interface BackgroundTaskState { - id: string; - taskType: string; - title: string; - projectRoot: string; - sessionId?: string; - status: BackgroundTaskStatus; - createdAt: string; - updatedAt: string; - progressText?: string; - error?: string; - dedupeKey?: string; - metadata?: Record; -} - -export interface RegisterBackgroundTaskParams { - id?: string; - taskType: string; - title: string; - projectRoot: string; - sessionId?: string; - dedupeKey?: string; - metadata?: Record; -} - -export type BackgroundTaskListener = (task: BackgroundTaskState) => void; - -export class BackgroundTaskRegistry { - private readonly tasks = new Map(); - private readonly listeners = new Set(); - - register(params: RegisterBackgroundTaskParams): BackgroundTaskState { - const now = new Date().toISOString(); - const task: BackgroundTaskState = { - id: params.id ?? randomUUID(), - taskType: params.taskType, - title: params.title, - projectRoot: params.projectRoot, - sessionId: params.sessionId, - dedupeKey: params.dedupeKey, - metadata: params.metadata, - status: 'pending', - createdAt: now, - updatedAt: now, - }; - this.tasks.set(task.id, task); - this.emit(task); - return task; - } - - get(taskId: string): BackgroundTaskState | undefined { - const task = this.tasks.get(taskId); - return task ? { ...task } : undefined; - } - - list(projectRoot?: string): BackgroundTaskState[] { - return [...this.tasks.values()] - .filter((task) => !projectRoot || task.projectRoot === projectRoot) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) - .map((task) => ({ ...task })); - } - - update( - taskId: string, - patch: Partial>, - ): BackgroundTaskState { - const current = this.tasks.get(taskId); - if (!current) { - throw new Error(`Unknown background task: ${taskId}`); - } - - const next: BackgroundTaskState = { - ...current, - ...patch, - updatedAt: new Date().toISOString(), - metadata: - patch.metadata === undefined - ? current.metadata - : { ...(current.metadata ?? {}), ...patch.metadata }, - }; - this.tasks.set(taskId, next); - this.emit(next); - return { ...next }; - } - - subscribe(listener: BackgroundTaskListener): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - private emit(task: BackgroundTaskState): void { - for (const listener of this.listeners) { - listener({ ...task }); - } - } -} diff --git a/packages/core/src/background/taskScheduler.test.ts b/packages/core/src/background/taskScheduler.test.ts deleted file mode 100644 index e3044d0045d..00000000000 --- a/packages/core/src/background/taskScheduler.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it, vi } from 'vitest'; -import { BackgroundTaskDrainer } from './taskDrainer.js'; -import { BackgroundTaskRegistry } from './taskRegistry.js'; -import { BackgroundTaskScheduler } from './taskScheduler.js'; - -describe('BackgroundTaskScheduler', () => { - it('runs a background task and marks it completed', async () => { - const registry = new BackgroundTaskRegistry(); - const drainer = new BackgroundTaskDrainer(); - const scheduler = new BackgroundTaskScheduler(registry, drainer); - const run = vi.fn().mockResolvedValue({ - progressText: 'Finished extraction', - metadata: { touchedTopics: ['user'] }, - }); - - const scheduled = scheduler.schedule({ - taskType: 'memory-extract', - title: 'Extract memory', - projectRoot: '/tmp/project', - run, - }); - const finalTask = await scheduled.promise; - - expect(run).toHaveBeenCalledTimes(1); - expect(finalTask.status).toBe('completed'); - expect(finalTask.progressText).toBe('Finished extraction'); - expect(finalTask.metadata).toEqual({ touchedTopics: ['user'] }); - expect(await drainer.drain()).toBe(true); - }); - - it('skips duplicate tasks that share a dedupe key while one is running', async () => { - const registry = new BackgroundTaskRegistry(); - const drainer = new BackgroundTaskDrainer(); - const scheduler = new BackgroundTaskScheduler(registry, drainer); - - let resolveFirst: (() => void) | undefined; - const first = scheduler.schedule({ - taskType: 'memory-dream', - title: 'Dream memory', - projectRoot: '/tmp/project', - dedupeKey: 'dream:/tmp/project', - run: () => - new Promise((resolve) => { - resolveFirst = resolve; - }), - }); - - const second = scheduler.schedule({ - taskType: 'memory-dream', - title: 'Dream memory duplicate', - projectRoot: '/tmp/project', - dedupeKey: 'dream:/tmp/project', - run: vi.fn(), - }); - - const skippedTask = await second.promise; - expect(skippedTask.status).toBe('skipped'); - expect(skippedTask.metadata).toEqual( - expect.objectContaining({ - skippedBecauseOf: first.taskId, - }), - ); - - resolveFirst?.(); - const completedTask = await first.promise; - expect(completedTask.status).toBe('completed'); - }); -}); diff --git a/packages/core/src/background/taskScheduler.ts b/packages/core/src/background/taskScheduler.ts deleted file mode 100644 index 6f38ee04040..00000000000 --- a/packages/core/src/background/taskScheduler.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - BackgroundTaskRegistry, - BackgroundTaskStatus, - BackgroundTaskState, -} from './taskRegistry.js'; -import type { BackgroundTaskDrainer } from './taskDrainer.js'; - -export interface ScheduleBackgroundTaskParams { - taskType: string; - title: string; - projectRoot: string; - sessionId?: string; - dedupeKey?: string; - metadata?: Record; - run: (task: BackgroundTaskState) => Promise<{ - status?: BackgroundTaskStatus; - progressText?: string; - error?: string; - metadata?: Record; - } | void>; -} - -export interface ScheduledBackgroundTask { - taskId: string; - promise: Promise; -} - -export class BackgroundTaskScheduler { - private readonly inFlightByDedupeKey = new Map(); - - constructor( - private readonly registry: BackgroundTaskRegistry, - private readonly drainer: BackgroundTaskDrainer, - ) {} - - schedule(params: ScheduleBackgroundTaskParams): ScheduledBackgroundTask { - if (params.dedupeKey) { - const existingTaskId = this.inFlightByDedupeKey.get(params.dedupeKey); - if (existingTaskId) { - const skipped = this.registry.register({ - taskType: params.taskType, - title: params.title, - projectRoot: params.projectRoot, - sessionId: params.sessionId, - dedupeKey: params.dedupeKey, - metadata: { - ...(params.metadata ?? {}), - skippedBecauseOf: existingTaskId, - }, - }); - this.registry.update(skipped.id, { - status: 'skipped', - progressText: `Skipped duplicate background task; existing task ${existingTaskId} is still running.`, - }); - return { - taskId: skipped.id, - promise: Promise.resolve( - this.registry.get(skipped.id) as BackgroundTaskState, - ), - }; - } - } - - const task = this.registry.register({ - taskType: params.taskType, - title: params.title, - projectRoot: params.projectRoot, - sessionId: params.sessionId, - dedupeKey: params.dedupeKey, - metadata: params.metadata, - }); - this.registry.update(task.id, { status: 'running' }); - if (params.dedupeKey) { - this.inFlightByDedupeKey.set(params.dedupeKey, task.id); - } - - const promise = this.drainer.track( - task.id, - (async () => { - try { - const result = await params.run( - this.registry.get(task.id) as BackgroundTaskState, - ); - const finalTask = this.registry.update(task.id, { - status: result?.status ?? 'completed', - progressText: result?.progressText, - error: result?.error, - metadata: result?.metadata, - }); - return finalTask; - } catch (error) { - return this.registry.update(task.id, { - status: 'failed', - error: error instanceof Error ? error.message : String(error), - }); - } finally { - if (params.dedupeKey) { - this.inFlightByDedupeKey.delete(params.dedupeKey); - } - } - })(), - ); - - return { - taskId: task.id, - promise, - }; - } -} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 660d4a5f64a..14c116d4531 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -137,7 +137,7 @@ import { } from '../utils/debugLogger.js'; import { getAutoMemoryRoot } from '../memory/paths.js'; import { readAutoMemoryIndex } from '../memory/store.js'; -import { appendManagedAutoMemoryToUserMemory } from '../memory/prompt.js'; +import { MemoryManager } from '../memory/manager.js'; import { ModelsConfig, @@ -643,6 +643,7 @@ export class Config { private readonly hooks?: Record; private hookSystem?: HookSystem; private messageBus?: MessageBus; + private readonly memoryManager: MemoryManager; constructor(params: ConfigParameters) { this.sessionId = params.sessionId ?? randomUUID(); @@ -819,6 +820,7 @@ export class Config { this.fastModel = params.fastModel || undefined; this.disableAllHooks = params.disableAllHooks ?? false; this.hooks = params.hooks; + this.memoryManager = new MemoryManager(); } /** @@ -1061,7 +1063,7 @@ export class Config { this.getProjectRoot(), ); this.setUserMemory( - appendManagedAutoMemoryToUserMemory( + this.memoryManager.appendToUserMemory( memoryContent, getAutoMemoryRoot(this.getProjectRoot()), managedAutoMemoryIndex, @@ -1965,6 +1967,16 @@ export class Config { return this.enableManagedAutoDream; } + /** + * Return the MemoryManager instance created for this Config. + * Use this to share background-task state (registry, drainer) with memory + * module runtimes (extract, dream) instead of relying on module-level + * globals. + */ + getMemoryManager(): MemoryManager { + return this.memoryManager; + } + /** * Get the message bus instance. * Returns undefined if not set. diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 849c376c155..2beb6d21777 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -38,9 +38,6 @@ import { promptIdContext } from '../utils/promptIdContext.js'; import { setSimulate429 } from '../utils/testUtils.js'; import { ideContextStore } from '../ide/ideContext.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; -import { scheduleAutoMemoryExtract } from '../memory/extract.js'; -import { scheduleManagedAutoMemoryDream } from '../memory/dreamScheduler.js'; -import { resolveRelevantAutoMemoryPromptForQuery } from '../memory/recall.js'; // Mock fs module to prevent actual file system operations during tests const mockFileSystem = new Map(); @@ -93,26 +90,6 @@ vi.mock('./turn', async (importOriginal) => { vi.mock('../config/config.js'); vi.mock('./prompts'); -vi.mock('../memory/extract.js', () => ({ - scheduleAutoMemoryExtract: vi.fn().mockResolvedValue({ - patches: [], - touchedTopics: [], - cursor: { updatedAt: new Date(0).toISOString() }, - }), -})); -vi.mock('../memory/dreamScheduler.js', () => ({ - scheduleManagedAutoMemoryDream: vi.fn().mockResolvedValue({ - status: 'skipped', - skippedReason: 'min_sessions', - }), -})); -vi.mock('../memory/recall.js', () => ({ - resolveRelevantAutoMemoryPromptForQuery: vi.fn().mockResolvedValue({ - prompt: '', - selectedDocs: [], - strategy: 'none', - }), -})); vi.mock('../utils/getFolderStructure', () => ({ getFolderStructure: vi.fn().mockResolvedValue('Mock Folder Structure'), })); @@ -291,22 +268,30 @@ describe('Gemini Client (client.ts)', () => { let mockConfig: Config; let client: GeminiClient; let mockGenerateContentFn: Mock; + let mockMemoryManager: { + scheduleExtract: ReturnType; + scheduleDream: ReturnType; + recall: ReturnType; + }; beforeEach(async () => { vi.resetAllMocks(); vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear(); - vi.mocked(resolveRelevantAutoMemoryPromptForQuery).mockResolvedValue({ - prompt: '', - selectedDocs: [], - strategy: 'none', - }); - vi.mocked(scheduleAutoMemoryExtract).mockResolvedValue({ - touchedTopics: [], - cursor: { updatedAt: new Date(0).toISOString() }, - }); - vi.mocked(scheduleManagedAutoMemoryDream).mockResolvedValue({ - status: 'skipped', - skippedReason: 'min_sessions', - }); + + mockMemoryManager = { + scheduleExtract: vi.fn().mockResolvedValue({ + touchedTopics: [], + cursor: { updatedAt: new Date(0).toISOString() }, + }), + scheduleDream: vi.fn().mockResolvedValue({ + status: 'skipped', + skippedReason: 'min_sessions', + }), + recall: vi.fn().mockResolvedValue({ + prompt: '', + selectedDocs: [], + strategy: 'none', + }), + }; mockGenerateContentFn = vi.fn().mockResolvedValue({ candidates: [{ content: { parts: [{ text: '{"key": "value"}' }] } }], @@ -402,6 +387,7 @@ describe('Gemini Client (client.ts)', () => { getResumedSessionData: vi.fn().mockReturnValue(undefined), getArenaAgentClient: vi.fn().mockReturnValue(null), getManagedAutoMemoryEnabled: vi.fn().mockReturnValue(true), + getMemoryManager: vi.fn().mockReturnValue(mockMemoryManager), getDisableAllHooks: vi.fn().mockReturnValue(true), getArenaManager: vi.fn().mockReturnValue(null), getMessageBus: vi.fn().mockReturnValue(undefined), @@ -1453,7 +1439,7 @@ hello }); it('should prepend relevant managed auto-memory prompt when recall returns content', async () => { - vi.mocked(resolveRelevantAutoMemoryPromptForQuery).mockResolvedValue({ + mockMemoryManager.recall.mockResolvedValue({ prompt: '## Relevant memory\n\nUser prefers terse responses.', selectedDocs: [ { @@ -1491,7 +1477,7 @@ hello // consume stream } - expect(resolveRelevantAutoMemoryPromptForQuery).toHaveBeenCalledWith( + expect(mockMemoryManager.recall).toHaveBeenCalledWith( '/test/project/root', 'Please answer tersely', expect.objectContaining({ @@ -1510,7 +1496,7 @@ hello }); it('should track surfaced managed memory paths across user queries', async () => { - vi.mocked(resolveRelevantAutoMemoryPromptForQuery) + mockMemoryManager.recall .mockResolvedValueOnce({ prompt: '## Relevant memory\n\nUser prefers terse responses.', selectedDocs: [ @@ -1563,7 +1549,7 @@ hello // consume stream } - expect(resolveRelevantAutoMemoryPromptForQuery).toHaveBeenNthCalledWith( + expect(mockMemoryManager.recall).toHaveBeenNthCalledWith( 2, '/test/project/root', 'Keep it short again', @@ -1576,7 +1562,7 @@ hello }); it('should run managed auto-memory extraction after a completed user query', async () => { - vi.mocked(scheduleAutoMemoryExtract).mockResolvedValue({ + mockMemoryManager.scheduleExtract.mockResolvedValue({ touchedTopics: ['user'], cursor: { sessionId: 'test-session-id', @@ -1611,13 +1597,13 @@ hello const recordedHistory = mockChat.getHistory?.(); - expect(scheduleAutoMemoryExtract).toHaveBeenCalledWith({ + expect(mockMemoryManager.scheduleExtract).toHaveBeenCalledWith({ projectRoot: '/test/project/root', sessionId: 'test-session-id', history: recordedHistory, config: mockConfig, }); - expect(scheduleManagedAutoMemoryDream).toHaveBeenCalledWith({ + expect(mockMemoryManager.scheduleDream).toHaveBeenCalledWith({ projectRoot: '/test/project/root', sessionId: 'test-session-id', config: mockConfig, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index ed70c7ac24f..b8a23b0d339 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -48,12 +48,7 @@ import { LoopDetectionService } from '../services/loopDetectionService.js'; // Tools import { AgentTool } from '../tools/agent.js'; -import { scheduleAutoMemoryExtract } from '../memory/extract.js'; -import { scheduleManagedAutoMemoryDream } from '../memory/dreamScheduler.js'; -import { - type RelevantAutoMemoryPromptResult, - resolveRelevantAutoMemoryPromptForQuery, -} from '../memory/recall.js'; +import type { RelevantAutoMemoryPromptResult } from '../memory/manager.js'; // Telemetry import { @@ -66,7 +61,7 @@ import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { saveCacheSafeParams, clearCacheSafeParams, -} from '../background/forkedAgent.js'; +} from '../utils/forkedAgent.js'; // Utilities import { @@ -524,15 +519,17 @@ export class GeminiClient { const projectRoot = this.config.getProjectRoot(); const sessionId = this.config.getSessionId(); const history = this.getHistory(); + const mgr = this.config.getMemoryManager(); - const extractPromise = scheduleAutoMemoryExtract({ - projectRoot, - sessionId, - history, - config: this.config, - }) + const extractPromise = mgr + .scheduleExtract({ + projectRoot, + sessionId, + history, + config: this.config, + }) .then((result) => result.touchedTopics.length) - .catch((error) => { + .catch((error: unknown) => { debugLogger.warn( 'Failed to schedule managed auto-memory extraction.', error, @@ -541,11 +538,12 @@ export class GeminiClient { }); this.pendingMemoryTaskPromises.push(extractPromise); - const dreamPromise = scheduleManagedAutoMemoryDream({ - projectRoot, - sessionId, - config: this.config, - }) + const dreamPromise = mgr + .scheduleDream({ + projectRoot, + sessionId, + config: this.config, + }) .then((schedResult) => { if (schedResult.status === 'scheduled' && schedResult.promise) { return schedResult.promise.then((state) => { @@ -557,7 +555,7 @@ export class GeminiClient { } return 0; }) - .catch((error) => { + .catch((error: unknown) => { debugLogger.warn( 'Failed to schedule managed auto-memory dream.', error, @@ -587,9 +585,7 @@ export class GeminiClient { ): AsyncGenerator { const messageType = options?.type ?? SendMessageType.UserQuery; let relevantAutoMemoryPromise: - | Promise< - Awaited> - > + | Promise | undefined; if (messageType === SendMessageType.Retry) { @@ -654,20 +650,19 @@ export class GeminiClient { this.lastPromptId = prompt_id; if (this.config.getManagedAutoMemoryEnabled()) { - relevantAutoMemoryPromise = resolveRelevantAutoMemoryPromptForQuery( - this.config.getProjectRoot(), - partToString(request), - { + relevantAutoMemoryPromise = this.config + .getMemoryManager() + .recall(this.config.getProjectRoot(), partToString(request), { config: this.config, excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, - }, - ).catch((error) => { - debugLogger.warn( - 'Managed auto-memory recall prefetch failed.', - error, - ); - return EMPTY_RELEVANT_AUTO_MEMORY_RESULT; - }); + }) + .catch((error: unknown) => { + debugLogger.warn( + 'Managed auto-memory recall prefetch failed.', + error, + ); + return EMPTY_RELEVANT_AUTO_MEMORY_RESULT; + }); } // record user message for session management diff --git a/packages/core/src/followup/smoke.test.ts b/packages/core/src/followup/smoke.test.ts index d089ab33abd..e93151bca0a 100644 --- a/packages/core/src/followup/smoke.test.ts +++ b/packages/core/src/followup/smoke.test.ts @@ -17,7 +17,7 @@ import { saveCacheSafeParams, getCacheSafeParams, clearCacheSafeParams, -} from '../background/forkedAgent.js'; +} from '../utils/forkedAgent.js'; import { ensureToolResultPairing } from './speculation.js'; import { ToolNames } from '../tools/tool-names.js'; import { ApprovalMode } from '../config/config.js'; diff --git a/packages/core/src/followup/speculation.ts b/packages/core/src/followup/speculation.ts index 050d127c206..d8450f1b446 100644 --- a/packages/core/src/followup/speculation.ts +++ b/packages/core/src/followup/speculation.ts @@ -25,7 +25,7 @@ import { getCacheSafeParams, createForkedChat, runForkedAgent, -} from '../background/forkedAgent.js'; +} from '../utils/forkedAgent.js'; import { getFilterReason, SUGGESTION_PROMPT } from './suggestionGenerator.js'; // --------------------------------------------------------------------------- @@ -197,7 +197,7 @@ interface LoopResult { async function runSpeculativeLoop( config: Config, state: SpeculationState, - cacheSafe: import('../background/forkedAgent.js').CacheSafeParams, + cacheSafe: import('../utils/forkedAgent.js').CacheSafeParams, modelOverride?: string, ): Promise { const chat = createForkedChat(config, cacheSafe); diff --git a/packages/core/src/followup/suggestionGenerator.ts b/packages/core/src/followup/suggestionGenerator.ts index e90d6303d34..ac977aece4f 100644 --- a/packages/core/src/followup/suggestionGenerator.ts +++ b/packages/core/src/followup/suggestionGenerator.ts @@ -11,10 +11,7 @@ import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; -import { - getCacheSafeParams, - runForkedAgent, -} from '../background/forkedAgent.js'; +import { getCacheSafeParams, runForkedAgent } from '../utils/forkedAgent.js'; import { uiTelemetryService, EVENT_API_RESPONSE, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c81ee1a848e..5cf6d072816 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,32 +118,17 @@ export * from './services/shellExecutionService.js'; // Managed Auto-Memory // ============================================================================ -export * from './auxiliary/sideQuery.js'; -export * from './background/taskRegistry.js'; -export * from './background/taskDrainer.js'; -export * from './background/taskScheduler.js'; -export * from './background/taskHub.js'; -export * from './background/forkedAgent.js'; +// MemoryManager is the single public API for all memory operations. +// Production code: config.getMemoryManager().method(...) +// Tests: new MemoryManager() +export * from './memory/manager.js'; +// Foundational utilities (paths, storage scaffold, type definitions, constants) +// that are legitimately needed by UI code (MemoryDialog, commands, etc.) export * from './memory/types.js'; export * from './memory/paths.js'; export * from './memory/store.js'; -export * from './memory/entries.js'; -export * from './memory/indexer.js'; -export * from './memory/prompt.js'; -export * from './memory/state.js'; -export * from './memory/extractionAgentPlanner.js'; -export * from './memory/extract.js'; -export * from './memory/extractScheduler.js'; -export * from './memory/dreamAgentPlanner.js'; -export * from './memory/dream.js'; -export * from './memory/dreamScheduler.js'; -export * from './memory/scan.js'; -export * from './memory/relevanceSelector.js'; -export * from './memory/recall.js'; -export * from './memory/forget.js'; -export * from './memory/governance.js'; -export * from './memory/status.js'; +export * from './memory/const.js'; // ============================================================================ // IDE Support @@ -282,6 +267,8 @@ export * from './utils/toml-to-markdown-converter.js'; export * from './utils/tool-utils.js'; export * from './utils/workspaceContext.js'; export * from './utils/yaml-parser.js'; +export * from './utils/forkedAgent.js'; +export * from './utils/sideQuery.js'; // ============================================================================ // OAuth & Authentication diff --git a/packages/core/src/memory/dreamAgentPlanner.test.ts b/packages/core/src/memory/dreamAgentPlanner.test.ts index 81f2a3b7713..edf8fcdb9d1 100644 --- a/packages/core/src/memory/dreamAgentPlanner.test.ts +++ b/packages/core/src/memory/dreamAgentPlanner.test.ts @@ -9,12 +9,12 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import type { ForkedAgentResult } from '../background/forkedAgent.js'; -import { runForkedAgent } from '../background/forkedAgent.js'; +import type { ForkedAgentResult } from '../utils/forkedAgent.js'; +import { runForkedAgent } from '../utils/forkedAgent.js'; import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; import { ensureAutoMemoryScaffold } from './store.js'; -vi.mock('../background/forkedAgent.js', () => ({ +vi.mock('../utils/forkedAgent.js', () => ({ runForkedAgent: vi.fn(), })); diff --git a/packages/core/src/memory/dreamAgentPlanner.ts b/packages/core/src/memory/dreamAgentPlanner.ts index d3880d5fac9..8a61b619f6c 100644 --- a/packages/core/src/memory/dreamAgentPlanner.ts +++ b/packages/core/src/memory/dreamAgentPlanner.ts @@ -8,7 +8,7 @@ import type { Config } from '../config/config.js'; import { runForkedAgent, type ForkedAgentResult, -} from '../background/forkedAgent.js'; +} from '../utils/forkedAgent.js'; import { getProjectHash, QWEN_DIR } from '../utils/paths.js'; import { AUTO_MEMORY_INDEX_FILENAME, diff --git a/packages/core/src/memory/dreamScheduler.test.ts b/packages/core/src/memory/dreamScheduler.test.ts deleted file mode 100644 index 46f59a6657c..00000000000 --- a/packages/core/src/memory/dreamScheduler.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - getAutoMemoryConsolidationLockPath, - getAutoMemoryMetadataPath, -} from './paths.js'; - -vi.mock('./dream.js', () => ({ - runManagedAutoMemoryDream: vi.fn(), -})); - -import { runManagedAutoMemoryDream } from './dream.js'; -import { - createManagedAutoMemoryDreamRuntimeForTests, - DEFAULT_AUTO_DREAM_MIN_HOURS, - DREAM_TASK_TYPE, - type SessionScannerFn, -} from './dreamScheduler.js'; -import { ensureAutoMemoryScaffold } from './store.js'; - -/** - * Creates a simple in-memory session scanner for tests. - * Returns session IDs from `sessions` that are not in `excluded`. - */ -function makeSessionScanner(sessions: string[]): SessionScannerFn { - return async (_projectRoot, _sinceMs, excludeSessionId) => - sessions.filter((id) => id !== excludeSessionId); -} - -describe('managed auto-memory dream scheduler', () => { - let tempDir: string; - let projectRoot: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'auto-memory-dream-scheduler-'), - ); - projectRoot = path.join(tempDir, 'project'); - await fs.mkdir(projectRoot, { recursive: true }); - await ensureAutoMemoryScaffold( - projectRoot, - new Date('2026-04-01T00:00:00.000Z'), - ); - // Default: dream succeeds with no touched topics - vi.mocked(runManagedAutoMemoryDream).mockReset(); - vi.mocked(runManagedAutoMemoryDream).mockResolvedValue({ - touchedTopics: [], - dedupedEntries: 0, - systemMessage: undefined, - }); - }); - - afterEach(async () => { - await fs.rm(tempDir, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 10, - }); - }); - - it('waits for enough distinct sessions before scheduling dream', async () => { - // Start with one session in the scanner; first call should skip (need 2) - const knownSessions = ['session-0']; - const runtime = createManagedAutoMemoryDreamRuntimeForTests( - makeSessionScanner(knownSessions), - ); - - const first = await runtime.schedule({ - projectRoot, - sessionId: 'session-1', - now: new Date('2026-04-01T10:00:00.000Z'), - minHoursBetweenDreams: 0, - minSessionsBetweenDreams: 2, - }); - expect(first).toEqual({ - status: 'skipped', - skippedReason: 'min_sessions', - }); - - // Add a second session so the count reaches the threshold - knownSessions.push('session-00'); - const runtime2 = createManagedAutoMemoryDreamRuntimeForTests( - makeSessionScanner(knownSessions), - ); - - const second = await runtime2.schedule({ - projectRoot, - sessionId: 'session-2', - now: new Date('2026-04-01T11:00:00.000Z'), - minHoursBetweenDreams: 0, - minSessionsBetweenDreams: 2, - }); - - expect(second.status).toBe('scheduled'); - await second.promise; - - const metadata = JSON.parse( - await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'), - ) as { lastDreamAt?: string; lastDreamSessionId?: string }; - - expect(metadata.lastDreamSessionId).toBe('session-2'); - expect(metadata.lastDreamAt).toBe('2026-04-01T11:00:00.000Z'); - await expect( - fs.access(getAutoMemoryConsolidationLockPath(projectRoot)), - ).rejects.toThrow(); - }); - - it('skips dream in the same session after a successful run', async () => { - const runtime = createManagedAutoMemoryDreamRuntimeForTests( - makeSessionScanner(['session-0']), - ); - - const scheduled = await runtime.schedule({ - projectRoot, - sessionId: 'session-1', - now: new Date('2026-04-01T10:00:00.000Z'), - minHoursBetweenDreams: 0, - minSessionsBetweenDreams: 1, - }); - await scheduled.promise; - - const skipped = await runtime.schedule({ - projectRoot, - sessionId: 'session-1', - now: new Date('2026-04-01T12:00:00.000Z'), - minHoursBetweenDreams: 0, - minSessionsBetweenDreams: 1, - }); - - expect(skipped).toEqual({ - status: 'skipped', - skippedReason: 'same_session', - }); - }); - - it('skips dream when consolidation lock already exists', async () => { - const runtime = createManagedAutoMemoryDreamRuntimeForTests( - makeSessionScanner(['session-0']), - ); - // Write our own PID so isProcessRunning() considers the lock live. - await fs.writeFile( - getAutoMemoryConsolidationLockPath(projectRoot), - String(process.pid), - 'utf-8', - ); - - const result = await runtime.schedule({ - projectRoot, - sessionId: 'session-2', - now: new Date( - `2026-04-0${DEFAULT_AUTO_DREAM_MIN_HOURS > 0 ? '2' : '1'}T12:00:00.000Z`, - ), - minHoursBetweenDreams: 0, - minSessionsBetweenDreams: 1, - }); - - expect(result).toEqual({ - status: 'skipped', - skippedReason: 'locked', - }); - }); - - it('propagates dream result to task metadata and releases lock on completion', async () => { - vi.mocked(runManagedAutoMemoryDream).mockResolvedValue({ - touchedTopics: ['user'], - dedupedEntries: 2, - systemMessage: 'Dream agent consolidated 2 entries.', - }); - - const runtime = createManagedAutoMemoryDreamRuntimeForTests( - makeSessionScanner(['session-0']), - ); - const result = await runtime.schedule({ - projectRoot, - sessionId: 'session-1', - now: new Date('2026-04-01T10:00:00.000Z'), - minHoursBetweenDreams: 0, - minSessionsBetweenDreams: 1, - }); - - expect(result.status).toBe('scheduled'); - const finalTask = await result.promise; - - // Task should complete successfully - expect(finalTask?.status).toBe('completed'); - // Scheduler propagates dream result to task metadata - expect(finalTask?.metadata).toEqual( - expect.objectContaining({ - touchedTopics: ['user'], - dedupedEntries: 2, - }), - ); - // Lock must be released after completion - await expect( - fs.access(getAutoMemoryConsolidationLockPath(projectRoot)), - ).rejects.toThrow(); - // Metadata must record the session and timestamp - const metadata = JSON.parse( - await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'), - ) as { lastDreamSessionId?: string; lastDreamAt?: string }; - expect(metadata.lastDreamSessionId).toBe('session-1'); - expect(metadata.lastDreamAt).toBe('2026-04-01T10:00:00.000Z'); - }); - - it('test runtimes created by the factory have isolated task registries', async () => { - const runtimeA = createManagedAutoMemoryDreamRuntimeForTests( - makeSessionScanner(['session-0']), - ); - const runtimeB = createManagedAutoMemoryDreamRuntimeForTests( - makeSessionScanner(['session-0']), - ); - - const result = await runtimeA.schedule({ - projectRoot, - sessionId: 'session-1', - now: new Date('2026-04-01T10:00:00.000Z'), - minHoursBetweenDreams: 0, - minSessionsBetweenDreams: 1, - }); - expect(result.status).toBe('scheduled'); - await result.promise; - - // runtimeA has the dream task; runtimeB registry is completely empty - const tasksA = runtimeA.registry.list(); - expect(tasksA.length).toBeGreaterThan(0); - expect(tasksA.every((t) => t.taskType === DREAM_TASK_TYPE)).toBe(true); - expect(runtimeB.registry.list()).toHaveLength(0); - }); -}); diff --git a/packages/core/src/memory/dreamScheduler.ts b/packages/core/src/memory/dreamScheduler.ts deleted file mode 100644 index d7bda7c2d46..00000000000 --- a/packages/core/src/memory/dreamScheduler.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import type { Config } from '../config/config.js'; -import { Storage } from '../config/storage.js'; -import type { DrainBackgroundTasksOptions } from '../background/taskDrainer.js'; -import type { BackgroundTaskDrainer } from '../background/taskDrainer.js'; -import type { - BackgroundTaskRegistry, - BackgroundTaskState, -} from '../background/taskRegistry.js'; -import type { BackgroundTaskScheduler } from '../background/taskScheduler.js'; -import { - BackgroundTaskHub, - globalBackgroundTaskHub, -} from '../background/taskHub.js'; -import { - getAutoMemoryConsolidationLockPath, - getAutoMemoryMetadataPath, -} from './paths.js'; -import { ensureAutoMemoryScaffold } from './store.js'; -import { runManagedAutoMemoryDream } from './dream.js'; -import type { AutoMemoryMetadata } from './types.js'; - -export const DEFAULT_AUTO_DREAM_MIN_HOURS = 24; -export const DEFAULT_AUTO_DREAM_MIN_SESSIONS = 5; -/** Maximum age before a lock is reclaimed even if the PID appears live (PID-reuse guard). */ -const DREAM_LOCK_STALE_MS = 60 * 60 * 1000; // 1 hour (same as CC) -/** Minimum interval between session-count filesystem scans when time-gate is open. */ -const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes (same as CC) - -/** - * Returns true if the given process ID is currently alive. - * Uses kill(pid, 0) — no signal sent, just existence check. - */ -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -export interface ScheduleManagedAutoMemoryDreamParams { - projectRoot: string; - sessionId: string; - config?: Config; - now?: Date; - minHoursBetweenDreams?: number; - minSessionsBetweenDreams?: number; -} - -export interface ManagedAutoMemoryDreamScheduleResult { - status: 'scheduled' | 'skipped'; - taskId?: string; - skippedReason?: - | 'disabled' - | 'same_session' - | 'min_hours' - | 'min_sessions' - | 'locked' - | 'running'; - promise?: Promise; -} - -async function readDreamMetadata( - projectRoot: string, -): Promise { - const content = await fs.readFile( - getAutoMemoryMetadataPath(projectRoot), - 'utf-8', - ); - return JSON.parse(content) as AutoMemoryMetadata; -} - -async function writeDreamMetadata( - projectRoot: string, - metadata: AutoMemoryMetadata, -): Promise { - await fs.writeFile( - getAutoMemoryMetadataPath(projectRoot), - `${JSON.stringify(metadata, null, 2)}\n`, - 'utf-8', - ); -} - -function hoursSince(lastDreamAt: string | undefined, now: Date): number | null { - if (!lastDreamAt) { - return null; - } - const timestamp = Date.parse(lastDreamAt); - if (Number.isNaN(timestamp)) { - return null; - } - return (now.getTime() - timestamp) / (1000 * 60 * 60); -} - -/** Pattern matching session JSONL files: .jsonl */ -const SESSION_FILE_PATTERN = /^[0-9a-fA-F-]{32,36}\.jsonl$/; - -/** - * Returns session IDs whose transcript files have mtime after sinceMs. - * Uses filesystem mtime as ground truth, immune to meta.json corruption or loss. - * Caller should exclude the current session (its mtime is always recent). - */ -async function listSessionsTouchedSince( - projectRoot: string, - sinceMs: number, - excludeSessionId: string, -): Promise { - const chatsDir = path.join(new Storage(projectRoot).getProjectDir(), 'chats'); - let names: string[]; - try { - names = await fs.readdir(chatsDir); - } catch { - return []; - } - const results: string[] = []; - await Promise.all( - names.map(async (name) => { - if (!SESSION_FILE_PATTERN.test(name)) return; - const sessionId = name.slice(0, -'.jsonl'.length); - if (sessionId === excludeSessionId) return; - try { - const stats = await fs.stat(path.join(chatsDir, name)); - if (stats.mtimeMs > sinceMs) { - results.push(sessionId); - } - } catch { - // Skip files we cannot stat - } - }), - ); - return results; -} - -async function lockExists(projectRoot: string): Promise { - const lockPath = getAutoMemoryConsolidationLockPath(projectRoot); - let mtimeMs: number; - let holderPid: number | undefined; - try { - const [stats, content] = await Promise.all([ - fs.stat(lockPath), - fs.readFile(lockPath, 'utf-8').catch(() => ''), - ]); - mtimeMs = stats.mtimeMs; - const parsed = parseInt(content.trim(), 10); - holderPid = Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; - } catch { - return false; // ENOENT — no lock - } - - const ageMs = Date.now() - mtimeMs; - - // Within stale threshold: check if the holder PID is still alive. - if (ageMs <= DREAM_LOCK_STALE_MS) { - if (holderPid !== undefined && isProcessRunning(holderPid)) { - return true; // live holder - } - // Dead PID or unparseable body — reclaim the stale lock immediately. - await fs.rm(lockPath, { force: true }); - return false; - } - - // Past stale threshold regardless of PID (PID-reuse guard). - await fs.rm(lockPath, { force: true }); - return false; -} - -async function acquireDreamLock(projectRoot: string): Promise { - // Write our PID so lockExists() can detect whether we're still alive. - await fs.writeFile( - getAutoMemoryConsolidationLockPath(projectRoot), - String(process.pid), - { flag: 'wx' }, // exclusive create — throws EEXIST if already locked - ); -} - -async function releaseDreamLock(projectRoot: string): Promise { - await fs.rm(getAutoMemoryConsolidationLockPath(projectRoot), { - force: true, - }); -} - -/** Function type for scanning session files by mtime. Injected for testing. */ -export type SessionScannerFn = ( - projectRoot: string, - sinceMs: number, - excludeSessionId: string, -) => Promise; - -export const DREAM_TASK_TYPE = 'managed-auto-memory-dream' as const; - -export class ManagedAutoMemoryDreamRuntime { - readonly registry: BackgroundTaskRegistry; - readonly drainer: BackgroundTaskDrainer; - readonly scheduler: BackgroundTaskScheduler; - - constructor( - private readonly sessionScanner: SessionScannerFn = listSessionsTouchedSince, - hub: BackgroundTaskHub = globalBackgroundTaskHub, - ) { - this.registry = hub.registry; - this.drainer = hub.drainer; - this.scheduler = hub.createScheduler(); - } - /** - * Timestamp (ms) of the last session-count filesystem scan per project root. - * When the time-gate passes but session-count doesn't, we'd otherwise re-scan - * every turn. Throttle to SESSION_SCAN_INTERVAL_MS (10 min). - */ - private lastSessionScanAt = new Map(); - - async schedule( - params: ScheduleManagedAutoMemoryDreamParams, - ): Promise { - if (params.config && !params.config.getManagedAutoDreamEnabled()) { - return { - status: 'skipped', - skippedReason: 'disabled', - }; - } - const now = params.now ?? new Date(); - const minHoursBetweenDreams = - params.minHoursBetweenDreams ?? DEFAULT_AUTO_DREAM_MIN_HOURS; - const minSessionsBetweenDreams = - params.minSessionsBetweenDreams ?? DEFAULT_AUTO_DREAM_MIN_SESSIONS; - - await ensureAutoMemoryScaffold(params.projectRoot, now); - const metadata = await readDreamMetadata(params.projectRoot); - - if (metadata.lastDreamSessionId === params.sessionId) { - return { - status: 'skipped', - skippedReason: 'same_session', - }; - } - - const elapsedHours = hoursSince(metadata.lastDreamAt, now); - if (elapsedHours !== null && elapsedHours < minHoursBetweenDreams) { - return { - status: 'skipped', - skippedReason: 'min_hours', - }; - } - - // Scan throttle: when the time-gate passes but the session-gate hasn't, we'd - // re-scan the session set on every turn. Throttle to SESSION_SCAN_INTERVAL_MS. - const lastScan = this.lastSessionScanAt.get(params.projectRoot) ?? 0; - const sinceScanMs = now.getTime() - lastScan; - if (sinceScanMs < SESSION_SCAN_INTERVAL_MS) { - return { - status: 'skipped', - skippedReason: 'min_sessions', - }; - } - this.lastSessionScanAt.set(params.projectRoot, now.getTime()); - - // Scan session files by mtime (filesystem ground truth, immune to meta.json loss). - const lastDreamMs = metadata.lastDreamAt - ? Date.parse(metadata.lastDreamAt) - : 0; - const sessionIds = await this.sessionScanner( - params.projectRoot, - lastDreamMs, - params.sessionId, - ); - if (sessionIds.length < minSessionsBetweenDreams) { - return { - status: 'skipped', - skippedReason: 'min_sessions', - }; - } - - if (await lockExists(params.projectRoot)) { - return { - status: 'skipped', - skippedReason: 'locked', - }; - } - - const scheduled = this.scheduler.schedule({ - taskType: DREAM_TASK_TYPE, - title: 'Managed auto-memory dream', - projectRoot: params.projectRoot, - sessionId: params.sessionId, - dedupeKey: `${DREAM_TASK_TYPE}:${params.projectRoot}`, - metadata: { - sessionCount: sessionIds.length, - }, - run: async () => { - try { - await acquireDreamLock(params.projectRoot); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'EEXIST') { - return { - progressText: - 'Skipped managed auto-memory dream because consolidation lock already exists.', - metadata: { skippedReason: 'locked' }, - }; - } - throw error; - } - - try { - const result = await runManagedAutoMemoryDream( - params.projectRoot, - now, - params.config, - ); - const nextMetadata = await readDreamMetadata(params.projectRoot); - nextMetadata.lastDreamAt = now.toISOString(); - nextMetadata.lastDreamSessionId = params.sessionId; - nextMetadata.updatedAt = now.toISOString(); - await writeDreamMetadata(params.projectRoot, nextMetadata); - - return { - progressText: - result.systemMessage ?? 'Managed auto-memory dream completed.', - metadata: { - touchedTopics: result.touchedTopics, - dedupedEntries: result.dedupedEntries, - lastDreamAt: now.toISOString(), - }, - }; - } finally { - await releaseDreamLock(params.projectRoot); - } - }, - }); - - const initialTask = this.registry.get(scheduled.taskId); - if (initialTask?.status === 'skipped') { - return { - status: 'skipped', - skippedReason: 'running', - taskId: scheduled.taskId, - promise: scheduled.promise, - }; - } - - return { - status: 'scheduled', - taskId: scheduled.taskId, - promise: scheduled.promise, - }; - } - - listTasks(projectRoot?: string): BackgroundTaskState[] { - return this.registry.list(projectRoot); - } - - drain(options?: DrainBackgroundTasksOptions): Promise { - return this.drainer.drain(options); - } -} - -const defaultManagedAutoMemoryDreamRuntime = - new ManagedAutoMemoryDreamRuntime(); - -export async function scheduleManagedAutoMemoryDream( - params: ScheduleManagedAutoMemoryDreamParams, -): Promise { - return defaultManagedAutoMemoryDreamRuntime.schedule(params); -} - -export function getManagedAutoMemoryDreamTaskRegistry(): BackgroundTaskRegistry { - return globalBackgroundTaskHub.registry; -} - -export async function drainManagedAutoMemoryDreamTasks( - options?: DrainBackgroundTasksOptions, -): Promise { - return globalBackgroundTaskHub.drain(options); -} - -export function createManagedAutoMemoryDreamRuntimeForTests( - sessionScanner?: SessionScannerFn, -): ManagedAutoMemoryDreamRuntime { - return new ManagedAutoMemoryDreamRuntime( - sessionScanner, - new BackgroundTaskHub(), - ); -} diff --git a/packages/core/src/memory/extract.test.ts b/packages/core/src/memory/extract.test.ts index 0f4dd872ce9..ff2afc60aa7 100644 --- a/packages/core/src/memory/extract.test.ts +++ b/packages/core/src/memory/extract.test.ts @@ -17,7 +17,6 @@ import { } from './extract.js'; import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { ensureAutoMemoryScaffold } from './store.js'; -import { resetAutoMemoryStateForTests } from './state.js'; vi.mock('./extractionAgentPlanner.js', () => ({ runAutoMemoryExtractionByAgent: vi.fn(), @@ -41,7 +40,6 @@ describe('auto-memory extraction', () => { }); afterEach(async () => { - resetAutoMemoryStateForTests(); await fs.rm(tempDir, { recursive: true, force: true, diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts index 631b09f16c1..e72283fae16 100644 --- a/packages/core/src/memory/extract.ts +++ b/packages/core/src/memory/extract.ts @@ -15,7 +15,6 @@ import { } from './paths.js'; import { ensureAutoMemoryScaffold } from './store.js'; import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; -import { scheduleManagedAutoMemoryExtract } from './extractScheduler.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; import { type AutoMemoryExtractCursor, @@ -194,13 +193,3 @@ export async function runAutoMemoryExtract(params: { systemMessage: agentResult.systemMessage, }; } - -export async function scheduleAutoMemoryExtract(params: { - projectRoot: string; - sessionId: string; - history: Content[]; - now?: Date; - config?: Config; -}): Promise { - return scheduleManagedAutoMemoryExtract(params); -} diff --git a/packages/core/src/memory/extractAgent.test.ts b/packages/core/src/memory/extractAgent.test.ts index cbfbbc6ae44..4415718f101 100644 --- a/packages/core/src/memory/extractAgent.test.ts +++ b/packages/core/src/memory/extractAgent.test.ts @@ -14,7 +14,6 @@ import { runAutoMemoryExtract } from './extract.js'; import { getAutoMemoryRoot } from './paths.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; -import { resetAutoMemoryStateForTests } from './state.js'; vi.mock('./extractionAgentPlanner.js', () => ({ runAutoMemoryExtractionByAgent: vi.fn(), @@ -36,7 +35,6 @@ describe('auto-memory extraction with agent planner', () => { }); afterEach(async () => { - resetAutoMemoryStateForTests(); await fs.rm(tempDir, { recursive: true, force: true, diff --git a/packages/core/src/memory/extractScheduler.test.ts b/packages/core/src/memory/extractScheduler.test.ts deleted file mode 100644 index 60f2cd469f8..00000000000 --- a/packages/core/src/memory/extractScheduler.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Config } from '../config/config.js'; -import { - createManagedAutoMemoryExtractRuntimeForTests, - EXTRACT_TASK_TYPE, -} from './extractScheduler.js'; -import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; -import { getAutoMemoryFilePath } from './paths.js'; -import { scanAutoMemoryTopicDocuments } from './scan.js'; -import { ensureAutoMemoryScaffold } from './store.js'; -import { markExtractRunning, resetAutoMemoryStateForTests } from './state.js'; - -vi.mock('./extractionAgentPlanner.js', () => ({ - runAutoMemoryExtractionByAgent: vi.fn(), -})); - -describe('managed auto-memory extraction runtime', () => { - let tempDir: string; - let projectRoot: string; - let mockConfig: Config; - let extractionCount: number; - - beforeEach(async () => { - tempDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'auto-memory-extract-runtime-'), - ); - projectRoot = path.join(tempDir, 'project'); - await fs.mkdir(projectRoot, { recursive: true }); - await ensureAutoMemoryScaffold(projectRoot); - mockConfig = { - getSessionId: vi.fn().mockReturnValue('session-1'), - getModel: vi.fn().mockReturnValue('qwen3-coder-plus'), - } as unknown as Config; - vi.clearAllMocks(); - extractionCount = 0; - vi.mocked(runAutoMemoryExtractionByAgent).mockImplementation( - async (_config: Config, root: string) => { - extractionCount += 1; - const topic = extractionCount > 1 ? 'reference' : 'user'; - const relativePath = - topic === 'reference' - ? path.join('reference', 'latency-dashboard.md') - : path.join('user', 'terse-responses.md'); - const filePath = getAutoMemoryFilePath(root, relativePath); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - const description = - topic === 'reference' - ? 'https://grafana.example/d/api' - : 'User prefers terse responses.'; - await fs.writeFile( - filePath, - [ - '---', - `type: ${topic}`, - `name: ${topic === 'reference' ? 'Latency Dashboard' : 'Terse Responses'}`, - `description: ${description}`, - '---', - '', - description, - '', - ].join('\n'), - 'utf-8', - ); - - return { - touchedTopics: [topic], - systemMessage: undefined, - }; - }, - ); - }); - - afterEach(async () => { - resetAutoMemoryStateForTests(); - await fs.rm(tempDir, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 10, - }); - }); - - it('queues a trailing extraction while another extraction is running', async () => { - const runtime = createManagedAutoMemoryExtractRuntimeForTests(); - - const firstPromise = runtime.schedule({ - projectRoot, - sessionId: 'session-1', - config: mockConfig, - history: [ - { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, - ], - }); - - const queued = await runtime.schedule({ - projectRoot, - sessionId: 'session-1', - config: mockConfig, - history: [ - { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, - { role: 'model', parts: [{ text: 'Done.' }] }, - { - role: 'user', - parts: [ - { text: 'The latency dashboard is https://grafana.example/d/api' }, - ], - }, - ], - }); - - expect(queued.skippedReason).toBe('queued'); - - const first = await firstPromise; - expect(first.touchedTopics).toEqual(['user']); - - const drained = await runtime.drain({ timeoutMs: 1_000 }); - expect(drained).toBe(true); - - const docs = await scanAutoMemoryTopicDocuments(projectRoot); - expect(docs.find((doc) => doc.type === 'reference')?.body).toContain( - 'grafana.example/d/api', - ); - - const tasks = runtime.listTasks(projectRoot); - expect(tasks.some((task) => task.status === 'completed')).toBe(true); - expect(tasks.some((task) => task.metadata?.['trailing'] === true)).toBe( - true, - ); - }); - - it('returns already_running when extraction state is externally locked', async () => { - markExtractRunning(projectRoot); - const runtime = createManagedAutoMemoryExtractRuntimeForTests(); - - const result = await runtime.schedule({ - projectRoot, - sessionId: 'session-1', - config: mockConfig, - history: [ - { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, - ], - }); - - expect(result.skippedReason).toBe('already_running'); - }); - - it('test runtimes created by the factory have isolated task registries', async () => { - const runtimeA = createManagedAutoMemoryExtractRuntimeForTests(); - const runtimeB = createManagedAutoMemoryExtractRuntimeForTests(); - - // Schedule a task only in runtimeA - await runtimeA.schedule({ - projectRoot, - sessionId: 'session-a', - config: mockConfig, - history: [{ role: 'user', parts: [{ text: 'Prefer terse responses.' }] }], - }); - await runtimeA.drain({ timeoutMs: 1_000 }); - - // runtimeA registry has tasks, runtimeB registry is empty - expect(runtimeA.listTasks(projectRoot).length).toBeGreaterThan(0); - expect(runtimeB.listTasks(projectRoot)).toHaveLength(0); - - // All tasks in runtimeA belong to the extract type - expect( - runtimeA.listTasks().every((t) => t.taskType === EXTRACT_TASK_TYPE), - ).toBe(true); - }); -}); diff --git a/packages/core/src/memory/extractScheduler.ts b/packages/core/src/memory/extractScheduler.ts deleted file mode 100644 index f0253085fbe..00000000000 --- a/packages/core/src/memory/extractScheduler.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Content, Part } from '@google/genai'; -import type { Config } from '../config/config.js'; -import type { BackgroundTaskDrainer } from '../background/taskDrainer.js'; -import type { DrainBackgroundTasksOptions } from '../background/taskDrainer.js'; -import type { - BackgroundTaskRegistry, - BackgroundTaskState, -} from '../background/taskRegistry.js'; -import { - BackgroundTaskHub, - globalBackgroundTaskHub, -} from '../background/taskHub.js'; -import { - type AutoMemoryExtractResult, - runAutoMemoryExtract, -} from './extract.js'; -import { - clearExtractRunning, - isExtractRunning, - markExtractRunning, -} from './state.js'; -import { isAutoMemPath } from './paths.js'; -import { logMemoryExtract, MemoryExtractEvent } from '../telemetry/index.js'; - -export interface ScheduleAutoMemoryExtractParams { - projectRoot: string; - sessionId: string; - history: Content[]; - now?: Date; - config?: Config; -} - -interface QueuedExtractionRequest { - taskId: string; - params: ScheduleAutoMemoryExtractParams; -} - -function buildSkippedExtractResult( - params: ScheduleAutoMemoryExtractParams, - skippedReason: AutoMemoryExtractResult['skippedReason'], -): AutoMemoryExtractResult { - return { - touchedTopics: [], - skippedReason, - cursor: { - sessionId: params.sessionId, - updatedAt: (params.now ?? new Date()).toISOString(), - }, - }; -} - -/** - * Returns true if the part is a write-tool call targeting a file path inside - * the auto-memory directory (write_file / edit / replace / create_file). - */ -function partWritesToMemory(part: Part, projectRoot: string): boolean { - // Direct write_file or edit tool calls to a memory path - const writeToolNames = new Set([ - 'write_file', - 'edit', - 'replace', - 'create_file', - ]); - const name = part.functionCall?.name; - if (name && writeToolNames.has(name)) { - const args = part.functionCall?.args as Record | undefined; - const filePath = - args?.['file_path'] ?? args?.['path'] ?? args?.['target_file']; - if (typeof filePath === 'string' && isAutoMemPath(filePath, projectRoot)) { - return true; - } - } - return false; -} - -function historySliceUsesMemoryTool( - history: Content[], - projectRoot: string, -): boolean { - return history.some((message) => - (message.parts ?? []).some((part) => partWritesToMemory(part, projectRoot)), - ); -} - -export const EXTRACT_TASK_TYPE = 'managed-auto-memory-extraction' as const; - -export class ManagedAutoMemoryExtractRuntime { - readonly registry: BackgroundTaskRegistry; - readonly drainer: BackgroundTaskDrainer; - - private readonly currentTaskIdByProject = new Map(); - private readonly queuedByProject = new Map(); - - constructor(hub: BackgroundTaskHub = globalBackgroundTaskHub) { - this.registry = hub.registry; - this.drainer = hub.drainer; - } - - async schedule( - params: ScheduleAutoMemoryExtractParams, - ): Promise { - if (historySliceUsesMemoryTool(params.history, params.projectRoot)) { - const task = this.registry.register({ - taskType: EXTRACT_TASK_TYPE, - title: 'Managed auto-memory extraction', - projectRoot: params.projectRoot, - sessionId: params.sessionId, - metadata: { - skippedReason: 'memory_tool', - historyLength: params.history.length, - }, - }); - this.registry.update(task.id, { - status: 'skipped', - progressText: - 'Skipped managed auto-memory extraction: main agent wrote to memory files this turn.', - }); - return buildSkippedExtractResult(params, 'memory_tool'); - } - - if (isExtractRunning(params.projectRoot)) { - const currentTaskId = this.currentTaskIdByProject.get(params.projectRoot); - if (!currentTaskId) { - return buildSkippedExtractResult(params, 'already_running'); - } - - const queued = this.queuedByProject.get(params.projectRoot); - if (queued) { - queued.params = params; - this.registry.update(queued.taskId, { - status: 'pending', - progressText: - 'Updated trailing managed auto-memory extraction request while another extraction is running.', - metadata: { - queuedBehindTaskId: currentTaskId, - historyLength: params.history.length, - supersededAt: new Date().toISOString(), - }, - }); - } else { - const pendingTask = this.registry.register({ - taskType: EXTRACT_TASK_TYPE, - title: 'Managed auto-memory extraction', - projectRoot: params.projectRoot, - sessionId: params.sessionId, - metadata: { - trailing: true, - queuedBehindTaskId: currentTaskId, - historyLength: params.history.length, - }, - }); - this.registry.update(pendingTask.id, { - status: 'pending', - progressText: - 'Queued trailing managed auto-memory extraction until the active extraction completes.', - }); - this.queuedByProject.set(params.projectRoot, { - taskId: pendingTask.id, - params, - }); - } - - return buildSkippedExtractResult(params, 'queued'); - } - - const task = this.registry.register({ - taskType: EXTRACT_TASK_TYPE, - title: 'Managed auto-memory extraction', - projectRoot: params.projectRoot, - sessionId: params.sessionId, - metadata: { - historyLength: params.history.length, - }, - }); - - return this.drainer.track(task.id, this.runTask(task.id, params)); - } - - listTasks(projectRoot?: string): BackgroundTaskState[] { - return this.registry.list(projectRoot); - } - - drain(options?: DrainBackgroundTasksOptions): Promise { - return this.drainer.drain(options); - } - - resetForTests(): void { - this.currentTaskIdByProject.clear(); - this.queuedByProject.clear(); - } - - private async runTask( - taskId: string, - params: ScheduleAutoMemoryExtractParams, - ): Promise { - this.currentTaskIdByProject.set(params.projectRoot, taskId); - markExtractRunning(params.projectRoot); - this.registry.update(taskId, { - status: 'running', - progressText: 'Running managed auto-memory extraction.', - metadata: { - historyLength: params.history.length, - }, - }); - - const t0 = Date.now(); - try { - const result = await runAutoMemoryExtract(params); - const durationMs = Date.now() - t0; - this.registry.update(taskId, { - status: result.skippedReason ? 'skipped' : 'completed', - progressText: - result.systemMessage ?? - (result.touchedTopics.length > 0 - ? `Managed auto-memory updated: ${result.touchedTopics.join(', ')}.` - : 'Managed auto-memory extraction completed without durable changes.'), - metadata: { - touchedTopics: result.touchedTopics, - processedOffset: result.cursor.processedOffset, - skippedReason: result.skippedReason, - }, - }); - if (params.config) { - logMemoryExtract( - params.config, - new MemoryExtractEvent({ - trigger: 'auto', - status: 'completed', - patches_count: result.touchedTopics.length, - touched_topics: result.touchedTopics, - duration_ms: durationMs, - }), - ); - } - return result; - } catch (error) { - const durationMs = Date.now() - t0; - this.registry.update(taskId, { - status: 'failed', - error: error instanceof Error ? error.message : String(error), - }); - if (params.config) { - logMemoryExtract( - params.config, - new MemoryExtractEvent({ - trigger: 'auto', - status: 'failed', - patches_count: 0, - touched_topics: [], - duration_ms: durationMs, - }), - ); - } - throw error; - } finally { - this.currentTaskIdByProject.delete(params.projectRoot); - clearExtractRunning(params.projectRoot); - void this.startQueuedIfNeeded(params.projectRoot); - } - } - - private async startQueuedIfNeeded(projectRoot: string): Promise { - if (isExtractRunning(projectRoot)) { - return; - } - - const queued = this.queuedByProject.get(projectRoot); - if (!queued) { - return; - } - - this.queuedByProject.delete(projectRoot); - await this.drainer.track( - queued.taskId, - this.runTask(queued.taskId, queued.params), - ); - } -} - -const defaultManagedAutoMemoryExtractRuntime = - new ManagedAutoMemoryExtractRuntime(); - -export async function scheduleManagedAutoMemoryExtract( - params: ScheduleAutoMemoryExtractParams, -): Promise { - return defaultManagedAutoMemoryExtractRuntime.schedule(params); -} - -export function getManagedAutoMemoryExtractTaskRegistry(): BackgroundTaskRegistry { - return globalBackgroundTaskHub.registry; -} - -export async function drainManagedAutoMemoryExtractTasks( - options?: DrainBackgroundTasksOptions, -): Promise { - return globalBackgroundTaskHub.drain(options); -} - -export function createManagedAutoMemoryExtractRuntimeForTests(): ManagedAutoMemoryExtractRuntime { - return new ManagedAutoMemoryExtractRuntime(new BackgroundTaskHub()); -} - -export function resetManagedAutoMemoryExtractRuntimeForTests(): void { - defaultManagedAutoMemoryExtractRuntime.resetForTests(); -} diff --git a/packages/core/src/memory/extractionAgentPlanner.test.ts b/packages/core/src/memory/extractionAgentPlanner.test.ts index 4fb44e72b7d..d959c62abd9 100644 --- a/packages/core/src/memory/extractionAgentPlanner.test.ts +++ b/packages/core/src/memory/extractionAgentPlanner.test.ts @@ -8,10 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; -import { - runForkedAgent, - getCacheSafeParams, -} from '../background/forkedAgent.js'; +import { runForkedAgent, getCacheSafeParams } from '../utils/forkedAgent.js'; vi.mock('./scan.js', async (importOriginal) => { const actual = await importOriginal(); @@ -29,7 +26,7 @@ vi.mock('./paths.js', async (importOriginal) => { }; }); -vi.mock('../background/forkedAgent.js', () => ({ +vi.mock('../utils/forkedAgent.js', () => ({ runForkedAgent: vi.fn(), getCacheSafeParams: vi.fn(), })); diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index 5b70076dc16..058682e0624 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -5,10 +5,7 @@ */ import type { Config } from '../config/config.js'; -import { - runForkedAgent, - getCacheSafeParams, -} from '../background/forkedAgent.js'; +import { runForkedAgent, getCacheSafeParams } from '../utils/forkedAgent.js'; import { buildFunctionResponseParts } from '../agents/runtime/forkSubagent.js'; import type { Content } from '@google/genai'; import type { PermissionManager } from '../permissions/permission-manager.js'; diff --git a/packages/core/src/memory/forget.ts b/packages/core/src/memory/forget.ts index 8acbd2b205f..e87def12e38 100644 --- a/packages/core/src/memory/forget.ts +++ b/packages/core/src/memory/forget.ts @@ -7,7 +7,7 @@ import * as fs from 'node:fs/promises'; import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; -import { runSideQuery } from '../auxiliary/sideQuery.js'; +import { runSideQuery } from '../utils/sideQuery.js'; import { buildAutoMemoryEntrySearchText, getAutoMemoryBodyHeading, diff --git a/packages/core/src/memory/governance.ts b/packages/core/src/memory/governance.ts index b8db1228a8e..3186d346dea 100644 --- a/packages/core/src/memory/governance.ts +++ b/packages/core/src/memory/governance.ts @@ -6,7 +6,7 @@ import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; -import { runSideQuery } from '../auxiliary/sideQuery.js'; +import { runSideQuery } from '../utils/sideQuery.js'; import { parseAutoMemoryEntries } from './entries.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; import type { AutoMemoryType } from './types.js'; @@ -227,9 +227,7 @@ function buildHeuristicSuggestions( }); } - if ( - /\b(deprecated|obsolete|sunset|legacy|old)\b/i.test(entry.summary) - ) { + if (/\b(deprecated|obsolete|sunset|legacy|old)\b/i.test(entry.summary)) { suggestions.push({ type: 'outdated', topic: entry.topic, @@ -339,8 +337,7 @@ export async function reviewManagedAutoMemoryGovernance( suggestedTargetTopic: suggestion.suggestedTargetTopic, } satisfies AutoMemoryGovernanceSuggestion; }), - strategy: - response.suggestions.length > 0 ? 'model' : 'none', + strategy: response.suggestions.length > 0 ? 'model' : 'none', }; } catch { // Fall back to heuristics. diff --git a/packages/core/src/memory/manager.test.ts b/packages/core/src/memory/manager.test.ts new file mode 100644 index 00000000000..4860bdaae49 --- /dev/null +++ b/packages/core/src/memory/manager.test.ts @@ -0,0 +1,471 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { globalMemoryManager, MemoryManager } from './manager.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { + getAutoMemoryMetadataPath, + getAutoMemoryConsolidationLockPath, + clearAutoMemoryRootCache, +} from './paths.js'; +import type { Config } from '../config/config.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('./extract.js', () => ({ + runAutoMemoryExtract: vi.fn(), +})); + +vi.mock('./dream.js', () => ({ + runManagedAutoMemoryDream: vi.fn(), +})); + +import { runAutoMemoryExtract } from './extract.js'; +import { runManagedAutoMemoryDream } from './dream.js'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeMockConfig(overrides: Partial = {}): Config { + return { + getManagedAutoMemoryEnabled: vi.fn().mockReturnValue(true), + getManagedAutoDreamEnabled: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('session-1'), + getModel: vi.fn().mockReturnValue('test-model'), + logEvent: vi.fn(), + ...overrides, + } as unknown as Config; +} + +// ─── MemoryManager ──────────────────────────────────────────────────────────── + +describe('MemoryManager', () => { + describe('globalMemoryManager', () => { + it('is a MemoryManager instance', () => { + expect(globalMemoryManager).toBeInstanceOf(MemoryManager); + }); + }); + + // ─── drain() ────────────────────────────────────────────────────────────── + + describe('drain()', () => { + it('resolves true immediately when there are no in-flight tasks', async () => { + const mgr = new MemoryManager(); + expect(await mgr.drain()).toBe(true); + }); + + it('resolves false when drain times out while a task is in-flight', async () => { + const mgr = new MemoryManager(); + let resolveExtract!: ( + v: Awaited>, + ) => void; + + vi.mocked(runAutoMemoryExtract).mockReturnValue( + new Promise>>( + (resolve) => { + resolveExtract = resolve; + }, + ), + ); + + void mgr.scheduleExtract({ + projectRoot: '/project', + sessionId: 'sess', + history: [{ role: 'user', parts: [{ text: 'hi' }] }], + }); + + expect(await mgr.drain({ timeoutMs: 20 })).toBe(false); + + resolveExtract({ + touchedTopics: [], + cursor: { sessionId: 'sess', updatedAt: new Date().toISOString() }, + }); + expect(await mgr.drain()).toBe(true); + }); + }); + + // ─── scheduleExtract() ──────────────────────────────────────────────────── + + describe('scheduleExtract()', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + vi.resetAllMocks(); + process.env['QWEN_CODE_MEMORY_LOCAL'] = '1'; + clearAutoMemoryRootCache(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mgr-extract-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold(projectRoot); + }); + + afterEach(async () => { + delete process.env['QWEN_CODE_MEMORY_LOCAL']; + clearAutoMemoryRootCache(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('runs extract and records a completed task', async () => { + vi.mocked(runAutoMemoryExtract).mockResolvedValue({ + touchedTopics: ['user'], + cursor: { sessionId: 'sess-1', updatedAt: new Date().toISOString() }, + }); + + const mgr = new MemoryManager(); + const result = await mgr.scheduleExtract({ + projectRoot, + sessionId: 'sess-1', + history: [{ role: 'user', parts: [{ text: 'hi' }] }], + }); + + expect(result.touchedTopics).toEqual(['user']); + await mgr.drain(); + const tasks = mgr.listTasksByType('extract', projectRoot); + expect(tasks.some((t) => t.status === 'completed')).toBe(true); + }); + + it('skips extraction when history writes to a memory file', async () => { + const mgr = new MemoryManager(); + const result = await mgr.scheduleExtract({ + projectRoot, + sessionId: 'sess-1', + history: [ + { + role: 'model', + parts: [ + { + functionCall: { + name: 'write_file', + args: { + file_path: `${projectRoot}/.qwen/memory/user/test.md`, + }, + }, + }, + ], + }, + ], + }); + + expect(result.skippedReason).toBe('memory_tool'); + expect(vi.mocked(runAutoMemoryExtract)).not.toHaveBeenCalled(); + }); + + it('queues a trailing extract when one is already running', async () => { + let resolveFirst!: ( + v: Awaited>, + ) => void; + vi.mocked(runAutoMemoryExtract) + .mockReturnValueOnce( + new Promise>>( + (resolve) => { + resolveFirst = resolve; + }, + ), + ) + .mockResolvedValueOnce({ + touchedTopics: ['reference'], + cursor: { sessionId: 'sess-1', updatedAt: new Date().toISOString() }, + }); + + const mgr = new MemoryManager(); + const firstPromise = mgr.scheduleExtract({ + projectRoot, + sessionId: 'sess-1', + history: [{ role: 'user', parts: [{ text: 'first' }] }], + }); + + // Second call while first is in-flight — should be queued + const queued = await mgr.scheduleExtract({ + projectRoot, + sessionId: 'sess-1', + history: [{ role: 'user', parts: [{ text: 'second' }] }], + }); + expect(queued.skippedReason).toBe('queued'); + + // Resolve first so queued one can start + resolveFirst({ + touchedTopics: ['user'], + cursor: { sessionId: 'sess-1', updatedAt: new Date().toISOString() }, + }); + await firstPromise; + await mgr.drain({ timeoutMs: 1_000 }); + + // Both extractions should have run + expect(vi.mocked(runAutoMemoryExtract)).toHaveBeenCalledTimes(2); + }); + + it('isolates state between manager instances', async () => { + vi.mocked(runAutoMemoryExtract).mockResolvedValue({ + touchedTopics: ['user'], + cursor: { sessionId: 'sess-1', updatedAt: new Date().toISOString() }, + }); + + const mgrA = new MemoryManager(); + const mgrB = new MemoryManager(); + + await mgrA.scheduleExtract({ + projectRoot, + sessionId: 'sess-a', + history: [{ role: 'user', parts: [{ text: 'hi' }] }], + }); + await mgrA.drain(); + + expect(mgrA.listTasksByType('extract', projectRoot)).toHaveLength(1); + expect(mgrB.listTasksByType('extract', projectRoot)).toHaveLength(0); + }); + }); + + // ─── listTasksByType() ──────────────────────────────────────────────────── + + describe('listTasksByType()', () => { + it('returns empty array when no tasks of that type exist', () => { + const mgr = new MemoryManager(); + expect(mgr.listTasksByType('extract')).toEqual([]); + expect(mgr.listTasksByType('dream')).toEqual([]); + }); + + it('filters by projectRoot when provided', async () => { + vi.mocked(runAutoMemoryExtract).mockResolvedValue({ + touchedTopics: [], + cursor: { sessionId: 'sess', updatedAt: new Date().toISOString() }, + }); + + const mgr = new MemoryManager(); + + // Two extractions for different project roots + await Promise.all([ + mgr.scheduleExtract({ + projectRoot: '/project-a', + sessionId: 'sess', + history: [{ role: 'user', parts: [{ text: 'hi' }] }], + }), + mgr.scheduleExtract({ + projectRoot: '/project-b', + sessionId: 'sess', + history: [{ role: 'user', parts: [{ text: 'hi' }] }], + }), + ]); + await mgr.drain(); + + expect(mgr.listTasksByType('extract', '/project-a')).toHaveLength(1); + expect(mgr.listTasksByType('extract', '/project-b')).toHaveLength(1); + expect(mgr.listTasksByType('extract')).toHaveLength(2); + }); + }); + + // ─── scheduleDream() ───────────────────────────────────────────────────── + + describe('scheduleDream()', () => { + let tempDir: string; + let projectRoot: string; + + beforeEach(async () => { + vi.resetAllMocks(); + process.env['QWEN_CODE_MEMORY_LOCAL'] = '1'; + clearAutoMemoryRootCache(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mgr-dream-')); + projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await ensureAutoMemoryScaffold( + projectRoot, + new Date('2026-04-01T00:00:00.000Z'), + ); + vi.mocked(runManagedAutoMemoryDream).mockResolvedValue({ + touchedTopics: [], + dedupedEntries: 0, + systemMessage: undefined, + }); + }); + + afterEach(async () => { + delete process.env['QWEN_CODE_MEMORY_LOCAL']; + clearAutoMemoryRootCache(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('skips when dream is disabled in config', async () => { + const mgr = new MemoryManager(async () => [ + 'sess-0', + 'sess-1', + 'sess-2', + 'sess-3', + 'sess-4', + ]); + const config = makeMockConfig({ + getManagedAutoDreamEnabled: vi.fn().mockReturnValue(false), + }); + + const result = await mgr.scheduleDream({ + projectRoot, + sessionId: 'sess-5', + config, + now: new Date('2026-04-01T10:00:00.000Z'), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 1, + }); + + expect(result).toEqual({ status: 'skipped', skippedReason: 'disabled' }); + }); + + it('skips when called again in the same session', async () => { + const scanner = vi + .fn() + .mockResolvedValue(['sess-0', 'sess-1', 'sess-2', 'sess-3', 'sess-4']); + const mgr = new MemoryManager(scanner); + + const first = await mgr.scheduleDream({ + projectRoot, + sessionId: 'sess-x', + now: new Date('2026-04-01T10:00:00.000Z'), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 1, + }); + expect(first.status).toBe('scheduled'); + await first.promise; + + const second = await mgr.scheduleDream({ + projectRoot, + sessionId: 'sess-x', + now: new Date('2026-04-01T11:00:00.000Z'), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 1, + }); + expect(second).toEqual({ + status: 'skipped', + skippedReason: 'same_session', + }); + }); + + it('skips when min_hours has not elapsed', async () => { + const mgr = new MemoryManager(async () => [ + 'sess-0', + 'sess-1', + 'sess-2', + 'sess-3', + 'sess-4', + ]); + + // Inject lastDreamAt that is very recent + const metaPath = getAutoMemoryMetadataPath(projectRoot); + const metadata = JSON.parse( + await fs.readFile(metaPath, 'utf-8'), + ) as Record; + metadata['lastDreamAt'] = new Date( + '2026-04-01T09:00:00.000Z', + ).toISOString(); + await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2), 'utf-8'); + + const result = await mgr.scheduleDream({ + projectRoot, + sessionId: 'sess-new', + now: new Date('2026-04-01T10:00:00.000Z'), + minHoursBetweenDreams: 24, + minSessionsBetweenDreams: 1, + }); + + expect(result).toEqual({ status: 'skipped', skippedReason: 'min_hours' }); + }); + + it('skips when session count is below threshold (via session scanner)', async () => { + // Only 1 session — need 5 + const mgr = new MemoryManager(async () => ['sess-0']); + + const result = await mgr.scheduleDream({ + projectRoot, + sessionId: 'sess-new', + now: new Date('2026-04-01T10:00:00.000Z'), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 5, + }); + + expect(result.status).toBe('skipped'); + expect(result.skippedReason).toBe('min_sessions'); + }); + + it('schedules when all conditions are met, releases lock, and records metadata', async () => { + vi.mocked(runManagedAutoMemoryDream).mockResolvedValue({ + touchedTopics: ['user'], + dedupedEntries: 1, + systemMessage: 'Dream complete.', + }); + + const mgr = new MemoryManager(async () => ['s0', 's1', 's2', 's3', 's4']); + + const result = await mgr.scheduleDream({ + projectRoot, + sessionId: 'sess-x', + now: new Date('2026-04-01T10:00:00.000Z'), + minHoursBetweenDreams: 0, + minSessionsBetweenDreams: 3, + }); + + expect(result.status).toBe('scheduled'); + const finalRecord = await result.promise; + expect(finalRecord?.status).toBe('completed'); + expect(finalRecord?.metadata?.['touchedTopics']).toEqual(['user']); + + // Lock must be released + await expect( + fs.access(getAutoMemoryConsolidationLockPath(projectRoot)), + ).rejects.toThrow(); + + // Metadata must be updated + const meta = JSON.parse( + await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'), + ) as { lastDreamSessionId?: string; lastDreamAt?: string }; + expect(meta.lastDreamSessionId).toBe('sess-x'); + expect(meta.lastDreamAt).toBe('2026-04-01T10:00:00.000Z'); + }); + }); + + // ─── resetExtractStateForTests() ───────────────────────────────────────── + + describe('resetExtractStateForTests()', () => { + it('clears in-flight extract state so subsequent calls are not blocked', async () => { + let resolveExtract!: ( + v: Awaited>, + ) => void; + vi.mocked(runAutoMemoryExtract) + .mockReturnValueOnce( + new Promise>>( + (resolve) => { + resolveExtract = resolve; + }, + ), + ) + .mockResolvedValueOnce({ + touchedTopics: [], + cursor: { sessionId: 'sess', updatedAt: new Date().toISOString() }, + }); + + const mgr = new MemoryManager(); + void mgr.scheduleExtract({ + projectRoot: '/project', + sessionId: 'sess', + history: [{ role: 'user', parts: [{ text: 'hi' }] }], + }); + + mgr.resetExtractStateForTests(); + + // After reset, a new schedule call should not return 'already_running' + const result = await mgr.scheduleExtract({ + projectRoot: '/project', + sessionId: 'sess-2', + history: [{ role: 'user', parts: [{ text: 'hi' }] }], + }); + expect(result.skippedReason).not.toBe('already_running'); + + resolveExtract({ + touchedTopics: [], + cursor: { sessionId: 'sess', updatedAt: new Date().toISOString() }, + }); + }); + }); +}); diff --git a/packages/core/src/memory/manager.ts b/packages/core/src/memory/manager.ts new file mode 100644 index 00000000000..86924893cc8 --- /dev/null +++ b/packages/core/src/memory/manager.ts @@ -0,0 +1,900 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * MemoryManager — the single entry-point for all memory module operations. + * + * # Design + * All background-task state (in-flight promises, per-project extraction queues, + * per-project dream-scan timestamps, task records) is owned directly by + * MemoryManager using plain Maps and sets. There are no separate + * BackgroundTaskRegistry / BackgroundTaskDrainer / BackgroundTaskScheduler + * helper classes; those abstractions are replaced by straightforward inline + * state management inside this class. + * + * Public API — everything external callers need: + * config.getMemoryManager().scheduleExtract(params) + * config.getMemoryManager().scheduleDream(params) + * config.getMemoryManager().recall(projectRoot, query, options) + * config.getMemoryManager().forget(projectRoot, query, options) + * config.getMemoryManager().getStatus(projectRoot) + * config.getMemoryManager().drain(options?) + * config.getMemoryManager().appendToUserMemory(userMemory, projectRoot) + * + * # Task records + * Each scheduled operation is tracked as a lightweight MemoryTaskRecord. + * These are queryable by type and projectRoot for status display. + * + * # Injection for tests + * Production code uses `config.getMemoryManager()`. Tests that need isolation + * construct `new MemoryManager()` directly. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { Content, Part } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { Storage } from '../config/storage.js'; +import { logMemoryExtract, MemoryExtractEvent } from '../telemetry/index.js'; +import { isAutoMemPath } from './paths.js'; +import { + getAutoMemoryConsolidationLockPath, + getAutoMemoryMetadataPath, +} from './paths.js'; +import { ensureAutoMemoryScaffold } from './store.js'; +import { runAutoMemoryExtract } from './extract.js'; +import { runManagedAutoMemoryDream } from './dream.js'; +import { + forgetManagedAutoMemoryEntries, + forgetManagedAutoMemoryMatches, + selectManagedAutoMemoryForgetCandidates, + type AutoMemoryForgetMatch, + type AutoMemoryForgetResult, + type AutoMemoryForgetSelectionResult, +} from './forget.js'; +import { + resolveRelevantAutoMemoryPromptForQuery, + type RelevantAutoMemoryPromptResult, + type ResolveRelevantAutoMemoryPromptOptions, +} from './recall.js'; +import { getManagedAutoMemoryStatus } from './status.js'; +import { appendManagedAutoMemoryToUserMemory } from './prompt.js'; +import { writeDreamManualRunToMetadata } from './dream.js'; +import { buildConsolidationTaskPrompt } from './dreamAgentPlanner.js'; +import type { AutoMemoryMetadata } from './types.js'; + +// ─── Re-export public types consumed by callers ─────────────────────────────── + +export type { + AutoMemoryForgetResult, + AutoMemoryForgetMatch, + AutoMemoryForgetSelectionResult, +}; +export type { + RelevantAutoMemoryPromptResult, + ResolveRelevantAutoMemoryPromptOptions, +}; +export type { ManagedAutoMemoryStatus } from './status.js'; + +// ─── Task record ────────────────────────────────────────────────────────────── + +export type MemoryTaskStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'skipped'; + +export interface MemoryTaskRecord { + id: string; + taskType: 'extract' | 'dream'; + projectRoot: string; + sessionId?: string; + status: MemoryTaskStatus; + createdAt: string; + updatedAt: string; + progressText?: string; + error?: string; + metadata?: Record; +} + +// ─── Extract params / result ────────────────────────────────────────────────── + +export interface ScheduleExtractParams { + projectRoot: string; + sessionId: string; + history: Content[]; + now?: Date; + config?: Config; +} + +// AutoMemoryExtractResult is re-used as the return type +export type { AutoMemoryExtractResult as ExtractResult } from './extract.js'; + +// ─── Dream params / result ──────────────────────────────────────────────────── + +export interface ScheduleDreamParams { + projectRoot: string; + sessionId: string; + config?: Config; + now?: Date; + minHoursBetweenDreams?: number; + minSessionsBetweenDreams?: number; +} + +export interface DreamScheduleResult { + status: 'scheduled' | 'skipped'; + taskId?: string; + skippedReason?: + | 'disabled' + | 'same_session' + | 'min_hours' + | 'min_sessions' + | 'scan_throttled' + | 'locked' + | 'running'; + promise?: Promise; +} + +/** Function type for scanning session files by mtime. Injected for testing. */ +export type SessionScannerFn = ( + projectRoot: string, + sinceMs: number, + excludeSessionId: string, +) => Promise; + +// ─── Drain options ──────────────────────────────────────────────────────────── + +export interface DrainOptions { + timeoutMs?: number; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +export const EXTRACT_TASK_TYPE = 'managed-auto-memory-extraction' as const; +export const DREAM_TASK_TYPE = 'managed-auto-memory-dream' as const; + +export const DEFAULT_AUTO_DREAM_MIN_HOURS = 24; +export const DEFAULT_AUTO_DREAM_MIN_SESSIONS = 5; + +const DREAM_LOCK_STALE_MS = 60 * 60 * 1000; // 1 hour +const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes + +const WRITE_TOOL_NAMES = new Set([ + 'write_file', + 'edit', + 'replace', + 'create_file', +]); + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +function makeTaskRecord( + type: 'extract' | 'dream', + projectRoot: string, + sessionId?: string, +): MemoryTaskRecord { + const now = new Date().toISOString(); + return { + id: randomUUID(), + taskType: type, + projectRoot, + sessionId, + status: 'pending', + createdAt: now, + updatedAt: now, + }; +} + +function updateRecord( + record: MemoryTaskRecord, + patch: Partial< + Pick + >, +): void { + if (patch.status !== undefined) record.status = patch.status; + if (patch.progressText !== undefined) + record.progressText = patch.progressText; + if (patch.error !== undefined) record.error = patch.error; + if (patch.metadata !== undefined) { + record.metadata = { ...(record.metadata ?? {}), ...patch.metadata }; + } + record.updatedAt = new Date().toISOString(); +} + +function partWritesToMemory(part: Part, projectRoot: string): boolean { + const name = part.functionCall?.name; + if (name && WRITE_TOOL_NAMES.has(name)) { + const args = part.functionCall?.args as Record | undefined; + const filePath = + args?.['file_path'] ?? args?.['path'] ?? args?.['target_file']; + if (typeof filePath === 'string' && isAutoMemPath(filePath, projectRoot)) { + return true; + } + } + return false; +} + +function historyWritesToMemory( + history: Content[], + projectRoot: string, +): boolean { + return history.some((msg) => + (msg.parts ?? []).some((p) => partWritesToMemory(p, projectRoot)), + ); +} + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function readDreamMetadata( + projectRoot: string, +): Promise { + const content = await fs.readFile( + getAutoMemoryMetadataPath(projectRoot), + 'utf-8', + ); + return JSON.parse(content) as AutoMemoryMetadata; +} + +async function writeDreamMetadata( + projectRoot: string, + metadata: AutoMemoryMetadata, +): Promise { + await fs.writeFile( + getAutoMemoryMetadataPath(projectRoot), + `${JSON.stringify(metadata, null, 2)}\n`, + 'utf-8', + ); +} + +function hoursSince(lastDreamAt: string | undefined, now: Date): number | null { + if (!lastDreamAt) return null; + const timestamp = Date.parse(lastDreamAt); + if (Number.isNaN(timestamp)) return null; + return (now.getTime() - timestamp) / (1000 * 60 * 60); +} + +const SESSION_FILE_PATTERN = /^[0-9a-fA-F-]{32,36}\.jsonl$/; + +async function defaultSessionScanner( + projectRoot: string, + sinceMs: number, + excludeSessionId: string, +): Promise { + const chatsDir = path.join(new Storage(projectRoot).getProjectDir(), 'chats'); + let names: string[]; + try { + names = await fs.readdir(chatsDir); + } catch { + return []; + } + const results: string[] = []; + await Promise.all( + names.map(async (name) => { + if (!SESSION_FILE_PATTERN.test(name)) return; + const sessionId = name.slice(0, -'.jsonl'.length); + if (sessionId === excludeSessionId) return; + try { + const stats = await fs.stat(path.join(chatsDir, name)); + if (stats.mtimeMs > sinceMs) results.push(sessionId); + } catch { + // skip unreadable files + } + }), + ); + return results; +} + +async function dreamLockExists(projectRoot: string): Promise { + const lockPath = getAutoMemoryConsolidationLockPath(projectRoot); + let mtimeMs: number; + let holderPid: number | undefined; + try { + const [stats, content] = await Promise.all([ + fs.stat(lockPath), + fs.readFile(lockPath, 'utf-8').catch(() => ''), + ]); + mtimeMs = stats.mtimeMs; + const parsed = parseInt(content.trim(), 10); + holderPid = Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; + } catch { + return false; // ENOENT — no lock + } + const ageMs = Date.now() - mtimeMs; + if (ageMs <= DREAM_LOCK_STALE_MS) { + if (holderPid !== undefined && isProcessRunning(holderPid)) return true; + await fs.rm(lockPath, { force: true }); + return false; + } + await fs.rm(lockPath, { force: true }); + return false; +} + +async function acquireDreamLock(projectRoot: string): Promise { + await fs.writeFile( + getAutoMemoryConsolidationLockPath(projectRoot), + String(process.pid), + { flag: 'wx' }, + ); +} + +async function releaseDreamLock(projectRoot: string): Promise { + await fs.rm(getAutoMemoryConsolidationLockPath(projectRoot), { + force: true, + }); +} + +// ─── MemoryManager ──────────────────────────────────────────────────────────── + +/** + * MemoryManager owns all runtime state for the memory subsystem and exposes a + * clean, stable API. It is created once per Config instance and returned by + * `config.getMemoryManager()`. Tests pass a fresh `new MemoryManager()`. + */ +export class MemoryManager { + // ── Task records ──────────────────────────────────────────────────────────── + private readonly tasks = new Map(); + // ── Subscribers (useSyncExternalStore / custom listeners) ──────────────── + private readonly subscribers = new Set<() => void>(); + // ── In-flight promises (for drain) ────────────────────────────────────────── + private readonly inFlight = new Map>(); + + // ── Extract scheduling state ───────────────────────────────────────────────── + private readonly extractRunning = new Set(); + private readonly extractCurrentTaskId = new Map(); + private readonly extractQueued = new Map< + string, + { taskId: string; params: ScheduleExtractParams } + >(); + + // ── Dream scheduling state ─────────────────────────────────────────────────── + private readonly dreamInFlightByKey = new Map(); + private readonly dreamLastSessionScanAt = new Map(); + private readonly sessionScanner: SessionScannerFn; + + constructor(sessionScanner: SessionScannerFn = defaultSessionScanner) { + this.sessionScanner = sessionScanner; + } + // ─── Subscribe ─────────────────────────────────────────────────────────────────── + + /** + * Register a listener that is called whenever any task record changes. + * Compatible with React’s `useSyncExternalStore`. + * Returns an unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.subscribers.add(listener); + return () => this.subscribers.delete(listener); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + /** Update a record and notify subscribers. */ + private update( + record: MemoryTaskRecord, + patch: Partial< + Pick + >, + ): void { + updateRecord(record, patch); + this.notify(); + } + + /** + * Register a brand-new record in the task map and notify once. + * Use this for records that start in 'pending' and need no immediate patch. + */ + private store(record: MemoryTaskRecord): void { + this.tasks.set(record.id, record); + this.notify(); + } + + /** + * Register a brand-new record AND apply an initial status patch in a single + * notify. Avoids the double-render that separate store()+update() causes. + */ + private storeWith( + record: MemoryTaskRecord, + patch: Partial< + Pick + >, + ): void { + updateRecord(record, patch); + this.tasks.set(record.id, record); + this.notify(); + } + // ─── Task record query ──────────────────────────────────────────────────────── + + /** Return task records filtered by type and optionally by projectRoot. */ + listTasksByType( + taskType: 'extract' | 'dream', + projectRoot?: string, + ): MemoryTaskRecord[] { + return [...this.tasks.values()] + .filter( + (t) => + t.taskType === taskType && + (!projectRoot || t.projectRoot === projectRoot), + ) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + + // ─── Drain ──────────────────────────────────────────────────────────────────── + + /** Wait for all in-flight tasks to settle, with optional timeout. */ + async drain(options: DrainOptions = {}): Promise { + const promises = [...this.inFlight.values()]; + if (promises.length === 0) return true; + const waitAll = Promise.allSettled(promises).then(() => true); + if (!options.timeoutMs || options.timeoutMs <= 0) return waitAll; + return Promise.race([ + waitAll, + new Promise((resolve) => + setTimeout(() => resolve(false), options.timeoutMs), + ), + ]); + } + + private track(taskId: string, promise: Promise): Promise { + this.inFlight.set(taskId, promise); + void promise.finally(() => this.inFlight.delete(taskId)); + return promise; + } + + // ─── Extract ────────────────────────────────────────────────────────────────── + + /** + * Schedule a managed auto-memory extraction for the given session turn. + * + * Returns immediately with a skipped result if: + * - The last history turn wrote to a memory file (memory_tool) + * - Extraction is already running for this project (queues trailing request) + * + * The trailing request starts automatically when the active extraction + * completes. + */ + async scheduleExtract( + params: ScheduleExtractParams, + ): Promise< + ReturnType extends Promise ? T : never + > { + if (historyWritesToMemory(params.history, params.projectRoot)) { + const record = makeTaskRecord( + 'extract', + params.projectRoot, + params.sessionId, + ); + this.storeWith(record, { + status: 'skipped', + progressText: 'Skipped: main agent wrote to memory files this turn.', + metadata: { + skippedReason: 'memory_tool', + historyLength: params.history.length, + }, + }); + return { + touchedTopics: [], + skippedReason: 'memory_tool' as const, + cursor: { + sessionId: params.sessionId, + updatedAt: (params.now ?? new Date()).toISOString(), + }, + } as never; + } + + if (this.extractRunning.has(params.projectRoot)) { + const currentTaskId = this.extractCurrentTaskId.get(params.projectRoot); + if (!currentTaskId) { + return { + touchedTopics: [], + skippedReason: 'already_running' as const, + cursor: { + sessionId: params.sessionId, + updatedAt: (params.now ?? new Date()).toISOString(), + }, + } as never; + } + + const queued = this.extractQueued.get(params.projectRoot); + if (queued) { + // Supersede the existing queued request with newer params + queued.params = params; + const queuedRecord = this.tasks.get(queued.taskId); + if (queuedRecord) { + this.update(queuedRecord, { + status: 'pending', + progressText: + 'Updated trailing managed auto-memory extraction request while another extraction is running.', + metadata: { + queuedBehindTaskId: currentTaskId, + historyLength: params.history.length, + supersededAt: new Date().toISOString(), + }, + }); + } + } else { + const record = makeTaskRecord( + 'extract', + params.projectRoot, + params.sessionId, + ); + this.storeWith(record, { + status: 'pending', + progressText: + 'Queued trailing managed auto-memory extraction until the active extraction completes.', + metadata: { + trailing: true, + queuedBehindTaskId: currentTaskId, + historyLength: params.history.length, + }, + }); + this.extractQueued.set(params.projectRoot, { + taskId: record.id, + params, + }); + } + + return { + touchedTopics: [], + skippedReason: 'queued' as const, + cursor: { + sessionId: params.sessionId, + updatedAt: (params.now ?? new Date()).toISOString(), + }, + } as never; + } + + const record = makeTaskRecord( + 'extract', + params.projectRoot, + params.sessionId, + ); + this.store(record); + return this.track(record.id, this.runExtract(record.id, params)) as never; + } + + private async runExtract( + taskId: string, + params: ScheduleExtractParams, + ): Promise>> { + const record = this.tasks.get(taskId)!; + this.extractCurrentTaskId.set(params.projectRoot, taskId); + this.extractRunning.add(params.projectRoot); + this.update(record, { + status: 'running', + progressText: 'Running managed auto-memory extraction.', + metadata: { historyLength: params.history.length }, + }); + + const t0 = Date.now(); + try { + const result = await runAutoMemoryExtract(params); + const durationMs = Date.now() - t0; + this.update(record, { + status: result.skippedReason ? 'skipped' : 'completed', + progressText: + result.systemMessage ?? + (result.touchedTopics.length > 0 + ? `Managed auto-memory updated: ${result.touchedTopics.join(', ')}.` + : 'Managed auto-memory extraction completed without durable changes.'), + metadata: { + touchedTopics: result.touchedTopics, + processedOffset: result.cursor.processedOffset, + skippedReason: result.skippedReason, + }, + }); + if (params.config) { + logMemoryExtract( + params.config, + new MemoryExtractEvent({ + trigger: 'auto', + status: 'completed', + patches_count: result.touchedTopics.length, + touched_topics: result.touchedTopics, + duration_ms: durationMs, + }), + ); + } + return result; + } catch (error) { + const durationMs = Date.now() - t0; + this.update(record, { + status: 'failed', + error: error instanceof Error ? error.message : String(error), + }); + if (params.config) { + logMemoryExtract( + params.config, + new MemoryExtractEvent({ + trigger: 'auto', + status: 'failed', + patches_count: 0, + touched_topics: [], + duration_ms: durationMs, + }), + ); + } + throw error; + } finally { + this.extractCurrentTaskId.delete(params.projectRoot); + this.extractRunning.delete(params.projectRoot); + void this.startQueuedExtract(params.projectRoot); + } + } + + private async startQueuedExtract(projectRoot: string): Promise { + if (this.extractRunning.has(projectRoot)) return; + const queued = this.extractQueued.get(projectRoot); + if (!queued) return; + this.extractQueued.delete(projectRoot); + await this.track( + queued.taskId, + this.runExtract(queued.taskId, queued.params), + ); + } + + // ─── Dream ──────────────────────────────────────────────────────────────────── + + /** + * Maybe schedule a managed auto-memory dream (consolidation). + * Returns immediately if preconditions aren't met (time gate, session count, + * lock, or duplicate). + */ + async scheduleDream( + params: ScheduleDreamParams, + ): Promise { + if (params.config && !params.config.getManagedAutoDreamEnabled()) { + return { status: 'skipped', skippedReason: 'disabled' }; + } + + const now = params.now ?? new Date(); + const minHours = + params.minHoursBetweenDreams ?? DEFAULT_AUTO_DREAM_MIN_HOURS; + const minSessions = + params.minSessionsBetweenDreams ?? DEFAULT_AUTO_DREAM_MIN_SESSIONS; + + await ensureAutoMemoryScaffold(params.projectRoot, now); + const metadata = await readDreamMetadata(params.projectRoot); + + if (metadata.lastDreamSessionId === params.sessionId) { + return { status: 'skipped', skippedReason: 'same_session' }; + } + + const elapsedHours = hoursSince(metadata.lastDreamAt, now); + if (elapsedHours !== null && elapsedHours < minHours) { + return { status: 'skipped', skippedReason: 'min_hours' }; + } + + // Throttle the expensive session-count filesystem scan. + // Return a distinct reason so callers can tell the difference between + // "we know there aren't enough sessions" and "we haven't checked yet". + const lastScan = this.dreamLastSessionScanAt.get(params.projectRoot) ?? 0; + if (now.getTime() - lastScan < SESSION_SCAN_INTERVAL_MS) { + return { status: 'skipped', skippedReason: 'scan_throttled' }; + } + + const lastDreamMs = metadata.lastDreamAt + ? Date.parse(metadata.lastDreamAt) + : 0; + const sessionIds = await this.sessionScanner( + params.projectRoot, + lastDreamMs, + params.sessionId, + ); + // Record scan time only after we actually performed the filesystem scan. + this.dreamLastSessionScanAt.set(params.projectRoot, now.getTime()); + if (sessionIds.length < minSessions) { + return { status: 'skipped', skippedReason: 'min_sessions' }; + } + + if (await dreamLockExists(params.projectRoot)) { + return { status: 'skipped', skippedReason: 'locked' }; + } + + // Deduplication — only one dream per projectRoot at a time + const dedupeKey = `${DREAM_TASK_TYPE}:${params.projectRoot}`; + const existingId = this.dreamInFlightByKey.get(dedupeKey); + if (existingId) { + return { + status: 'skipped', + skippedReason: 'running', + taskId: existingId, + }; + } + + const record = makeTaskRecord( + 'dream', + params.projectRoot, + params.sessionId, + ); + this.storeWith(record, { + status: 'running', + metadata: { sessionCount: sessionIds.length }, + }); + this.dreamInFlightByKey.set(dedupeKey, record.id); + + const promise = this.track( + record.id, + this.runDream(record, dedupeKey, params, now), + ); + + return { status: 'scheduled', taskId: record.id, promise }; + } + + private async runDream( + record: MemoryTaskRecord, + dedupeKey: string, + params: ScheduleDreamParams, + now: Date, + ): Promise { + try { + try { + await acquireDreamLock(params.projectRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + this.update(record, { + status: 'skipped', + progressText: + 'Skipped managed auto-memory dream: consolidation lock already exists.', + metadata: { skippedReason: 'locked' }, + }); + return record; + } + throw error; + } + + try { + const result = await runManagedAutoMemoryDream( + params.projectRoot, + now, + params.config, + ); + const nextMetadata = await readDreamMetadata(params.projectRoot); + nextMetadata.lastDreamAt = now.toISOString(); + nextMetadata.lastDreamSessionId = params.sessionId; + nextMetadata.updatedAt = now.toISOString(); + await writeDreamMetadata(params.projectRoot, nextMetadata); + + this.update(record, { + status: 'completed', + progressText: + result.systemMessage ?? 'Managed auto-memory dream completed.', + metadata: { + touchedTopics: result.touchedTopics, + dedupedEntries: result.dedupedEntries, + lastDreamAt: now.toISOString(), + }, + }); + } finally { + await releaseDreamLock(params.projectRoot); + } + } catch (error) { + this.update(record, { + status: 'failed', + error: error instanceof Error ? error.message : String(error), + }); + } finally { + this.dreamInFlightByKey.delete(dedupeKey); + } + return record; + } + + // ─── Recall ─────────────────────────────────────────────────────────────────── + + /** Select and format relevant memory for the given query. */ + recall( + projectRoot: string, + query: string, + options: ResolveRelevantAutoMemoryPromptOptions = {}, + ): Promise { + return resolveRelevantAutoMemoryPromptForQuery(projectRoot, query, options); + } + + // ─── Forget ─────────────────────────────────────────────────────────────────── + + /** Select candidate memory entries matching the given query (step 1 of forget). */ + selectForgetCandidates( + projectRoot: string, + query: string, + options: { config?: Config; limit?: number } = {}, + ): Promise { + return selectManagedAutoMemoryForgetCandidates(projectRoot, query, options); + } + + /** Remove the selected memory entries (step 2 of forget). */ + forgetMatches( + projectRoot: string, + matches: AutoMemoryForgetMatch[], + now?: Date, + ): Promise { + return forgetManagedAutoMemoryMatches(projectRoot, matches, now); + } + + /** Convenience: select + remove in a single call. */ + forget( + projectRoot: string, + query: string, + options: { config?: Config } = {}, + now?: Date, + ): Promise { + return forgetManagedAutoMemoryEntries(projectRoot, query, options, now); + } + + // ─── Status ─────────────────────────────────────────────────────────────────── + + /** Return a full status snapshot for the given project's memory. */ + getStatus(projectRoot: string) { + return getManagedAutoMemoryStatus(projectRoot, this); + } + + // ─── Prompt append ──────────────────────────────────────────────────────────── + + /** Append the managed auto-memory section to a user memory string. */ + appendToUserMemory( + userMemory: string, + memoryDir: string, + indexContent?: string | null, + ): string { + return appendManagedAutoMemoryToUserMemory( + userMemory, + memoryDir, + indexContent, + ); + } + + // ─── Dream utilities ────────────────────────────────────────────────────────── + + /** + * Record that a manual dream run has completed for the given session. + * Call this from the dreamCommand's onComplete callback. + */ + writeDreamManualRun( + projectRoot: string, + sessionId: string, + now?: Date, + ): Promise { + return writeDreamManualRunToMetadata(projectRoot, sessionId, now); + } + + /** + * Build the consolidation task prompt used by the dream slash command. + * Returns a prompt string describing what the agent should do. + */ + buildConsolidationPrompt(memoryRoot: string, transcriptDir: string): string { + return buildConsolidationTaskPrompt(memoryRoot, transcriptDir); + } + + // ─── Test helpers ───────────────────────────────────────────────────────────── + + /** Reset all extract scheduling state. Call from afterEach in tests. */ + resetExtractStateForTests(): void { + this.extractRunning.clear(); + this.extractCurrentTaskId.clear(); + this.extractQueued.clear(); + } + + /** Reset all dream scheduling state. */ + resetDreamStateForTests(): void { + this.dreamInFlightByKey.clear(); + this.dreamLastSessionScanAt.clear(); + } +} + +/** + * Application-wide singleton. In a fully wired application Config creates its + * own MemoryManager accessible via `config.getMemoryManager()`. + */ +export const globalMemoryManager = new MemoryManager(); diff --git a/packages/core/src/memory/memoryLifecycle.integration.test.ts b/packages/core/src/memory/memoryLifecycle.integration.test.ts index bb5af0a0464..d1e94ba77c5 100644 --- a/packages/core/src/memory/memoryLifecycle.integration.test.ts +++ b/packages/core/src/memory/memoryLifecycle.integration.test.ts @@ -12,17 +12,12 @@ import type { Config } from '../config/config.js'; import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { runManagedAutoMemoryDream } from './dream.js'; import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; -import { - drainManagedAutoMemoryExtractTasks, - resetManagedAutoMemoryExtractRuntimeForTests, - scheduleManagedAutoMemoryExtract, -} from './extractScheduler.js'; +import { MemoryManager } from './manager.js'; import { rebuildManagedAutoMemoryIndex } from './indexer.js'; import { getAutoMemoryFilePath, getAutoMemoryIndexPath } from './paths.js'; import { resolveRelevantAutoMemoryPromptForQuery } from './recall.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; -import { resetAutoMemoryStateForTests } from './state.js'; vi.mock('./extractionAgentPlanner.js', () => ({ runAutoMemoryExtractionByAgent: vi.fn(), @@ -37,8 +32,10 @@ describe('managed auto-memory lifecycle integration', () => { let projectRoot: string; let mockConfig: Config; let extractionCount: number; + let mgr: MemoryManager; beforeEach(async () => { + mgr = new MemoryManager(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-lifecycle-int-')); projectRoot = path.join(tempDir, 'project'); await fs.mkdir(projectRoot, { recursive: true }); @@ -104,8 +101,7 @@ describe('managed auto-memory lifecycle integration', () => { }); afterEach(async () => { - resetAutoMemoryStateForTests(); - resetManagedAutoMemoryExtractRuntimeForTests(); + mgr.resetExtractStateForTests(); await fs.rm(tempDir, { recursive: true, force: true, @@ -115,7 +111,7 @@ describe('managed auto-memory lifecycle integration', () => { }); it('supports a durable memory lifecycle across extraction, recall, and dream', async () => { - const firstExtraction = scheduleManagedAutoMemoryExtract({ + const firstExtraction = mgr.scheduleExtract({ projectRoot, sessionId: 'session-1', config: mockConfig, @@ -124,7 +120,7 @@ describe('managed auto-memory lifecycle integration', () => { ], }); - const queuedExtraction = await scheduleManagedAutoMemoryExtract({ + const queuedExtraction = await mgr.scheduleExtract({ projectRoot, sessionId: 'session-1', config: mockConfig, @@ -147,7 +143,7 @@ describe('managed auto-memory lifecycle integration', () => { const firstResult = await firstExtraction; expect(firstResult.touchedTopics).toEqual(['user']); - const drained = await drainManagedAutoMemoryExtractTasks({ + const drained = await mgr.drain({ timeoutMs: 1_000, }); expect(drained).toBe(true); diff --git a/packages/core/src/memory/relevanceSelector.test.ts b/packages/core/src/memory/relevanceSelector.test.ts index 774afa98b63..7b86aebc9bf 100644 --- a/packages/core/src/memory/relevanceSelector.test.ts +++ b/packages/core/src/memory/relevanceSelector.test.ts @@ -6,11 +6,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { runSideQuery } from '../auxiliary/sideQuery.js'; +import { runSideQuery } from '../utils/sideQuery.js'; import type { ScannedAutoMemoryDocument } from './scan.js'; import { selectRelevantAutoMemoryDocumentsByModel } from './relevanceSelector.js'; -vi.mock('../auxiliary/sideQuery.js', () => ({ +vi.mock('../utils/sideQuery.js', () => ({ runSideQuery: vi.fn(), })); diff --git a/packages/core/src/memory/relevanceSelector.ts b/packages/core/src/memory/relevanceSelector.ts index 8fbc55d8048..b457a965e09 100644 --- a/packages/core/src/memory/relevanceSelector.ts +++ b/packages/core/src/memory/relevanceSelector.ts @@ -6,7 +6,7 @@ import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; -import { runSideQuery } from '../auxiliary/sideQuery.js'; +import { runSideQuery } from '../utils/sideQuery.js'; import type { ScannedAutoMemoryDocument } from './scan.js'; /** diff --git a/packages/core/src/memory/state.test.ts b/packages/core/src/memory/state.test.ts deleted file mode 100644 index dc757f77eeb..00000000000 --- a/packages/core/src/memory/state.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { afterEach, describe, expect, it } from 'vitest'; -import { - clearExtractRunning, - isExtractRunning, - markExtractRunning, - resetAutoMemoryStateForTests, -} from './state.js'; - -describe('auto-memory state', () => { - afterEach(() => { - resetAutoMemoryStateForTests(); - }); - - it('tracks extract running state per project', () => { - expect(isExtractRunning('/tmp/project')).toBe(false); - markExtractRunning('/tmp/project'); - expect(isExtractRunning('/tmp/project')).toBe(true); - clearExtractRunning('/tmp/project'); - expect(isExtractRunning('/tmp/project')).toBe(false); - }); -}); \ No newline at end of file diff --git a/packages/core/src/memory/state.ts b/packages/core/src/memory/state.ts deleted file mode 100644 index b3cfad5cbd1..00000000000 --- a/packages/core/src/memory/state.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -const runningExtractProjects = new Set(); - -export function isExtractRunning(projectRoot: string): boolean { - return runningExtractProjects.has(projectRoot); -} - -export function markExtractRunning(projectRoot: string): void { - runningExtractProjects.add(projectRoot); -} - -export function clearExtractRunning(projectRoot: string): void { - runningExtractProjects.delete(projectRoot); -} - -export function resetAutoMemoryStateForTests(): void { - runningExtractProjects.clear(); -} \ No newline at end of file diff --git a/packages/core/src/memory/status.ts b/packages/core/src/memory/status.ts index 3f424b56022..c94658b5034 100644 --- a/packages/core/src/memory/status.ts +++ b/packages/core/src/memory/status.ts @@ -5,9 +5,7 @@ */ import * as fs from 'node:fs/promises'; -import { globalBackgroundTaskHub } from '../background/taskHub.js'; -import { EXTRACT_TASK_TYPE } from './extractScheduler.js'; -import { DREAM_TASK_TYPE } from './dreamScheduler.js'; +import { type MemoryManager, type MemoryTaskRecord } from './manager.js'; import { getAutoMemoryExtractCursorPath, getAutoMemoryIndexPath, @@ -15,14 +13,12 @@ import { getAutoMemoryRoot, } from './paths.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; -import { isExtractRunning } from './state.js'; import type { AutoMemoryExtractCursor, AutoMemoryMetadata, AutoMemoryType, } from './types.js'; import { AUTO_MEMORY_TYPES } from './types.js'; -import type { BackgroundTaskState } from '../background/taskRegistry.js'; export interface ManagedAutoMemoryTopicStatus { topic: AutoMemoryType; @@ -38,8 +34,8 @@ export interface ManagedAutoMemoryStatus { metadata?: AutoMemoryMetadata; extractionRunning: boolean; topics: ManagedAutoMemoryTopicStatus[]; - extractionTasks: BackgroundTaskState[]; - dreamTasks: BackgroundTaskState[]; + extractionTasks: MemoryTaskRecord[]; + dreamTasks: MemoryTaskRecord[]; } async function readJsonFile(filePath: string): Promise { @@ -53,6 +49,7 @@ async function readJsonFile(filePath: string): Promise { export async function getManagedAutoMemoryStatus( projectRoot: string, + manager: MemoryManager, ): Promise { const root = getAutoMemoryRoot(projectRoot); const indexPath = getAutoMemoryIndexPath(projectRoot); @@ -80,19 +77,22 @@ export async function getManagedAutoMemoryStatus( filePaths: byTopic.get(topic) ?? [], })); + const extractTaskType = 'extract' as const; + const dreamTaskType = 'dream' as const; + return { root, indexPath, indexContent, cursor, metadata, - extractionRunning: isExtractRunning(projectRoot), + extractionRunning: manager + .listTasksByType(extractTaskType, projectRoot) + .some((t) => t.status === 'running'), topics, - extractionTasks: globalBackgroundTaskHub - .listByType(EXTRACT_TASK_TYPE, projectRoot) + extractionTasks: manager + .listTasksByType(extractTaskType, projectRoot) .slice(0, 8), - dreamTasks: globalBackgroundTaskHub - .listByType(DREAM_TASK_TYPE, projectRoot) - .slice(0, 5), + dreamTasks: manager.listTasksByType(dreamTaskType, projectRoot).slice(0, 5), }; } diff --git a/packages/core/src/tools/agent.ts b/packages/core/src/tools/agent.ts index b7a198593aa..b7f2f045fc8 100644 --- a/packages/core/src/tools/agent.ts +++ b/packages/core/src/tools/agent.ts @@ -602,9 +602,7 @@ class AgentToolInvocation extends BaseToolInvocation { // Retrieve the parent's cached generationConfig (systemInstruction + // tools) so the fork's API requests share the same prefix for // DashScope prompt cache hits. - const { getCacheSafeParams } = await import( - '../background/forkedAgent.js' - ); + const { getCacheSafeParams } = await import('../utils/forkedAgent.js'); const cacheSafeParams = getCacheSafeParams(); if (cacheSafeParams) { forkGenerationConfig = cacheSafeParams.generationConfig; diff --git a/packages/core/src/background/forkedAgent.cache.test.ts b/packages/core/src/utils/forkedAgent.cache.test.ts similarity index 100% rename from packages/core/src/background/forkedAgent.cache.test.ts rename to packages/core/src/utils/forkedAgent.cache.test.ts diff --git a/packages/core/src/background/forkedAgent.ts b/packages/core/src/utils/forkedAgent.ts similarity index 100% rename from packages/core/src/background/forkedAgent.ts rename to packages/core/src/utils/forkedAgent.ts diff --git a/packages/core/src/utils/nextSpeakerChecker.ts b/packages/core/src/utils/nextSpeakerChecker.ts index 6b2660c7eae..8b431fac038 100644 --- a/packages/core/src/utils/nextSpeakerChecker.ts +++ b/packages/core/src/utils/nextSpeakerChecker.ts @@ -9,7 +9,7 @@ import type { GeminiChat } from '../core/geminiChat.js'; import { isFunctionResponse } from './messageInspectors.js'; import type { Config } from '../config/config.js'; import { createDebugLogger } from './debugLogger.js'; -import { runSideQuery } from '../auxiliary/sideQuery.js'; +import { runSideQuery } from './sideQuery.js'; const debugLogger = createDebugLogger('NEXT_SPEAKER'); diff --git a/packages/core/src/auxiliary/sideQuery.test.ts b/packages/core/src/utils/sideQuery.test.ts similarity index 97% rename from packages/core/src/auxiliary/sideQuery.test.ts rename to packages/core/src/utils/sideQuery.test.ts index a107f1d03aa..929fe66fbc9 100644 --- a/packages/core/src/auxiliary/sideQuery.test.ts +++ b/packages/core/src/utils/sideQuery.test.ts @@ -118,7 +118,9 @@ describe('runSideQuery', () => { }, abortSignal: abortController.signal, validate: (response) => - response.status.trim().length === 0 ? 'Status must be non-empty' : null, + response.status.trim().length === 0 + ? 'Status must be non-empty' + : null, }), ).rejects.toThrow('Status must be non-empty'); }); diff --git a/packages/core/src/auxiliary/sideQuery.ts b/packages/core/src/utils/sideQuery.ts similarity index 91% rename from packages/core/src/auxiliary/sideQuery.ts rename to packages/core/src/utils/sideQuery.ts index 5c4b343410b..b3770e1c849 100644 --- a/packages/core/src/auxiliary/sideQuery.ts +++ b/packages/core/src/utils/sideQuery.ts @@ -4,14 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Content, - GenerateContentConfig, - Part, -} from '@google/genai'; +import type { Content, GenerateContentConfig, Part } from '@google/genai'; import type { Config } from '../config/config.js'; import { DEFAULT_QWEN_MODEL } from '../config/models.js'; -import { SchemaValidator } from '../utils/schemaValidator.js'; +import { SchemaValidator } from './schemaValidator.js'; export interface SideQueryOptions { contents: Content[]; diff --git a/packages/core/src/utils/subagentGenerator.ts b/packages/core/src/utils/subagentGenerator.ts index 472364d440a..2f1d7e5e401 100644 --- a/packages/core/src/utils/subagentGenerator.ts +++ b/packages/core/src/utils/subagentGenerator.ts @@ -6,7 +6,7 @@ import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; -import { runSideQuery } from '../auxiliary/sideQuery.js'; +import { runSideQuery } from './sideQuery.js'; const SYSTEM_PROMPT = `You are an elite AI agent architect specializing in crafting high-performance agent configurations. Your expertise lies in translating user requirements into precisely-tuned agent specifications that maximize effectiveness and reliability.