Files
Hermes-ui/packages/server/src/services/hermes/session-sync.ts
T
ekko cd14bb1963 feat: add Anthropic format conversion for chat runs and improvements (#347)
* fix: improve chat compression and tool display

Context Compression Fixes:
- Remove duplicate token calculation in compress()
- Simplify compress() to only execute compression, not judge
- Add buildConversationHistory() to preserve tool calls in LLM context
- Remove unused estimateMessagesTokens() and contextLength parameter
- Move all judgment logic to chat-run-socket.ts (uses accurate DB tokens)

Tool Call Display Improvements:
- Add tool execution duration display (format: 1.272s)
- Add success/error status icons with circular backgrounds
- Replace text error with SVG icon (X in red circle)
- Replace old checkmark with polished green checkmark icon
- Add i18n key 'chat.executionDuration' for all locales

Bug Fixes:
- Fix streaming-indicator stuck by adding try-finally in handleEvent
- Add debug logging for compression flow diagnosis
- Fix template syntax error in MessageList.vue

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

* feat(chat): convert conversation history to Anthropic format before sending to Gateway

- Add convertToAnthropicFormat() to transform OpenAI format to Anthropic format
- Handle DeepSeek reasoning_content in thinking blocks
- Properly convert tool_use and tool_result blocks
- Add convertFromAnthropicFormat() for parsing SSE responses
- Handle stringified Python arrays in resume messages
- Record debug history files for troubleshooting (original vs converted)
- Fix tool_call_id validation to prevent empty ID errors
- Clean internal Hermes fields (call_id, response_item_id) from tool_calls

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

* fix(chat): optimize message parsing and add debug logging

- Only check for stringified arrays in assistant messages (performance)
- Improve parsing error handling: keep original content on parse failure
- Add debug logging for upstream events (reasoning/thinking tracking)
- Log run.completed event keys for troubleshooting

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

* feat(chat): add message pagination and reasoning sync improvements

**Message Pagination:**
- Add getSessionDetailPaginated() for paginated message loading
- Query with DESC order then reverse in code for optimal performance
- Remove listSessionsPaginated() (not needed)

**Reasoning Sync:**
- Add bidirectional reasoning merge in syncFromHermes
  - Memory → DB: preserve streamed reasoning from SSE events
  - DB → Memory: restore reasoning if Hermes Gateway fixes storage
- Send resumed event after sync completes with complete messages
- Fix reasoning field inconsistency: use unified 'reasoning' field

**Message Parsing:**
- Only parse stringified arrays for assistant messages (performance)
- Improve parse error handling: keep original content on failure
- Add debug logging for upstream reasoning/thinking events

**Bug Fixes:**
- Fix reasoning content display: now works on both SSE and resume
- Ensure reasoning is preserved across page refreshes via sync + resumed event

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

* fix: increase default pagination limit for messages to 500

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

* fix: remove auto-resumed event trigger and clean up debug code

- Remove automatic resumed event trigger in syncFromHermes to avoid timing issues
- Clean up unused imports (fs, join)
- Remove debug history file logging code
- Fix socket parameter passing in handleAbort, markCompleted, and syncFromHermes
- Change usage emit from room broadcast to socket-only emit
- Remove console.log debug statement

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

* fix: use reasoning field in convertToAnthropicFormat

Change convertToAnthropicFormat to read from reasoning field instead
of reasoning_content for consistency with database schema and frontend.

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

* feat: parse stringified array content and improve logs

- Parse stringified array format in run.completed to extract thinking/text/tool_use
- Send parsed content to frontend via parsed_content/parsed_reasoning/parsed_tool_calls
- Frontend updates last assistant message with parsed content
- Remove ellipsis from log messages, show full content
- Add detailed logging for conversion and parsing

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

* fix: move finalOutputTrimmed outside else block

* fix(chat): handle double-serialized content in resumeSession

- Remove outer quotes before parsing stringified array format
- Updated changelog for v0.5.2 and v0.5.3 with multilingual support
- Fixed message pagination with DESC query + array reverse

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

* fix(chat): improve error logging for resume parsing

- Add detailed logging for double-serialized content parsing
- Log content preview when parsing fails to diagnose issues

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

* revert(chat): use simple Python-to-JSON replacement

- Revert to simple .replace(/'/g, '"') approach
- Parsing failures will keep original content as-is

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 16:40:37 +08:00

190 lines
6.7 KiB
TypeScript

/**
* Sync Hermes sessions from all profiles on startup.
* Reads api_server sessions from Hermes state.db and imports into local DB.
* Only runs when local DB is empty (first startup).
*
* Uses sessions-db.ts query logic to properly aggregate session chains.
*/
import { readdirSync, existsSync } from 'fs'
import { resolve, join } from 'path'
import { homedir } from 'os'
import { randomBytes } from 'crypto'
import { getProfileDir } from './hermes-profile'
import { createSession, addMessage, updateSession } from '../../db/hermes/session-store'
import { getDb } from '../../db/index'
import { logger } from '../logger'
import { listSessionSummaries as listHermesSessionSummaries } from '../../db/hermes/sessions-db'
const HERMES_BASE = resolve(homedir(), '.hermes')
const PROFILES_DIR = join(HERMES_BASE, 'profiles')
/**
* Generate a UUID v4 without external dependencies
*/
function generateUuid(): string {
const bytes = randomBytes(16)
bytes[6] = (bytes[6]! & 0x0f) | 0x40 // Version 4
bytes[8] = (bytes[8]! & 0x3f) | 0x80 // Variant 10
return [
bytes.subarray(0, 4).toString('hex'),
bytes.subarray(4, 6).toString('hex'),
bytes.subarray(6, 8).toString('hex'),
bytes.subarray(8, 10).toString('hex'),
bytes.subarray(10, 16).toString('hex'),
].join('-')
}
/**
* Get all available profile names including 'default'
*/
function getAllProfiles(): string[] {
const profiles = ['default']
if (existsSync(PROFILES_DIR)) {
const dirs = readdirSync(PROFILES_DIR, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
profiles.push(...dirs)
}
return profiles
}
/**
* Sync api_server sessions from a single profile.
* Uses sessions-db.ts query logic to properly aggregate session chains.
*/
async function syncProfileSessions(profile: string): Promise<{
synced: number
errors: string[]
}> {
const result = { synced: 0, errors: [] as string[] }
try {
// Use listSessionSummaries to get aggregated session chains
// This returns only root sessions with aggregated stats from the entire chain
const summaries = await listHermesSessionSummaries('api_server', 10000, profile)
logger.info(`[session-sync] profile '${profile}': found ${summaries.length} aggregated session chains`)
for (const hermesSession of summaries) {
try {
// Generate new session ID for local DB
const newSessionId = generateUuid()
// Create session in local DB
createSession({
id: newSessionId,
profile,
model: hermesSession.model,
title: hermesSession.title || undefined,
})
// Get full detail including all messages from the session chain
const { getSessionDetailFromDbWithProfile } = await import('../../db/hermes/sessions-db')
const detail = await getSessionDetailFromDbWithProfile(hermesSession.id, profile)
if (!detail || !detail.messages) {
result.errors.push(`session ${hermesSession.id}: failed to load messages`)
logger.warn(`[session-sync] failed to load messages for session ${hermesSession.id}`)
continue
}
// Insert all messages from the entire chain
for (const msg of detail.messages) {
addMessage({
session_id: newSessionId,
role: msg.role,
content: msg.content,
tool_call_id: msg.tool_call_id,
tool_calls: msg.tool_calls,
tool_name: msg.tool_name,
timestamp: msg.timestamp,
token_count: msg.token_count,
finish_reason: msg.finish_reason,
reasoning: msg.reasoning,
reasoning_details: msg.reasoning_details,
reasoning_content: msg.reasoning_content,
codex_reasoning_items: msg.codex_reasoning_items,
})
}
// Update session with aggregated stats from Hermes
updateSession(newSessionId, {
started_at: hermesSession.started_at,
ended_at: hermesSession.ended_at,
end_reason: hermesSession.end_reason,
input_tokens: hermesSession.input_tokens,
output_tokens: hermesSession.output_tokens,
cache_read_tokens: hermesSession.cache_read_tokens,
cache_write_tokens: hermesSession.cache_write_tokens,
reasoning_tokens: hermesSession.reasoning_tokens,
estimated_cost_usd: hermesSession.estimated_cost_usd,
last_active: hermesSession.last_active,
preview: hermesSession.preview,
})
result.synced++
logger.info(`[session-sync] synced Hermes session ${hermesSession.id} -> ${newSessionId} (${detail.messages.length} messages, thread_session_count=${detail.thread_session_count})`)
} catch (err: any) {
result.errors.push(`session ${hermesSession.id}: ${err.message}`)
logger.warn(err, `[session-sync] failed to sync session ${hermesSession.id}`)
}
}
} catch (err: any) {
if (!err.message.includes('state.db not found')) {
result.errors.push(err.message)
logger.warn(err, `[session-sync] failed to open state.db for profile '${profile}'`)
}
}
return result
}
/**
* Main entry point: sync all profiles on startup
* Only runs if local DB is empty (first startup or after DB reset)
*/
export async function syncAllHermesSessionsOnStartup(): Promise<void> {
// Check if local DB has any sessions - only sync if completely empty
const db = getDb()
if (!db) {
logger.info('[session-sync] SQLite not available, skipping Hermes sync')
return
}
const countResult = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number } | undefined
const hasExistingSessions = countResult && countResult.count > 0
if (hasExistingSessions) {
logger.info('[session-sync] local DB has %d sessions, skipping Hermes sync', countResult!.count)
return
}
logger.info('[session-sync] local DB is empty, starting Hermes session sync...')
const profiles = getAllProfiles()
logger.info(`[session-sync] found ${profiles.length} profiles: ${profiles.join(', ')}`)
let totalSynced = 0
let totalErrors = 0
for (const profile of profiles) {
const result = await syncProfileSessions(profile)
totalSynced += result.synced
totalErrors += result.errors.length
if (result.errors.length > 0) {
logger.warn(`[session-sync] profile '${profile}' had ${result.errors.length} errors`)
for (const err of result.errors.slice(0, 5)) {
logger.warn(`[session-sync] - ${err}`)
}
if (result.errors.length > 5) {
logger.warn(`[session-sync] - ... and ${result.errors.length - 5} more errors`)
}
}
}
logger.info(`[session-sync] sync complete: synced=${totalSynced}, errors=${totalErrors}`)
}