Skip to content

Commit acd59b1

Browse files
committed
feat: add goal injection to context compression and enhance export functionality
- Add goal objective injection to both hybrid and standard compression branches to preserve user's original requirement text verbatim in compressed sessions - Implement goal file migration (migrateGoalToSession) to maintain goal continuity across session compression - Enhance /export command to support multiple formats (txt, md, html, json) with format validation - Refactor chatExporter to work with persisted Session entities instead of UI messages, ensuring exports reflect canonical on-disk state - Add getSessionForExport method to sessionManager for read-only session access during export - Improve export filename to include short session ID and format extension - Add validation to prevent export from temporary or unsaved sessions - Ensure goal hasGoal flag is preserved during compression and MCP tools cache is cleared after goal migration - Fix PowerShell encoding in file dialog to properly handle non-ASCII paths - Update i18n strings for export error messages across en, zh, zh-TW locales
1 parent 97e4c26 commit acd59b1

12 files changed

Lines changed: 803 additions & 104 deletions

File tree

source/hooks/conversation/useCommandHandler.ts

Lines changed: 167 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ import {performHybridCompression} from '../../utils/core/subAgentContextCompress
88
import {getSnowConfig} from '../../utils/config/apiConfig.js';
99
import {getHybridCompressEnabled} from '../../utils/config/projectSettings.js';
1010
import {getTodoService} from '../../utils/execution/mcpToolsManager.js';
11+
import {goalManager} from '../../utils/task/goalManager.js';
12+
import type {GoalRecord} from '../../utils/task/goalManager.js';
1113
import {navigateTo} from '../integration/useGlobalNavigation.js';
1214
import type {UsageInfo} from '../../api/chat.js';
1315
import {resetTerminal} from '../../utils/execution/terminal.js';
1416
import {
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';
1921
import {copyToClipboard} from '../../utils/core/clipboard.js';
2022
import {useI18n} from '../../i18n/index.js';
2123
import {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 = {

source/hooks/ui/useCommandPanel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const COMMAND_ARGS_HINTS: Record<string, string> = {
4545
btw: '<question>',
4646
deepresearch: '<prompt>',
4747
connect: '[apiUrl]',
48+
export: '[txt|md|html|json]',
4849
};
4950

5051
// 指令参数可选值列表:用于 Tab 弹出参数选择面板
@@ -59,6 +60,7 @@ export const COMMAND_ARGS_OPTIONS: Record<string, string[]> = {
5960
'role-subagent': ['-l', '-d'],
6061
'subagent-depth': ['status'],
6162
loop: ['list', 'tasks', 'cancel'],
63+
export: ['txt', 'md', 'html', 'json'],
6264
};
6365

6466
export function useCommandPanel(buffer: TextBuffer, isProcessing = false) {

source/i18n/lang/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,10 @@ export const en: TranslationKeys = {
694694
exporting: 'Exporting conversation...',
695695
openingDialog: 'Opening file save dialog...',
696696
cancelledByUser: 'Export cancelled by user.',
697+
invalidFormat:
698+
'Invalid export format: {format}. Supported: txt, md, html.',
699+
noSession:
700+
'No active session to export. Start a conversation first, then try /export again.',
697701
},
698702
// IDE command messages
699703
ide: {

source/i18n/lang/zh-TW.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,8 @@ export const zhTW: TranslationKeys = {
651651
exporting: '正在導出對話...',
652652
openingDialog: '正在開啟檔案儲存對話方塊...',
653653
cancelledByUser: '導出已被使用者取消。',
654+
invalidFormat: '不支援的匯出格式:{format}。可選:txt、md、html。',
655+
noSession: '目前沒有可匯出的會話,請先進行一輪對話後再試 /export。',
654656
},
655657
// IDE 命令訊息
656658
ide: {

source/i18n/lang/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,8 @@ export const zh: TranslationKeys = {
650650
exporting: '正在导出对话...',
651651
openingDialog: '正在打开文件保存对话框...',
652652
cancelledByUser: '导出已被用户取消。',
653+
invalidFormat: '不支持的导出格式:{format}。可选:txt、md、html。',
654+
noSession: '当前没有可导出的会话,请先进行一轮对话后再试 /export。',
653655
},
654656
// IDE 命令消息
655657
ide: {

source/i18n/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,8 @@ export type TranslationKeys = {
640640
exporting: string;
641641
openingDialog: string;
642642
cancelledByUser: string;
643+
invalidFormat: string;
644+
noSession: string;
643645
};
644646
// IDE command messages
645647
ide: {

source/utils/commands/export.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,50 @@ import {
55
import {getCurrentLanguage} from '../config/languageConfig.js';
66
import {translations} from '../../i18n/index.js';
77

8+
const SUPPORTED_FORMATS = ['txt', 'md', 'html', 'json'] as const;
9+
type ExportFormat = (typeof SUPPORTED_FORMATS)[number];
10+
811
// Get translated messages
912
function getMessages() {
1013
const currentLanguage = getCurrentLanguage();
1114
return translations[currentLanguage].commandPanel.commandOutput.export;
1215
}
1316

14-
// Export command handler - exports chat conversation to text file
17+
function parseFormat(args?: string): ExportFormat | {error: string} {
18+
const raw = (args ?? '').trim().toLowerCase();
19+
if (!raw) {
20+
return 'txt';
21+
}
22+
// Strip a leading dot if user typed `.md` etc.
23+
const normalized = raw.startsWith('.') ? raw.slice(1) : raw;
24+
if ((SUPPORTED_FORMATS as readonly string[]).includes(normalized)) {
25+
return normalized as ExportFormat;
26+
}
27+
const messages = getMessages();
28+
const template =
29+
messages.invalidFormat ??
30+
'Invalid export format: {format}. Supported: txt, md, html.';
31+
return {
32+
error: template.replace('{format}', raw),
33+
};
34+
}
35+
36+
// Export command handler - exports chat conversation to txt / md / html
1537
registerCommand('export', {
16-
execute: (): CommandResult => {
38+
execute: (args?: string): CommandResult => {
1739
const messages = getMessages();
40+
const parsed = parseFormat(args);
41+
if (typeof parsed !== 'string') {
42+
return {
43+
success: false,
44+
message: parsed.error,
45+
};
46+
}
1847
return {
1948
success: true,
2049
action: 'exportChat',
2150
message: messages.exporting,
51+
exportFormat: parsed,
2252
};
2353
},
2454
});

source/utils/execution/commandExecutor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface CommandResult {
7171
alreadyConnected?: boolean; // For /ide command to indicate if VSCode is already connected
7272
forceReindex?: boolean; // For /reindex -force to delete existing database and rebuild
7373
apiUrl?: string; // For /connect command to pass API URL
74+
exportFormat?: 'txt' | 'md' | 'html' | 'json'; // For /export command to choose output format
7475
}
7576

7677
export interface CommandHandler {

0 commit comments

Comments
 (0)