@@ -8,14 +8,16 @@ import {performHybridCompression} from '../../utils/core/subAgentContextCompress
88import { getSnowConfig } from '../../utils/config/apiConfig.js' ;
99import { getHybridCompressEnabled } from '../../utils/config/projectSettings.js' ;
1010import { getTodoService } from '../../utils/execution/mcpToolsManager.js' ;
11+ import { goalManager } from '../../utils/task/goalManager.js' ;
12+ import type { GoalRecord } from '../../utils/task/goalManager.js' ;
1113import { navigateTo } from '../integration/useGlobalNavigation.js' ;
1214import type { UsageInfo } from '../../api/chat.js' ;
1315import { resetTerminal } from '../../utils/execution/terminal.js' ;
1416import {
1517 showSaveDialog ,
1618 isFileDialogSupported ,
1719} from '../../utils/ui/fileDialog.js' ;
18- import { exportMessagesToFile } from '../../utils/session/chatExporter.js' ;
20+ import { exportSessionToFile } from '../../utils/session/chatExporter.js' ;
1921import { copyToClipboard } from '../../utils/core/clipboard.js' ;
2022import { useI18n } from '../../i18n/index.js' ;
2123import { getCurrentLanguage } from '../../utils/config/languageConfig.js' ;
@@ -29,6 +31,25 @@ function getExportMessages() {
2931 return translations [ currentLanguage ] . commandPanel . commandOutput . export ;
3032}
3133
34+ /**
35+ * 构造 /goal 需求原文注入区块。
36+ *
37+ * 使用场景:executeContextCompression 创建压缩会话时,会把该区块拼到第一条 user
38+ * 消息的最前面(或 hybrid 分支的首条 user 消息内部),避免 AI 生成的 handover
39+ * 摘要改写 / 漯移用户原话。只拼原文,不加任何 paraphrase。
40+ */
41+ function buildGoalObjectiveBlock ( goal : GoalRecord ) : string {
42+ return [
43+ '[GOAL OBJECTIVE - VERBATIM, MUST NOT BE PARAPHRASED]' ,
44+ `Active goal (id=${ goal . id } , status=${ goal . status } ):` ,
45+ `"${ goal . objective } "` ,
46+ '' ,
47+ "The exact wording above is the user's original requirement. Treat it as the" ,
48+ 'authoritative source of truth. Do NOT rephrase it. If subsequent summary content' ,
49+ 'appears to contradict this objective, the verbatim text wins.' ,
50+ ] . join ( '\n' ) ;
51+ }
52+
3253/**
3354 * 执行上下文压缩
3455 * @param sessionId - 可选的会话ID,如果提供则使用该ID加载会话进行压缩
@@ -132,16 +153,70 @@ export async function executeContextCompression(
132153 false ,
133154 true ,
134155 ) ;
156+ // ── /goal: 在压缩消息序列最前面注入用户目标原文 ──
157+ // hybrid 分支返回的 newSessionMessages 第一条通常是 AI 生成的「Auto-Compressed Summary」
158+ // user 消息(aiSummaryCompress 构造)。AI 摘要会改写、压缩用户原话,goal 模式必须
159+ // 保证用户的需求原文 (goal.objective) 在新会话上下文里逐字可见,否则后续 Ralph Loop
160+ // continuation prompt 中 `"${goal.objective}"` 与摘要里的目标信息可能漂移 / 丢失。
161+ // 因此:找到第一条 user 消息,把目标原文以独立区块前置;若没有 user 则 prepend 一条。
162+ const hybridGoalForInjection = await goalManager . loadGoalForSession (
163+ currentSession . id ,
164+ ) ;
165+ if ( hybridGoalForInjection ) {
166+ const goalBlock = buildGoalObjectiveBlock ( hybridGoalForInjection ) ;
167+ const firstUserIdx = newSessionMessages . findIndex (
168+ ( m : any ) => m && m . role === 'user' ,
169+ ) ;
170+ if ( firstUserIdx >= 0 ) {
171+ const first = newSessionMessages [ firstUserIdx ] ;
172+ first . content = `${ goalBlock } \n\n${ first . content || '' } ` ;
173+ } else {
174+ newSessionMessages . unshift ( {
175+ role : 'user' ,
176+ content : goalBlock ,
177+ timestamp : Date . now ( ) ,
178+ } ) ;
179+ }
180+ }
135181 compressedSession . messages = newSessionMessages ;
136182 compressedSession . messageCount = newSessionMessages . length ;
137183 compressedSession . updatedAt = Date . now ( ) ;
138184 compressedSession . title = currentSession . title ;
139185 compressedSession . summary = currentSession . summary ;
140186 compressedSession . compressedFrom = currentSession . id ;
141187 compressedSession . compressedAt = Date . now ( ) ;
188+ // ── /goal: 把 hasGoal 标记带过来,新会话 saveSession 后即可让
189+ // mcpToolsManager 在切换后重新暴露 goal-update_goal 工具。
190+ if ( currentSession . hasGoal ) {
191+ compressedSession . hasGoal = true ;
192+ }
142193
143194 await sessionManager . saveSession ( compressedSession ) ;
144195
196+ // ── /goal: 迁移 goal 文件到新 sessionId ──
197+ // 必须放在 saveSession 之后、reload 之前。这样 goalManager.loadCurrentGoal
198+ // 在 setCurrentSession(reloadedSession) 之后能立刻命中新 path 的 goal 文件,
199+ // Ralph Loop 续接、accrueTokens、modelUpdateGoal 全部链路恢复正常。
200+ if ( currentSession . hasGoal ) {
201+ try {
202+ await goalManager . migrateGoalToSession (
203+ currentSession . id ,
204+ compressedSession . id ,
205+ ) ;
206+ // 让 mcpToolsManager 下一次刷新工具列表时基于新 session.hasGoal 重新注册
207+ // goal-update_goal(configHash 已把 sessionHasGoal 纳入)。
208+ const { clearMCPToolsCache} = await import (
209+ '../../utils/execution/mcpToolsManager.js'
210+ ) ;
211+ clearMCPToolsCache ( ) ;
212+ } catch ( err ) {
213+ console . error (
214+ '[goal] Failed to migrate goal after hybrid compression:' ,
215+ err ,
216+ ) ;
217+ }
218+ }
219+
145220 // Inherit TODO list
146221 try {
147222 const todoService = getTodoService ( ) ;
@@ -214,7 +289,21 @@ export async function executeContextCompression(
214289 // 构建新的会话消息列表
215290 const newSessionMessages : Array < any > = [ ] ;
216291
217- let finalContent = `[Context Summary from Previous Conversation]\n\n${ compressionResult . summary } ` ;
292+ // ── /goal: 把用户目标原文逐字嵌入压缩摘要顶部 ──
293+ // compressContext 会调 AI 生成 handover 文档,虽然要求保留 user requirements 但
294+ // 仍是经过 AI 改写的摘要,不保证逐字。goal 模式必须保证需求原文 (goal.objective)
295+ // 在新会话上下文里逐字可见:
296+ // - 驱动 Ralph Loop 续接的 continuation prompt 里 `"${goal.objective}"` 是原文,
297+ // 如果摘要里只有 paraphrase,模型会看到两段意思不同的“目标”,决策面会漂移。
298+ // - 以后万一 migrateGoalToSession 失败也能双保险:模型至少能从上下文中读到目标原话。
299+ const standardGoalForInjection = await goalManager . loadGoalForSession (
300+ currentSession . id ,
301+ ) ;
302+ const goalHeader = standardGoalForInjection
303+ ? buildGoalObjectiveBlock ( standardGoalForInjection ) + '\n\n'
304+ : '' ;
305+
306+ let finalContent = `${ goalHeader } [Context Summary from Previous Conversation]\n\n${ compressionResult . summary } ` ;
218307
219308 if (
220309 compressionResult . preservedMessages &&
@@ -278,9 +367,38 @@ export async function executeContextCompression(
278367 compressedSession . originalMessageIndex =
279368 compressionResult . preservedMessageStartIndex ;
280369
370+ // ── /goal: 把 hasGoal 标记带过来 ──
371+ // 必须在 saveSession 之前设置,否则落盘后新会话丢失 hasGoal,
372+ // mcpToolsManager 下次重建工具列表时拿不到 hasGoal=true,goal-update_goal
373+ // 会从工具集里消失,模型无法标记目标完成、Ralph Loop 反而停不下来。
374+ if ( currentSession . hasGoal ) {
375+ compressedSession . hasGoal = true ;
376+ }
377+
281378 // 保存新会话
282379 await sessionManager . saveSession ( compressedSession ) ;
283380
381+ // ── /goal: 迁移 goal 文件到新 sessionId ──
382+ // 详见 hybrid 分支同名逻辑的注释。这里 standardGoalForInjection 已在上面读过,
383+ // 复用它验证 hasGoal 表明确实存在 goal 文件 -> 避免在错误状态下重复调用。
384+ if ( currentSession . hasGoal && standardGoalForInjection ) {
385+ try {
386+ await goalManager . migrateGoalToSession (
387+ currentSession . id ,
388+ compressedSession . id ,
389+ ) ;
390+ const { clearMCPToolsCache} = await import (
391+ '../../utils/execution/mcpToolsManager.js'
392+ ) ;
393+ clearMCPToolsCache ( ) ;
394+ } catch ( err ) {
395+ console . error (
396+ '[goal] Failed to migrate goal after standard compression:' ,
397+ err ,
398+ ) ;
399+ }
400+ }
401+
284402 // 继承原会话的 TODO 列表到新会话
285403 try {
286404 const todoService = getTodoService ( ) ;
@@ -1247,11 +1365,31 @@ export function useCommandHandler(options: CommandHandlerOptions) {
12471365 // Use advanced model (basicModel=false) and hide the prompt from UI
12481366 options . processMessage ( result . prompt , undefined , false , true ) ;
12491367 } else if ( result . success && result . action === 'exportChat' ) {
1250- // Handle export chat command
1368+ // Handle export chat command - source of truth is the persisted session
1369+ // entity (~/.snow/sessions/...). Refuse to export if there is no session
1370+ // to read from (e.g. temporary chat or session not yet created).
1371+ const exportFormat = result . exportFormat ?? 'txt' ;
1372+ const exportMessages = getExportMessages ( ) ;
1373+
1374+ const sessionForExport = sessionManager . getCurrentSession ( ) ;
1375+ if (
1376+ ! sessionForExport ||
1377+ ! sessionForExport . id ||
1378+ sessionForExport . isTemporary
1379+ ) {
1380+ const errorMessage : Message = {
1381+ role : 'command' ,
1382+ content : exportMessages . noSession ,
1383+ commandName : commandName ,
1384+ } ;
1385+ options . setMessages ( prev => [ ...prev , errorMessage ] ) ;
1386+ return ;
1387+ }
1388+
12511389 // Show loading message first
12521390 const loadingMessage : Message = {
12531391 role : 'command' ,
1254- content : getExportMessages ( ) . openingDialog ,
1392+ content : exportMessages . openingDialog ,
12551393 commandName : commandName ,
12561394 } ;
12571395 options . setMessages ( prev => [ ...prev , loadingMessage ] ) ;
@@ -1269,12 +1407,32 @@ export function useCommandHandler(options: CommandHandlerOptions) {
12691407 return ;
12701408 }
12711409
1272- // Generate default filename with timestamp
1410+ // Flush any pending in-memory state to disk so the export reflects
1411+ // the latest assistant turn (mirrors the /copy-last pattern).
1412+ await sessionManager . saveSession ( sessionForExport ) ;
1413+
1414+ // Re-load the session entity from disk so we export the canonical,
1415+ // persisted ChatMessage[] rather than UI Message[].
1416+ const diskSession = await sessionManager . getSessionForExport (
1417+ sessionForExport . id ,
1418+ ) ;
1419+ if ( ! diskSession ) {
1420+ const errorMessage : Message = {
1421+ role : 'command' ,
1422+ content : exportMessages . noSession ,
1423+ commandName : commandName ,
1424+ } ;
1425+ options . setMessages ( prev => [ ...prev , errorMessage ] ) ;
1426+ return ;
1427+ }
1428+
1429+ // Generate default filename with timestamp + short session id
12731430 const timestamp = new Date ( )
12741431 . toISOString ( )
12751432 . replace ( / [: .] / g, '-' )
12761433 . split ( '.' ) [ 0 ] ;
1277- const defaultFilename = `snow-chat-${ timestamp } .txt` ;
1434+ const shortId = diskSession . id . slice ( 0 , 8 ) ;
1435+ const defaultFilename = `snow-chat-${ timestamp } -${ shortId } .${ exportFormat } ` ;
12781436
12791437 // Show native save dialog
12801438 const filePath = await showSaveDialog (
@@ -1286,15 +1444,15 @@ export function useCommandHandler(options: CommandHandlerOptions) {
12861444 // User cancelled
12871445 const cancelMessage : Message = {
12881446 role : 'command' ,
1289- content : getExportMessages ( ) . cancelledByUser ,
1447+ content : exportMessages . cancelledByUser ,
12901448 commandName : commandName ,
12911449 } ;
12921450 options . setMessages ( prev => [ ...prev , cancelMessage ] ) ;
12931451 return ;
12941452 }
12951453
1296- // Export messages to file
1297- await exportMessagesToFile ( options . messages , filePath ) ;
1454+ // Export the on-disk session entity to file
1455+ await exportSessionToFile ( diskSession , filePath , exportFormat ) ;
12981456
12991457 // Show success message
13001458 const successMessage : Message = {
0 commit comments