feat: add session export with full and compressed modes (#507)

Add export functionality that allows users to download session data
as JSON or plain text, with optional LLM-based context compression
for long conversations. Includes UI controls in chat panel, session
list, and history view, plus i18n strings for all 8 locales.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-07 13:49:57 +08:00
committed by GitHub
parent c0ad8c907b
commit 173307ef28
18 changed files with 554 additions and 14 deletions
@@ -0,0 +1,149 @@
/**
* Export Compressor
*
* Compresses session context for export purposes.
* Reuses the LLM summarization logic from ChatContextCompressor
* but does NOT read or write compression snapshots.
* Always forces LLM compression regardless of token count.
* No tail reservation — all messages are compressed.
*/
import { logger } from '../../services/logger'
import {
type ChatMessage,
type CompressionConfig,
type CompressedResult,
DEFAULT_COMPRESSION_CONFIG,
countTokens,
serializeForSummary,
buildFullPrompt,
buildIncrementalPrompt,
buildConversationHistory,
callSummarizer,
} from './index'
import { getCompressionSnapshot } from '../../db/hermes/compression-snapshot'
export class ExportCompressor {
private config: CompressionConfig
constructor(opts?: { config?: Partial<CompressionConfig> }) {
this.config = { ...DEFAULT_COMPRESSION_CONFIG, ...opts?.config }
}
async compress(
messages: ChatMessage[],
upstream: string,
apiKey: string | undefined,
sessionId?: string,
profile?: string,
): Promise<CompressedResult> {
const total = messages.length
const meta: CompressedResult['meta'] = {
totalMessages: total,
compressed: false,
llmCompressed: false,
summaryTokenEstimate: 0,
verbatimCount: 0,
compressedStartIndex: -1,
}
// Read snapshot for incremental context, but never write
const snapshot = sessionId ? getCompressionSnapshot(sessionId) : null
if (snapshot) {
logger.info(
'[export-compressor] session=%s: incremental compress with existing snapshot at index %d',
sessionId, snapshot.lastMessageIndex,
)
return this.incrementalCompress(
messages, snapshot, upstream, apiKey, meta, profile,
)
}
logger.info(
'[export-compressor] session=%s: full compress %d messages',
sessionId, total,
)
return this.fullCompress(messages, upstream, apiKey, meta, profile)
}
private async incrementalCompress(
messages: ChatMessage[],
snapshot: { summary: string; lastMessageIndex: number },
upstream: string,
apiKey: string | undefined,
meta: CompressedResult['meta'],
profile?: string,
): Promise<CompressedResult> {
const { summary: previousSummary, lastMessageIndex } = snapshot
const newMessages = messages.slice(lastMessageIndex + 1)
let summary: string | null = null
try {
const contentToSummarize = serializeForSummary(newMessages)
const prompt = buildIncrementalPrompt(previousSummary, contentToSummarize, this.config.summaryBudget)
const history = buildConversationHistory(newMessages)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, profile)
logger.info('[export-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
} catch (err: any) {
logger.warn('[export-compressor] incremental-llm failed: %s — reusing previous summary', err.message)
summary = previousSummary
}
const summaryText = summary || previousSummary
return {
messages: [{ role: 'user', content: summaryText }],
meta: {
...meta,
compressed: true,
llmCompressed: true,
summaryTokenEstimate: countTokens(summaryText),
verbatimCount: 0,
},
}
}
private async fullCompress(
messages: ChatMessage[],
upstream: string,
apiKey: string | undefined,
meta: CompressedResult['meta'],
profile?: string,
): Promise<CompressedResult> {
if (messages.length === 0) {
return { messages: [], meta }
}
let summary: string | null = null
try {
const contentToSummarize = serializeForSummary(messages)
const prompt = buildFullPrompt(contentToSummarize, this.config.summaryBudget)
const history = buildConversationHistory(messages)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, profile)
logger.info('[export-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
} catch (err: any) {
logger.warn('[export-compressor] full-llm failed: %s', err.message)
}
if (!summary) {
return { messages, meta }
}
return {
messages: [{ role: 'user', content: summary }],
meta: {
...meta,
compressed: true,
llmCompressed: true,
summaryTokenEstimate: countTokens(summary),
verbatimCount: 0,
},
}
}
}
@@ -172,7 +172,7 @@ Be specific with file paths, commands, line numbers, and results.]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]`
function buildFullPrompt(contentToSummarize: string, summaryBudget: number): string {
export function buildFullPrompt(contentToSummarize: string, summaryBudget: number): string {
return `You are a summarization agent creating a context checkpoint.
Your output will be injected as reference material for a DIFFERENT
assistant that continues the conversation.
@@ -194,7 +194,7 @@ Target ~${summaryBudget} tokens. Be CONCRETE — include file paths, command out
Write only the summary body. Do not include any preamble or prefix.`
}
function buildIncrementalPrompt(previousSummary: string, contentToSummarize: string, summaryBudget: number): string {
export function buildIncrementalPrompt(previousSummary: string, contentToSummarize: string, summaryBudget: number): string {
return `You are a summarization agent creating a context checkpoint.
Your output will be injected as reference material for a DIFFERENT
assistant that continues the conversation.
@@ -229,7 +229,7 @@ Write only the summary body. Do not include any preamble or prefix.`
// ─── Pre-cleaning ───────────────────────────────────────
function serializeForSummary(messages: ChatMessage[]): string {
export function serializeForSummary(messages: ChatMessage[]): string {
const parts: string[] = []
function contentToString(content: string | ContentBlock[]): string {
@@ -272,13 +272,13 @@ function serializeForSummary(messages: ChatMessage[]): string {
* Convert messages to conversation history format for LLM API.
* Tool calls are converted to text format within assistant messages.
*/
function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string; content: string }> {
export function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string; content: string }> {
const result: Array<{ role: string; content: string }> = []
for (const msg of messages) {
if (msg.role === 'tool') {
// Convert tool result to text and append to previous assistant message
const toolText = `[Tool result: ${msg.name || 'unknown'}]\n${(msg.content || '').slice(0, 500)}${msg.content && msg.content.length > 500 ? '...' : ''}`
const toolText = `[Tool result: ${msg.name || 'unknown'}]\n${(msg.content || '').slice(0, 4000)}${msg.content && msg.content.length > 4000 ? '...' : ''}`
// Find the last assistant message and append to it
const lastAssistant = result.findLast(m => m.role === 'assistant')
if (lastAssistant) {
@@ -291,7 +291,7 @@ function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string
// Include tool calls in assistant message
const toolsInfo = msg.tool_calls.map(tc => {
let args = tc.function.arguments
if (args.length > 1000) args = args.slice(0, 1000) + '...'
if (args.length > 4000) args = args.slice(0, 4000) + '...'
return `[Calling tool: ${tc.function.name} with arguments: ${args}]`
}).join('\n')
const content = msg.content ? `${msg.content}\n\n${toolsInfo}` : toolsInfo
@@ -313,6 +313,7 @@ function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string
}
}
}
if (contentStr.length > 4000) contentStr = contentStr.slice(0, 4000) + '...'
result.push({ role: 'user', content: contentStr })
} else if (msg.role === 'assistant' || msg.role === 'system') {
let contentStr = ''
@@ -330,6 +331,7 @@ function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string
}
}
}
if (contentStr.length > 4000) contentStr = contentStr.slice(0, 4000) + '...'
result.push({ role: msg.role, content: contentStr })
}
// Skip other roles
@@ -338,7 +340,7 @@ function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string
return result
}
function pruneOldToolResults(messages: ChatMessage[], keepRecentCount: number): ChatMessage[] {
export function pruneOldToolResults(messages: ChatMessage[], keepRecentCount: number): ChatMessage[] {
if (messages.length <= keepRecentCount) return messages
const tail = messages.slice(-keepRecentCount)
@@ -365,7 +367,7 @@ function pruneOldToolResults(messages: ChatMessage[], keepRecentCount: number):
// ─── LLM Summarization ──────────────────────────────────
async function callSummarizer(
export async function callSummarizer(
upstream: string,
apiKey: string | undefined,
prompt: string,