Fix bridge compression history handling (#726)
* feat(bridge): refactor compression to use DB history and add structured logging - Extract buildDbHistory() to share message loading between buildCompressedHistory and forceCompressBridgeHistory - forceCompressBridgeHistory now reads from local DB instead of using Python-provided messages, ensuring consistency with api_server path - Pass sessionId to compressor for snapshot-aware compression - Add force_compress flag to bridge chat requests - Add bridgeLogger structured logging for compression lifecycle - Simplify schemas, session-sync, and providers Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix bridge compression history handling --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { setTimeout as delay } from 'timers/promises'
|
||||
import { createConnection, type Socket } from 'net'
|
||||
import { URL } from 'url'
|
||||
import { bridgeLogger } from '../../logger'
|
||||
|
||||
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = process.platform === 'win32'
|
||||
? 'tcp://127.0.0.1:18765'
|
||||
@@ -91,6 +92,36 @@ export class AgentBridgeClient {
|
||||
this.timeoutMs = options.timeoutMs ?? envPositiveInt('HERMES_AGENT_BRIDGE_TIMEOUT_MS') ?? DEFAULT_AGENT_BRIDGE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
private summarizePayload(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const action = String(payload.action || '')
|
||||
const summary: Record<string, unknown> = { action }
|
||||
for (const key of ['session_id', 'run_id', 'request_id', 'approval_id', 'profile']) {
|
||||
if (payload[key] != null) summary[key] = payload[key]
|
||||
}
|
||||
if (Array.isArray(payload.conversation_history)) summary.conversation_history_count = payload.conversation_history.length
|
||||
if (Array.isArray(payload.messages)) summary.messages_count = payload.messages.length
|
||||
if (typeof payload.message === 'string') summary.message_chars = payload.message.length
|
||||
else if (Array.isArray(payload.message)) summary.message_parts = payload.message.length
|
||||
if (typeof payload.command === 'string') summary.command = payload.command
|
||||
if (typeof payload.text === 'string') summary.text_chars = payload.text.length
|
||||
if (typeof payload.error === 'string') summary.error = payload.error
|
||||
if (payload.force_compress === true) summary.force_compress = true
|
||||
return summary
|
||||
}
|
||||
|
||||
private summarizeResponse(response: Record<string, unknown>): Record<string, unknown> {
|
||||
const summary: Record<string, unknown> = { ok: response.ok === true }
|
||||
for (const key of ['session_id', 'run_id', 'request_id', 'status', 'cursor', 'event_cursor']) {
|
||||
if (response[key] != null) summary[key] = response[key]
|
||||
}
|
||||
if (typeof response.delta === 'string') summary.delta_chars = response.delta.length
|
||||
if (typeof response.output === 'string') summary.output_chars = response.output.length
|
||||
if (Array.isArray(response.events)) summary.events_count = response.events.length
|
||||
if (typeof response.error === 'string') summary.error = response.error
|
||||
if (Array.isArray(response.history)) summary.history_count = response.history.length
|
||||
return summary
|
||||
}
|
||||
|
||||
async connect(): Promise<this> {
|
||||
return this
|
||||
}
|
||||
@@ -191,16 +222,47 @@ export class AgentBridgeClient {
|
||||
): Promise<T> {
|
||||
const run = async (): Promise<T> => {
|
||||
const timeoutMs = options.timeoutMs || this.timeoutMs
|
||||
const socket = await this.connectSocket()
|
||||
socket.write(`${JSON.stringify(payload)}\n`)
|
||||
const raw = await this.readResponse(socket, timeoutMs)
|
||||
const response = JSON.parse(raw) as { ok?: boolean; error?: string }
|
||||
if (!response.ok) {
|
||||
const error = new AgentBridgeError(response.error || 'Agent bridge request failed')
|
||||
error.response = response
|
||||
throw error
|
||||
const startedAt = Date.now()
|
||||
const action = String(payload.action || '')
|
||||
const shouldLogRequest = action !== 'get_output'
|
||||
if (shouldLogRequest) {
|
||||
bridgeLogger.info({
|
||||
endpoint: this.endpoint,
|
||||
timeoutMs,
|
||||
request: this.summarizePayload(payload),
|
||||
}, '[agent-bridge-client] request')
|
||||
}
|
||||
try {
|
||||
const socket = await this.connectSocket()
|
||||
socket.write(`${JSON.stringify(payload)}\n`)
|
||||
const raw = await this.readResponse(socket, timeoutMs)
|
||||
const response = JSON.parse(raw) as { ok?: boolean; error?: string }
|
||||
if (!response.ok) {
|
||||
const error = new AgentBridgeError(response.error || 'Agent bridge request failed')
|
||||
error.response = response
|
||||
bridgeLogger.warn({
|
||||
durationMs: Date.now() - startedAt,
|
||||
response: this.summarizeResponse(response as Record<string, unknown>),
|
||||
}, '[agent-bridge-client] request rejected')
|
||||
throw error
|
||||
}
|
||||
if (shouldLogRequest) {
|
||||
bridgeLogger.info({
|
||||
durationMs: Date.now() - startedAt,
|
||||
response: this.summarizeResponse(response as Record<string, unknown>),
|
||||
}, '[agent-bridge-client] response')
|
||||
}
|
||||
return response as T
|
||||
} catch (err: any) {
|
||||
if (!(err instanceof AgentBridgeError)) {
|
||||
bridgeLogger.error({
|
||||
durationMs: Date.now() - startedAt,
|
||||
err: { message: err?.message, name: err?.name },
|
||||
request: this.summarizePayload(payload),
|
||||
}, '[agent-bridge-client] request failed')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
return response as T
|
||||
}
|
||||
|
||||
const next = this.lock.then(run, run)
|
||||
@@ -218,6 +280,7 @@ export class AgentBridgeClient {
|
||||
conversationHistory?: unknown[],
|
||||
instructions?: string,
|
||||
profile?: string,
|
||||
options: { force_compress?: boolean } = {},
|
||||
): Promise<AgentBridgeChatStarted> {
|
||||
return this.request<AgentBridgeChatStarted>({
|
||||
action: 'chat',
|
||||
@@ -226,6 +289,7 @@ export class AgentBridgeClient {
|
||||
...(conversationHistory ? { conversation_history: conversationHistory } : {}),
|
||||
...(instructions ? { instructions } : {}),
|
||||
...(profile ? { profile } : {}),
|
||||
...(options.force_compress ? { force_compress: true } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -732,6 +732,7 @@ class AgentPool:
|
||||
instructions: str | None = None,
|
||||
conversation_history: list[dict[str, Any]] | None = None,
|
||||
profile: str | None = None,
|
||||
force_compress: bool = False,
|
||||
) -> RunRecord:
|
||||
session = self.get_or_create(session_id, profile=profile)
|
||||
with session.lock:
|
||||
@@ -747,14 +748,14 @@ class AgentPool:
|
||||
|
||||
thread = threading.Thread(
|
||||
target=self._run_chat,
|
||||
args=(session, record, message, instructions, conversation_history, profile),
|
||||
args=(session, record, message, instructions, conversation_history, profile, force_compress),
|
||||
daemon=True,
|
||||
name=f"hermes-bridge-run-{run_id[:8]}",
|
||||
)
|
||||
thread.start()
|
||||
return record
|
||||
|
||||
def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None) -> None:
|
||||
def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None, force_compress: bool = False) -> None:
|
||||
def stream_callback(delta: str) -> None:
|
||||
with self._lock:
|
||||
record.deltas.append(str(delta))
|
||||
@@ -774,6 +775,19 @@ class AgentPool:
|
||||
except Exception:
|
||||
previous_approval_callback = None
|
||||
self._prepersist_user_message(session, message, conversation_history, profile)
|
||||
if force_compress:
|
||||
compress = getattr(session.agent, "_compress_context", None)
|
||||
if callable(compress):
|
||||
compressed_history, compressed_system = compress(
|
||||
conversation_history if isinstance(conversation_history, list) else [],
|
||||
instructions,
|
||||
approx_tokens=None,
|
||||
focus_topic="debug_force_compress",
|
||||
)
|
||||
if isinstance(compressed_history, list):
|
||||
conversation_history = compressed_history
|
||||
if isinstance(compressed_system, str):
|
||||
instructions = compressed_system
|
||||
kwargs: dict[str, Any] = dict(
|
||||
task_id=session.session_id,
|
||||
stream_callback=stream_callback,
|
||||
@@ -996,7 +1010,14 @@ class BridgeServer:
|
||||
instructions = req.get("instructions") or req.get("system_message")
|
||||
conversation_history = req.get("conversation_history")
|
||||
profile = req.get("profile")
|
||||
record = self.pool.start_chat(session_id, message, instructions, conversation_history, profile)
|
||||
record = self.pool.start_chat(
|
||||
session_id,
|
||||
message,
|
||||
instructions,
|
||||
conversation_history,
|
||||
profile,
|
||||
bool(req.get("force_compress")),
|
||||
)
|
||||
if req.get("wait"):
|
||||
timeout = float(req.get("timeout", 0) or 0)
|
||||
deadline = time.time() + timeout if timeout > 0 else None
|
||||
|
||||
@@ -25,7 +25,7 @@ import { ChatContextCompressor, countTokens, SUMMARY_PREFIX } from '../../lib/co
|
||||
import { getCompressionSnapshot } from '../../db/hermes/compression-snapshot'
|
||||
import { parseAnthropicContentArray } from '../../lib/llm-json'
|
||||
import { updateUsage } from '../../db/hermes/usage-store'
|
||||
import { logger } from '../logger'
|
||||
import { bridgeLogger, logger } from '../logger'
|
||||
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from './agent-bridge'
|
||||
import { getActiveProfileName } from './hermes-profile'
|
||||
import type { ChatMessage } from '../../lib/context-compressor'
|
||||
@@ -194,6 +194,7 @@ interface SessionState {
|
||||
arguments: string
|
||||
startedAt: number
|
||||
}>
|
||||
bridgeCompressionResults?: Record<string, BridgeCompressionResult>
|
||||
}
|
||||
|
||||
interface ResponseRunState {
|
||||
@@ -205,6 +206,19 @@ interface ResponseRunState {
|
||||
|
||||
type ChatRunSource = 'api_server' | 'cli'
|
||||
|
||||
interface BridgeCompressionResult {
|
||||
messages: ChatMessage[]
|
||||
beforeMessages: number
|
||||
resultMessages: number
|
||||
beforeTokens: number
|
||||
afterTokens: number
|
||||
compressed: boolean
|
||||
llmCompressed: boolean
|
||||
summaryTokens: number
|
||||
verbatimCount: number
|
||||
compressedStartIndex: number
|
||||
}
|
||||
|
||||
// --- ChatRunSocket ---
|
||||
|
||||
export class ChatRunSocket {
|
||||
@@ -795,6 +809,54 @@ export class ChatRunSocket {
|
||||
* then apply context compression (snapshot-aware + LLM) identically for both
|
||||
* api_server and CLI bridge runs.
|
||||
*/
|
||||
private async buildDbHistory(
|
||||
sessionId: string,
|
||||
options: { excludeLastUser?: boolean } = {},
|
||||
): Promise<ChatMessage[]> {
|
||||
const detail = useLocalSessionStore()
|
||||
? getSessionDetail(sessionId)
|
||||
: await getSessionDetailFromDb(sessionId)
|
||||
if (!detail?.messages?.length) return []
|
||||
|
||||
const validMessages = detail.messages.filter(m =>
|
||||
(m.role === 'user' || m.role === 'assistant' || m.role === 'tool') && m.content !== undefined,
|
||||
)
|
||||
|
||||
const sourceMessages = options.excludeLastUser
|
||||
? (() => {
|
||||
const lastUserMsgIndex = [...validMessages].reverse().findIndex(m => m.role === 'user')
|
||||
return lastUserMsgIndex >= 0
|
||||
? validMessages.slice(0, validMessages.length - lastUserMsgIndex - 1)
|
||||
: validMessages
|
||||
})()
|
||||
: validMessages
|
||||
|
||||
return sourceMessages.map((m, idx, arr) => {
|
||||
const msg: any = { role: m.role, content: m.content || '' }
|
||||
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
|
||||
if (m.tool_calls?.length) {
|
||||
const cleanedToolCalls = m.tool_calls
|
||||
.filter((tc: any) => tc.id && tc.id.length > 0)
|
||||
.map((tc: any) => ({ id: tc.id, type: tc.type, function: tc.function }))
|
||||
if (cleanedToolCalls.length > 0) msg.tool_calls = cleanedToolCalls
|
||||
}
|
||||
if (m.role === 'tool') {
|
||||
let callId = m.tool_call_id
|
||||
if (!callId || callId.length === 0) {
|
||||
const prevMsg = arr[idx - 1]
|
||||
if (prevMsg?.role === 'assistant' && prevMsg.tool_calls?.length) {
|
||||
const tc = prevMsg.tool_calls.find((t: any) => t.function?.name === m.tool_name)
|
||||
if (tc?.id) callId = tc.id
|
||||
}
|
||||
}
|
||||
if (!callId || callId.length === 0) return null
|
||||
msg.tool_call_id = callId
|
||||
}
|
||||
if (m.tool_name) msg.name = m.tool_name
|
||||
return msg
|
||||
}).filter((m): m is ChatMessage => m !== null)
|
||||
}
|
||||
|
||||
private async buildCompressedHistory(
|
||||
sessionId: string,
|
||||
profile: string,
|
||||
@@ -803,44 +865,7 @@ export class ChatRunSocket {
|
||||
emit: (event: string, payload: any) => void,
|
||||
): Promise<ChatMessage[]> {
|
||||
try {
|
||||
const detail = useLocalSessionStore()
|
||||
? getSessionDetail(sessionId)
|
||||
: await getSessionDetailFromDb(sessionId)
|
||||
if (!detail?.messages?.length) return []
|
||||
|
||||
const validMessages = detail.messages.filter(m =>
|
||||
(m.role === 'user' || m.role === 'assistant' || m.role === 'tool') && m.content !== undefined,
|
||||
)
|
||||
|
||||
// Exclude the last user message (just added by the caller)
|
||||
const lastUserMsgIndex = [...validMessages].reverse().findIndex(m => m.role === 'user')
|
||||
let history: ChatMessage[] = (lastUserMsgIndex >= 0
|
||||
? validMessages.slice(0, validMessages.length - lastUserMsgIndex - 1)
|
||||
: validMessages
|
||||
).map((m, idx, arr) => {
|
||||
const msg: any = { role: m.role, content: m.content || '' }
|
||||
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
|
||||
if (m.tool_calls?.length) {
|
||||
const cleanedToolCalls = m.tool_calls
|
||||
.filter((tc: any) => tc.id && tc.id.length > 0)
|
||||
.map((tc: any) => ({ id: tc.id, type: tc.type, function: tc.function }))
|
||||
if (cleanedToolCalls.length > 0) msg.tool_calls = cleanedToolCalls
|
||||
}
|
||||
if (m.role === 'tool') {
|
||||
let callId = m.tool_call_id
|
||||
if (!callId || callId.length === 0) {
|
||||
const prevMsg = arr[idx - 1]
|
||||
if (prevMsg?.role === 'assistant' && prevMsg.tool_calls?.length) {
|
||||
const tc = prevMsg.tool_calls.find((t: any) => t.function?.name === m.tool_name)
|
||||
if (tc?.id) callId = tc.id
|
||||
}
|
||||
}
|
||||
if (!callId || callId.length === 0) return null
|
||||
msg.tool_call_id = callId
|
||||
}
|
||||
if (m.tool_name) msg.name = m.tool_name
|
||||
return msg
|
||||
}).filter((m): m is ChatMessage => m !== null)
|
||||
let history = await this.buildDbHistory(sessionId, { excludeLastUser: true })
|
||||
|
||||
if (history.length === 0) return []
|
||||
|
||||
@@ -954,37 +979,39 @@ export class ChatRunSocket {
|
||||
private async forceCompressBridgeHistory(
|
||||
sessionId: string,
|
||||
profile: string,
|
||||
messages: ChatMessage[],
|
||||
): Promise<ChatMessage[]> {
|
||||
const history = messages
|
||||
.filter(m => m && (m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'system'))
|
||||
.map(m => {
|
||||
const msg: any = { role: m.role, content: m.content || '' }
|
||||
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
|
||||
if (m.tool_calls?.length) {
|
||||
const cleanedToolCalls = m.tool_calls
|
||||
.filter((tc: any) => tc.id && tc.id.length > 0)
|
||||
.map((tc: any) => ({ id: tc.id, type: tc.type, function: tc.function }))
|
||||
if (cleanedToolCalls.length > 0) msg.tool_calls = cleanedToolCalls
|
||||
}
|
||||
if (m.tool_call_id) msg.tool_call_id = m.tool_call_id
|
||||
if (m.name) msg.name = m.name
|
||||
return msg as ChatMessage
|
||||
})
|
||||
_messages: ChatMessage[],
|
||||
): Promise<BridgeCompressionResult> {
|
||||
const history = await this.buildDbHistory(sessionId, { excludeLastUser: true })
|
||||
|
||||
if (history.length === 0) return []
|
||||
if (history.length === 0) {
|
||||
return {
|
||||
messages: [],
|
||||
beforeMessages: 0,
|
||||
resultMessages: 0,
|
||||
beforeTokens: 0,
|
||||
afterTokens: 0,
|
||||
compressed: false,
|
||||
llmCompressed: false,
|
||||
summaryTokens: 0,
|
||||
verbatimCount: 0,
|
||||
compressedStartIndex: -1,
|
||||
}
|
||||
}
|
||||
|
||||
const upstream = this.gatewayManager.getUpstream(profile).replace(/\/$/, '')
|
||||
const apiKey = this.gatewayManager.getApiKey(profile) || undefined
|
||||
const totalTokens = countTokens(JSON.stringify(history))
|
||||
logger.info('[context-compress] bridge forced compression session=%s: %d messages, ~%d tokens',
|
||||
sessionId, history.length, totalTokens)
|
||||
bridgeLogger.info({
|
||||
sessionId,
|
||||
profile,
|
||||
historyMessages: history.length,
|
||||
bridgeProvidedMessages: Array.isArray(_messages) ? _messages.length : 0,
|
||||
tokenEstimate: totalTokens,
|
||||
snapshotAware: true,
|
||||
}, '[chat-run-socket] bridge forced compression started')
|
||||
|
||||
const result = await compressor.compress(history, upstream, apiKey, undefined, profile)
|
||||
logger.info('[context-compress] bridge forced compression done session=%s: %d -> %d messages',
|
||||
sessionId, history.length, result.messages.length)
|
||||
|
||||
return result.messages.map(m => {
|
||||
const result = await compressor.compress(history, upstream, apiKey, sessionId, profile)
|
||||
const compressedMessages = result.messages.map(m => {
|
||||
const msg: any = { role: m.role, content: m.content }
|
||||
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
|
||||
if (m.tool_calls?.length) {
|
||||
@@ -997,6 +1024,40 @@ export class ChatRunSocket {
|
||||
if (m.name) msg.name = m.name
|
||||
return msg
|
||||
})
|
||||
const afterTokens = countTokens(JSON.stringify(compressedMessages))
|
||||
bridgeLogger.info({
|
||||
sessionId,
|
||||
profile,
|
||||
beforeMessages: history.length,
|
||||
resultMessages: result.messages.length,
|
||||
beforeTokens: totalTokens,
|
||||
afterTokens,
|
||||
compressed: result.meta.compressed,
|
||||
llmCompressed: result.meta.llmCompressed,
|
||||
verbatimCount: result.meta.verbatimCount,
|
||||
compressedStartIndex: result.meta.compressedStartIndex,
|
||||
compressedHistory: result.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
reasoning_content: m.reasoning_content,
|
||||
tool_calls: m.tool_calls,
|
||||
tool_call_id: m.tool_call_id,
|
||||
name: m.name,
|
||||
})),
|
||||
}, '[chat-run-socket] bridge forced compression completed')
|
||||
|
||||
return {
|
||||
messages: compressedMessages,
|
||||
beforeMessages: history.length,
|
||||
resultMessages: compressedMessages.length,
|
||||
beforeTokens: totalTokens,
|
||||
afterTokens,
|
||||
compressed: result.meta.compressed,
|
||||
llmCompressed: result.meta.llmCompressed,
|
||||
summaryTokens: result.meta.summaryTokenEstimate,
|
||||
verbatimCount: result.meta.verbatimCount,
|
||||
compressedStartIndex: result.meta.compressedStartIndex,
|
||||
}
|
||||
}
|
||||
|
||||
private resolveRunSource(source?: string, sessionId?: string): ChatRunSource {
|
||||
@@ -1079,8 +1140,20 @@ export class ChatRunSocket {
|
||||
|
||||
try {
|
||||
logger.info('[chat-run-socket] starting CLI bridge run for session %s', session_id)
|
||||
bridgeLogger.info({
|
||||
sessionId: session_id,
|
||||
profile,
|
||||
inputChars: inputStr.length,
|
||||
historyMessages: history.length,
|
||||
hasInstructions: Boolean(instructions),
|
||||
}, '[chat-run-socket] starting CLI bridge run')
|
||||
const started = await this.bridge.chat(session_id, input as AgentBridgeMessage, history, instructions, profile)
|
||||
state.runId = started.run_id
|
||||
bridgeLogger.info({
|
||||
sessionId: session_id,
|
||||
runId: started.run_id,
|
||||
status: started.status,
|
||||
}, '[chat-run-socket] CLI bridge run started')
|
||||
this.pushState(session_id, 'run.started', {
|
||||
event: 'run.started',
|
||||
run_id: started.run_id,
|
||||
@@ -1224,12 +1297,16 @@ export class ChatRunSocket {
|
||||
this.replaceState(sessionId, 'approval.resolved', payload)
|
||||
emit('approval.resolved', payload)
|
||||
} else if (evType === 'bridge.compression.requested') {
|
||||
const bridgeHistory = await this.buildDbHistory(sessionId, { excludeLastUser: true })
|
||||
const tokenCount = bridgeHistory.length > 0
|
||||
? countTokens(JSON.stringify(bridgeHistory))
|
||||
: ev.approx_tokens
|
||||
const payload = {
|
||||
event: 'compression.started',
|
||||
run_id: chunk.run_id,
|
||||
request_id: ev.request_id,
|
||||
message_count: ev.message_count,
|
||||
token_count: ev.approx_tokens,
|
||||
message_count: bridgeHistory.length || ev.message_count,
|
||||
token_count: tokenCount,
|
||||
source: 'bridge',
|
||||
}
|
||||
this.replaceState(sessionId, 'compression.started', payload)
|
||||
@@ -1241,7 +1318,9 @@ export class ChatRunSocket {
|
||||
profile,
|
||||
ev.messages as ChatMessage[],
|
||||
)
|
||||
await this.bridge.compressionRespond(String(ev.request_id), { messages: compressed })
|
||||
state.bridgeCompressionResults = state.bridgeCompressionResults || {}
|
||||
state.bridgeCompressionResults[String(ev.request_id)] = compressed
|
||||
await this.bridge.compressionRespond(String(ev.request_id), { messages: compressed.messages })
|
||||
} catch (err: any) {
|
||||
await this.bridge.compressionRespond(String(ev.request_id), {
|
||||
error: err?.message || String(err),
|
||||
@@ -1249,18 +1328,30 @@ export class ChatRunSocket {
|
||||
}
|
||||
}
|
||||
} else if (evType === 'bridge.compression.completed') {
|
||||
const compressionResult = ev.request_id
|
||||
? state.bridgeCompressionResults?.[String(ev.request_id)]
|
||||
: undefined
|
||||
const payload = {
|
||||
event: 'compression.completed',
|
||||
run_id: chunk.run_id,
|
||||
request_id: ev.request_id,
|
||||
compressed: ev.compressed !== false,
|
||||
totalMessages: ev.message_count,
|
||||
resultMessages: ev.result_messages,
|
||||
beforeTokens: ev.approx_tokens,
|
||||
compressed: compressionResult?.compressed ?? ev.compressed !== false,
|
||||
llmCompressed: compressionResult?.llmCompressed,
|
||||
totalMessages: compressionResult?.beforeMessages ?? ev.message_count,
|
||||
resultMessages: compressionResult?.resultMessages ?? ev.result_messages,
|
||||
beforeTokens: compressionResult?.beforeTokens ?? ev.approx_tokens,
|
||||
afterTokens: compressionResult?.afterTokens,
|
||||
summaryTokens: compressionResult?.summaryTokens,
|
||||
verbatimCount: compressionResult?.verbatimCount,
|
||||
compressedStartIndex: compressionResult?.compressedStartIndex,
|
||||
source: 'bridge',
|
||||
}
|
||||
if (ev.request_id && state.bridgeCompressionResults) {
|
||||
delete state.bridgeCompressionResults[String(ev.request_id)]
|
||||
}
|
||||
this.replaceState(sessionId, 'compression.completed', payload)
|
||||
emit('compression.completed', payload)
|
||||
await this.calcAndUpdateUsage(sessionId, state, emit)
|
||||
} else if (evType === 'bridge.compression.failed') {
|
||||
const payload = {
|
||||
event: 'compression.completed',
|
||||
@@ -1273,6 +1364,9 @@ export class ChatRunSocket {
|
||||
error: ev.error,
|
||||
source: 'bridge',
|
||||
}
|
||||
if (ev.request_id && state.bridgeCompressionResults) {
|
||||
delete state.bridgeCompressionResults[String(ev.request_id)]
|
||||
}
|
||||
this.replaceState(sessionId, 'compression.completed', payload)
|
||||
emit('compression.completed', payload)
|
||||
} else if (evType === 'status') {
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface CompressionConfig {
|
||||
export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = {
|
||||
triggerTokens: 100_000,
|
||||
maxHistoryTokens: 32_000,
|
||||
tailMessageCount: 20,
|
||||
tailMessageCount: 10,
|
||||
charsPerToken: 6,
|
||||
summarizationTimeoutMs: 30_000,
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ class ChatStorage {
|
||||
saveRoom(id: string, name: string, inviteCode?: string, config?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void {
|
||||
this.db()?.prepare(
|
||||
'INSERT OR IGNORE INTO gc_rooms (id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(id, name, inviteCode || null, config?.triggerTokens ?? 100000, config?.maxHistoryTokens ?? 32000, config?.tailMessageCount ?? 20)
|
||||
).run(id, name, inviteCode || null, config?.triggerTokens ?? 100000, config?.maxHistoryTokens ?? 32000, config?.tailMessageCount ?? 10)
|
||||
}
|
||||
|
||||
updateRoomConfig(roomId: string, config: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void {
|
||||
|
||||
@@ -1,191 +1,13 @@
|
||||
/**
|
||||
* 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).
|
||||
* Hermes session import is intentionally disabled.
|
||||
*
|
||||
* Uses sessions-db.ts query logic to properly aggregate session chains.
|
||||
* Hermes state.db remains a read-only source for Hermes-specific history APIs.
|
||||
* The web-ui local sessions/messages tables must not be populated from Hermes
|
||||
* on startup, because that can mix ownership and make data-loss incidents much
|
||||
* harder to reason about.
|
||||
*/
|
||||
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'
|
||||
import { detectHermesHome } from './hermes-path'
|
||||
|
||||
const HERMES_BASE = detectHermesHome()
|
||||
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) {
|
||||
// Skip ephemeral sessions (created internally by chat-run-socket)
|
||||
if (hermesSession.id.startsWith('eph_')) continue
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// 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}`)
|
||||
logger.info('[session-sync] Hermes session import is disabled')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user