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..3bd91baa094 --- /dev/null +++ b/packages/cli/src/ui/commands/dreamCommand.test.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { Storage } from '@qwen-code/qwen-code-core'; +import { dreamCommand } from './dreamCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('dreamCommand', () => { + it('submits a consolidation prompt with the project-scoped transcript directory', async () => { + const projectRoot = path.join('tmp', 'dream-project'); + const buildConsolidationPrompt = vi.fn().mockReturnValue('dream prompt'); + const writeDreamManualRun = vi.fn(); + const context = createMockCommandContext({ + services: { + config: { + getProjectRoot: vi.fn().mockReturnValue(projectRoot), + getMemoryManager: vi.fn().mockReturnValue({ + buildConsolidationPrompt, + writeDreamManualRun, + }), + getSessionId: vi.fn().mockReturnValue('session-1'), + }, + }, + }); + + const result = await dreamCommand.action?.(context, ''); + const expectedTranscriptDir = path.join( + new Storage(projectRoot).getProjectDir(), + 'chats', + ); + + expect(result).toEqual({ + type: 'submit_prompt', + content: 'dream prompt', + onComplete: expect.any(Function), + }); + expect(buildConsolidationPrompt).toHaveBeenCalledWith( + expect.any(String), + expectedTranscriptDir, + ); + expect(expectedTranscriptDir).not.toContain( + `${path.sep}.qwen${path.sep}tmp${path.sep}`, + ); + }); +}); diff --git a/packages/cli/src/ui/commands/dreamCommand.ts b/packages/cli/src/ui/commands/dreamCommand.ts index 0d5040d393f..fa7c520f0b8 100644 --- a/packages/cli/src/ui/commands/dreamCommand.ts +++ b/packages/cli/src/ui/commands/dreamCommand.ts @@ -4,11 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - getAutoMemoryRoot, - getProjectHash, - QWEN_DIR, -} from '@qwen-code/qwen-code-core'; +import * as path from 'node:path'; +import { getAutoMemoryRoot, Storage } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; @@ -31,8 +28,10 @@ export const dreamCommand: SlashCommand = { const projectRoot = config.getProjectRoot(); const memoryRoot = getAutoMemoryRoot(projectRoot); - const projectHash = getProjectHash(projectRoot); - const transcriptDir = `${QWEN_DIR}/tmp/${projectHash}/chats`; + const transcriptDir = path.join( + new Storage(projectRoot).getProjectDir(), + 'chats', + ); const prompt = config .getMemoryManager() diff --git a/packages/core/src/memory/dreamAgentPlanner.test.ts b/packages/core/src/memory/dreamAgentPlanner.test.ts index edf8fcdb9d1..a84aac18000 100644 --- a/packages/core/src/memory/dreamAgentPlanner.test.ts +++ b/packages/core/src/memory/dreamAgentPlanner.test.ts @@ -9,9 +9,15 @@ 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 { Storage } from '../config/storage.js'; import type { ForkedAgentResult } from '../utils/forkedAgent.js'; import { runForkedAgent } from '../utils/forkedAgent.js'; -import { planManagedAutoMemoryDreamByAgent } from './dreamAgentPlanner.js'; +import { escapeShellArg, getShellConfiguration } from '../utils/shell-utils.js'; +import { + buildConsolidationTaskPrompt, + getTranscriptDir, + planManagedAutoMemoryDreamByAgent, +} from './dreamAgentPlanner.js'; import { ensureAutoMemoryScaffold } from './store.js'; vi.mock('../utils/forkedAgent.js', () => ({ @@ -38,6 +44,7 @@ describe('dreamAgentPlanner', () => { }); afterEach(async () => { + Storage.setRuntimeBaseDir(null); await fs.rm(tempDir, { recursive: true, force: true, @@ -46,6 +53,46 @@ describe('dreamAgentPlanner', () => { }); }); + it('returns project-scoped session transcript directory', () => { + const runtimeDir = path.join(tempDir, 'runtime'); + Storage.setRuntimeBaseDir(runtimeDir); + + expect(getTranscriptDir(projectRoot)).toBe( + path.join(new Storage(projectRoot).getProjectDir(), 'chats'), + ); + expect(getTranscriptDir(projectRoot)).toContain( + path.join(runtimeDir, 'projects'), + ); + expect(getTranscriptDir(projectRoot)).not.toContain( + `${path.sep}.qwen${path.sep}tmp${path.sep}`, + ); + }); + + it('shell-quotes the transcript directory in the grep example', () => { + const transcriptDir = path.join( + tempDir, + 'runtime dir; touch BAD', + 'projects', + '-tmp-project', + 'chats', + ); + const quotedTranscriptDir = escapeShellArg( + `${transcriptDir}${path.sep}`, + getShellConfiguration().shell, + ); + const prompt = buildConsolidationTaskPrompt( + path.join(tempDir, 'memory'), + transcriptDir, + ); + + expect(prompt).toContain( + `grep -rn "" ${quotedTranscriptDir} --include="*.jsonl" | tail -50`, + ); + expect(prompt).not.toContain( + `grep -rn "" ${transcriptDir}${path.sep} --include="*.jsonl" | tail -50`, + ); + }); + it('returns the forked agent result', async () => { const mockResult: ForkedAgentResult = { status: 'completed', diff --git a/packages/core/src/memory/dreamAgentPlanner.ts b/packages/core/src/memory/dreamAgentPlanner.ts index 8a61b619f6c..790d91cc43c 100644 --- a/packages/core/src/memory/dreamAgentPlanner.ts +++ b/packages/core/src/memory/dreamAgentPlanner.ts @@ -9,7 +9,8 @@ import { runForkedAgent, type ForkedAgentResult, } from '../utils/forkedAgent.js'; -import { getProjectHash, QWEN_DIR } from '../utils/paths.js'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; import { AUTO_MEMORY_INDEX_FILENAME, getAutoMemoryRoot, @@ -22,7 +23,11 @@ import type { PermissionDecision, } from '../permissions/types.js'; import { isShellCommandReadOnlyAST } from '../utils/shellAstParser.js'; -import { stripShellWrapper } from '../utils/shell-utils.js'; +import { + escapeShellArg, + getShellConfiguration, + stripShellWrapper, +} from '../utils/shell-utils.js'; const MAX_TURNS = 8; const MAX_TIME_MINUTES = 5; @@ -160,15 +165,21 @@ Rules: - 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`; +export function getTranscriptDir(projectRoot: string): string { + return path.join(new Storage(projectRoot).getProjectDir(), 'chats'); +} + +function quoteShellPathWithTrailingSeparator(dirPath: string): string { + return escapeShellArg(`${dirPath}${path.sep}`, getShellConfiguration().shell); } export function buildConsolidationTaskPrompt( memoryRoot: string, transcriptDir: string, ): string { + const quotedTranscriptDir = + quoteShellPathWithTrailingSeparator(transcriptDir); + 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).', @@ -187,7 +198,7 @@ export function buildConsolidationTaskPrompt( '', '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\``, + ` \`grep -rn "" ${quotedTranscriptDir} --include="*.jsonl" | tail -50\``, '', "Don't exhaustively read transcripts. Look only for things you already suspect matter.", '',