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:
@@ -10,6 +10,8 @@ import {
|
||||
renameSession as localRenameSession,
|
||||
useLocalSessionStore,
|
||||
} from '../../db/hermes/session-store'
|
||||
import { ExportCompressor } from '../../lib/context-compressor/export-compressor'
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
import { deleteUsage, getUsage, getUsageBatch, getLocalUsageStats } from '../../db/hermes/usage-store'
|
||||
import type { LocalUsageStats, UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usage-store'
|
||||
import { getModelContextLength } from '../../services/hermes/model-context'
|
||||
@@ -539,6 +541,90 @@ export async function listWorkspaceFolders(ctx: any) {
|
||||
}
|
||||
}
|
||||
|
||||
const exportCompressor = new ExportCompressor()
|
||||
|
||||
export async function exportSession(ctx: any) {
|
||||
let session: any = null
|
||||
|
||||
if (useLocalSessionStore()) {
|
||||
session = localGetSessionDetail(ctx.params.id)
|
||||
} else {
|
||||
try {
|
||||
session = await getSessionDetailFromDb(ctx.params.id)
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Hermes Session DB: export detail query failed, falling back to CLI')
|
||||
}
|
||||
if (!session) {
|
||||
session = await hermesCli.getSession(ctx.params.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Session not found' }
|
||||
return
|
||||
}
|
||||
|
||||
const mode = (ctx.query.mode as string) || 'full'
|
||||
const ext = (ctx.query.ext as string) || (mode === 'compressed' ? 'txt' : 'json')
|
||||
const title = session.title || 'session'
|
||||
const safeName = title.replace(/[^a-zA-Z0-9一-鿿_-]/g, '_').slice(0, 50)
|
||||
const filename = `${safeName}_${ctx.params.id.slice(0, 8)}.${ext}`
|
||||
|
||||
if (mode === 'compressed') {
|
||||
const result = await compressSession(session)
|
||||
if (ext === 'json') {
|
||||
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
|
||||
ctx.set('Content-Type', 'application/json')
|
||||
ctx.body = JSON.stringify({ id: session.id, title: session.title, ...result.meta, messages: result.messages }, null, 2)
|
||||
} else {
|
||||
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
|
||||
ctx.set('Content-Type', 'text/plain; charset=utf-8')
|
||||
ctx.body = serializeAsText(session.title, result.messages)
|
||||
}
|
||||
} else {
|
||||
if (ext === 'txt') {
|
||||
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
|
||||
ctx.set('Content-Type', 'text/plain; charset=utf-8')
|
||||
ctx.body = serializeAsText(session.title, session.messages || [])
|
||||
} else {
|
||||
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
|
||||
ctx.set('Content-Type', 'application/json')
|
||||
ctx.body = JSON.stringify(session, null, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function compressSession(session: any) {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
const profile = getActiveProfileName()
|
||||
const upstream = mgr ? mgr.getUpstream(profile).replace(/\/$/, '') : ''
|
||||
const apiKey = mgr ? mgr.getApiKey(profile) || undefined : undefined
|
||||
const messages = (session.messages || []).map((m: any) => ({
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
tool_calls: m.tool_calls,
|
||||
tool_call_id: m.tool_call_id,
|
||||
name: m.tool_name,
|
||||
reasoning_content: m.reasoning,
|
||||
}))
|
||||
|
||||
return exportCompressor.compress(messages, upstream, apiKey, session.id, profile)
|
||||
}
|
||||
|
||||
function serializeAsText(title: string | null, messages: any[]): string {
|
||||
const lines: string[] = [`# ${title || 'Untitled'}`, '']
|
||||
for (const msg of messages) {
|
||||
const role = msg.role || 'unknown'
|
||||
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
|
||||
const ts = msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : ''
|
||||
lines.push(`[${role}]${ts ? ' ' + ts : ''}`)
|
||||
lines.push(content || '')
|
||||
lines.push('')
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export async function getConversationMessagesPaginated(ctx: any) {
|
||||
const offset = ctx.query.offset ? parseInt(ctx.query.offset as string, 10) : 0
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : 50
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@ sessionRoutes.get('/api/hermes/sessions/usage', ctrl.usageBatch)
|
||||
sessionRoutes.get('/api/hermes/usage/stats', ctrl.usageStats)
|
||||
sessionRoutes.get('/api/hermes/sessions/context-length', ctrl.contextLength)
|
||||
sessionRoutes.get('/api/hermes/sessions/:id', ctrl.get)
|
||||
sessionRoutes.get('/api/hermes/sessions/:id/export', ctrl.exportSession)
|
||||
sessionRoutes.get('/api/hermes/sessions/:id/usage', ctrl.usageSingle)
|
||||
sessionRoutes.delete('/api/hermes/sessions/:id', ctrl.remove)
|
||||
sessionRoutes.post('/api/hermes/sessions/batch-delete', ctrl.batchRemove)
|
||||
|
||||
Reference in New Issue
Block a user