feat: optimize context compression and session sync (#402)

* feat: optimize context compression and session sync

Context Compressor:
- Add ContentBlock type support for user messages with images/files
- Enhance buildConversationHistory to handle ContentBlock[] format
- Update serializeForSummary to convert ContentBlock[] to text
- Support mixed content (text, images, files) in history compression

Session Sync:
- Replace loop of addMessage with batch addMessages (transaction-protected)
- Use addMessages for atomic bulk insert with BEGIN/COMMIT
- Ensure data consistency before calcAndUpdateUsage reads DB

Chat Run Socket:
- Remove convertHistoryFormat before compression (compressor handles raw format)
- Compressor now processes original messages for better tool message handling
- Improve compression quality by preserving original message structure

Benefits:
- Better compression quality with proper tool message recognition
- Improved performance with batch DB inserts
- Strong data consistency with transaction protection
- Support for rich content (images/files) in conversation history

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: bump version to 0.5.7

- Prepare for release with context compression and session sync optimizations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add 0.5.7 changelog entries

- Optimize context compression to support rich content (images, files)
- Improve session sync with batch inserts and transaction protection
- Fix usage.updated event reception for accurate token tracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-02 20:12:58 +08:00
committed by GitHub
parent 8af1951f13
commit 4a9967ebdd
5 changed files with 129 additions and 125 deletions
@@ -25,9 +25,16 @@ import { getDb } from '../../db/index'
// ─── Types ───────────────────────────────────────────────
export interface ContentBlock {
type: 'text' | 'image' | 'file'
text?: string
path?: string
source?: { type: string; media_type?: string; data?: string }
}
export interface ChatMessage {
role: string
content: string
content: string | ContentBlock[]
tool_calls?: Array<{ id: string; type: string; function: { name: string; arguments: string } }>
tool_call_id?: string
name?: string
@@ -224,9 +231,23 @@ Write only the summary body. Do not include any preamble or prefix.`
function serializeForSummary(messages: ChatMessage[]): string {
const parts: string[] = []
function contentToString(content: string | ContentBlock[]): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content.map(block => {
if (block.type === 'text') return block.text || ''
if (block.type === 'image') return `[Image: ${block.path || ''}]`
if (block.type === 'file') return `[File: ${block.path || ''}]`
return ''
}).join('')
}
return ''
}
for (const msg of messages) {
const role = msg.role === 'tool' ? `[tool:${msg.name || 'unknown'}]` : msg.role
let content = msg.content || ''
let content = contentToString(msg.content || '')
if (msg.role === 'tool' && content.length > 5500) {
content = content.slice(0, 4000) + '\n... [truncated]\n...' + content.slice(-1500)
@@ -275,8 +296,41 @@ function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string
}).join('\n')
const content = msg.content ? `${msg.content}\n\n${toolsInfo}` : toolsInfo
result.push({ role: msg.role, content })
} else if (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system') {
result.push({ role: msg.role, content: msg.content || '' })
} else if (msg.role === 'user') {
// Handle ContentBlock[] format: { type: 'text', text: '...' } or { type: 'image', path: '...' }
let contentStr = ''
const content = msg.content || ''
if (typeof content === 'string') {
contentStr = content
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text') {
contentStr += block.text || ''
} else if (block.type === 'image') {
contentStr += `[Image: ${block.path || ''}]`
} else if (block.type === 'file') {
contentStr += `[File: ${block.path || ''}]`
}
}
}
result.push({ role: 'user', content: contentStr })
} else if (msg.role === 'assistant' || msg.role === 'system') {
let contentStr = ''
const content = msg.content
if (typeof content === 'string') {
contentStr = content
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text') {
contentStr += block.text || ''
} else if (block.type === 'image') {
contentStr += `[Image: ${block.path || ''}]`
} else if (block.type === 'file') {
contentStr += `[File: ${block.path || ''}]`
}
}
}
result.push({ role: msg.role, content: contentStr })
}
// Skip other roles
}
@@ -292,7 +346,15 @@ function pruneOldToolResults(messages: ChatMessage[], keepRecentCount: number):
const pruned = head.map(msg => {
if (msg.role !== 'tool') return msg
const content = msg.content || ''
let content = ''
if (typeof msg.content === 'string') {
content = msg.content
} else if (Array.isArray(msg.content)) {
content = msg.content.map(block => {
if (block.type === 'text') return block.text || ''
return `[${block.type}]`
}).join('')
}
const preview = content.slice(0, 100).replace(/\n/g, ' ')
const truncated = content.length > 100 ? '...' : ''
return { ...msg, content: `[${msg.name || 'tool'}] ${preview}${truncated}` }
@@ -512,7 +574,7 @@ export class ChatContextCompressor {
}
const result: ChatMessage[] = [
{ role: 'system', content: SUMMARY_PREFIX + '\n\n' + summary },
{ role: 'user', content: SUMMARY_PREFIX + '\n\n' + summary },
...tail,
]
@@ -575,7 +637,7 @@ export class ChatContextCompressor {
const result: ChatMessage[] = []
if (summary) {
result.push({ role: 'system', content: SUMMARY_PREFIX + '\n\n' + summary })
result.push({ role: 'user', content: SUMMARY_PREFIX + '\n\n' + summary })
if (sessionId) {
saveCompressionSnapshot(sessionId, summary, tailStart - 1, total)
}