feat(web-ui): add pinned sessions and live monitor in Chat (#118)
* feat: add single-page live session monitor and chat pinning * fix: restore full test green after main merge * fix: use Array.from instead of Set spread for ts-node compatibility [...new Set()] requires downlevelIteration which isn't enabled in ts-node dev mode, causing sonic-boom crash on startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: ekko <fqsy1416@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
import { exportSessionsRaw, type HermesSessionFull } from './hermes-cli'
|
||||
|
||||
const LINEAGE_TOLERANCE_SECONDS = 3
|
||||
const LIVE_WINDOW_SECONDS = 300
|
||||
const EXPORT_CACHE_TTL_MS = 30000
|
||||
const DEFAULT_CONVERSATION_LIMIT = 200
|
||||
const SYNTHETIC_USER_PREFIXES = [
|
||||
'[system:',
|
||||
"you've reached the maximum number of tool-calling iterations allowed.",
|
||||
'you have reached the maximum number of tool-calling iterations allowed.',
|
||||
]
|
||||
|
||||
type HermesMessageLike = {
|
||||
id?: number | string
|
||||
session_id?: string
|
||||
role?: string
|
||||
content?: unknown
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
type ConversationSession = HermesSessionFull & {
|
||||
parent_session_id?: string | null
|
||||
preview: string
|
||||
last_active: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
type CachedExport = {
|
||||
expires_at_ms: number
|
||||
sessions: HermesSessionFull[]
|
||||
}
|
||||
|
||||
const exportCache = new Map<string, CachedExport>()
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string
|
||||
source: string
|
||||
model: string
|
||||
title: string | null
|
||||
started_at: number
|
||||
ended_at: number | null
|
||||
last_active: number
|
||||
message_count: number
|
||||
tool_call_count: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_write_tokens: number
|
||||
reasoning_tokens: number
|
||||
billing_provider: string | null
|
||||
estimated_cost_usd: number
|
||||
actual_cost_usd: number | null
|
||||
cost_status: string
|
||||
preview: string
|
||||
is_active: boolean
|
||||
thread_session_count: number
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
id: number | string
|
||||
session_id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface ConversationDetail {
|
||||
session_id: string
|
||||
messages: ConversationMessage[]
|
||||
visible_count: number
|
||||
thread_session_count: number
|
||||
}
|
||||
|
||||
export interface ConversationListOptions {
|
||||
source?: string
|
||||
humanOnly?: boolean
|
||||
limit?: number
|
||||
}
|
||||
|
||||
function cacheKey(source?: string): string {
|
||||
return source || '__all__'
|
||||
}
|
||||
|
||||
function safeText(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
return ''
|
||||
}
|
||||
|
||||
function textFromContent(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => textFromContent(item).trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
if (!value || typeof value !== 'object') return ''
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
const directKeys = ['text', 'content', 'value'] as const
|
||||
for (const key of directKeys) {
|
||||
const direct = record[key]
|
||||
if (typeof direct === 'string') return direct
|
||||
if (Array.isArray(direct)) {
|
||||
const nested = textFromContent(direct)
|
||||
if (nested) return nested
|
||||
}
|
||||
}
|
||||
|
||||
const nestedKeys = ['parts', 'children', 'items'] as const
|
||||
for (const key of nestedKeys) {
|
||||
if (Array.isArray(record[key])) {
|
||||
const nested = textFromContent(record[key])
|
||||
if (nested) return nested
|
||||
}
|
||||
}
|
||||
|
||||
const flattened = Object.values(record)
|
||||
.map(entry => textFromContent(entry).trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
if (flattened) return flattened
|
||||
|
||||
try {
|
||||
return JSON.stringify(record)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
return textFromContent(value).replace(/\s+/g, ' ').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function excerpt(value: unknown, width = 80): string {
|
||||
const text = textFromContent(value).replace(/\s+/g, ' ').trim()
|
||||
if (!text) return ''
|
||||
return text.length > width ? `${text.slice(0, width)}…` : text
|
||||
}
|
||||
|
||||
function isSyntheticUserText(content: unknown): boolean {
|
||||
const text = normalizeText(content)
|
||||
return SYNTHETIC_USER_PREFIXES.some(prefix => text.startsWith(prefix))
|
||||
}
|
||||
|
||||
function visibleHumanMessage(message: HermesMessageLike): boolean {
|
||||
const role = safeText(message.role)
|
||||
const content = textFromContent(message.content).trim()
|
||||
if (!content) return false
|
||||
if (role !== 'user' && role !== 'assistant') return false
|
||||
if (role === 'user' && isSyntheticUserText(content)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function firstVisibleHumanText(messages: HermesMessageLike[]): string {
|
||||
const firstVisible = messages.find(visibleHumanMessage)
|
||||
return firstVisible ? textFromContent(firstVisible.content).trim() : ''
|
||||
}
|
||||
|
||||
function maxMessageTimestamp(messages: HermesMessageLike[]): number {
|
||||
return messages.reduce((max, message) => {
|
||||
const timestamp = Number(message.timestamp || 0)
|
||||
return Number.isFinite(timestamp) && timestamp > max ? timestamp : max
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function enrichSession(session: HermesSessionFull, nowSeconds: number): ConversationSession {
|
||||
const messages = Array.isArray(session.messages) ? session.messages : []
|
||||
const preview = excerpt(firstVisibleHumanText(messages))
|
||||
const lastActive = maxMessageTimestamp(messages) || Number(session.ended_at || session.started_at || 0)
|
||||
const endedAt = session.ended_at ?? null
|
||||
return {
|
||||
...session,
|
||||
parent_session_id: (session.parent_session_id as string | null | undefined) ?? null,
|
||||
preview,
|
||||
last_active: lastActive,
|
||||
is_active: endedAt == null && nowSeconds - lastActive <= LIVE_WINDOW_SECONDS,
|
||||
}
|
||||
}
|
||||
|
||||
function sortByRecency<T extends { last_active: number; started_at: number; id: string }>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (b.last_active !== a.last_active) return b.last_active - a.last_active
|
||||
if (b.started_at !== a.started_at) return b.started_at - a.started_at
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
function timingMatchesParent(parent: ConversationSession | undefined, child: ConversationSession | undefined): boolean {
|
||||
if (!parent || !child || parent.ended_at == null) return false
|
||||
return Math.abs(Number(child.started_at || 0) - Number(parent.ended_at || 0)) <= LINEAGE_TOLERANCE_SECONDS
|
||||
}
|
||||
|
||||
function isBranchRoot(session: ConversationSession | undefined, byId: Map<string, ConversationSession>): boolean {
|
||||
if (!session?.parent_session_id) return false
|
||||
const parent = byId.get(session.parent_session_id)
|
||||
return !!parent && parent.end_reason === 'branched' && timingMatchesParent(parent, session)
|
||||
}
|
||||
|
||||
function isVisibleRoot(session: ConversationSession | undefined, byId: Map<string, ConversationSession>): boolean {
|
||||
if (!session || session.source === 'tool') return false
|
||||
return session.parent_session_id == null || isBranchRoot(session, byId)
|
||||
}
|
||||
|
||||
function continuationCandidates(parent: ConversationSession, byId: Map<string, ConversationSession>, childrenByParent: Map<string | null, string[]>): ConversationSession[] {
|
||||
const childIds = childrenByParent.get(parent.id) || []
|
||||
return childIds
|
||||
.map(childId => byId.get(childId))
|
||||
.filter((child): child is ConversationSession => !!child)
|
||||
.filter(child => child.source !== 'tool')
|
||||
.filter(child => child.source === parent.source)
|
||||
.filter(child => timingMatchesParent(parent, child))
|
||||
.sort((a, b) => {
|
||||
const aDelta = Math.abs(Number(a.started_at || 0) - Number(parent.ended_at || 0))
|
||||
const bDelta = Math.abs(Number(b.started_at || 0) - Number(parent.ended_at || 0))
|
||||
if (aDelta !== bDelta) return aDelta - bDelta
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
function nextContinuationChild(parent: ConversationSession, byId: Map<string, ConversationSession>, childrenByParent: Map<string | null, string[]>): ConversationSession | null {
|
||||
if (parent.end_reason !== 'compression') return null
|
||||
const candidates = continuationCandidates(parent, byId, childrenByParent)
|
||||
if (candidates.length === 1) return candidates[0]
|
||||
|
||||
const exactPreviewMatches = candidates.filter(child => {
|
||||
const childPreview = normalizeText(child.preview)
|
||||
const parentPreview = normalizeText(parent.preview)
|
||||
return !!childPreview && childPreview === parentPreview
|
||||
})
|
||||
|
||||
if (exactPreviewMatches.length === 1) return exactPreviewMatches[0]
|
||||
return null
|
||||
}
|
||||
|
||||
function collectConversationChain(rootId: string, byId: Map<string, ConversationSession>, childrenByParent: Map<string | null, string[]>): ConversationSession[] {
|
||||
const chain: ConversationSession[] = []
|
||||
const seen = new Set<string>()
|
||||
let current = byId.get(rootId) || null
|
||||
while (current && !seen.has(current.id)) {
|
||||
chain.push(current)
|
||||
seen.add(current.id)
|
||||
current = nextContinuationChild(current, byId, childrenByParent)
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
function sessionMessages(session: HermesSessionFull): HermesMessageLike[] {
|
||||
return Array.isArray(session.messages) ? session.messages as HermesMessageLike[] : []
|
||||
}
|
||||
|
||||
function normalizeVisibleMessage(message: HermesMessageLike, session: HermesSessionFull, index: number): ConversationMessage | null {
|
||||
if (!visibleHumanMessage(message)) return null
|
||||
const role = safeText(message.role)
|
||||
const content = textFromContent(message.content).trim()
|
||||
if (role !== 'user' && role !== 'assistant') return null
|
||||
if (!content) return null
|
||||
|
||||
const rawTimestamp = Number(message.timestamp)
|
||||
const timestamp = Number.isFinite(rawTimestamp) && rawTimestamp > 0
|
||||
? rawTimestamp
|
||||
: Number(session.ended_at || session.started_at || 0)
|
||||
const id = message.id ?? `${session.id}:${index}:${timestamp}`
|
||||
|
||||
return {
|
||||
id,
|
||||
session_id: safeText(message.session_id || session.id),
|
||||
role,
|
||||
content,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
function visibleMessagesForSessions(sessions: HermesSessionFull[]): ConversationMessage[] {
|
||||
return sessions
|
||||
.flatMap(session => sessionMessages(session).map((message, index) => normalizeVisibleMessage({ ...message, session_id: safeText(message.session_id || session.id) }, session, index)))
|
||||
.filter((message): message is ConversationMessage => !!message)
|
||||
.sort((a, b) => {
|
||||
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp
|
||||
return String(a.id).localeCompare(String(b.id))
|
||||
})
|
||||
}
|
||||
|
||||
function hasVisibleHumanMessages(sessions: HermesSessionFull[]): boolean {
|
||||
return visibleMessagesForSessions(sessions).length > 0
|
||||
}
|
||||
|
||||
function toSummary(session: ConversationSession): ConversationSummary {
|
||||
return {
|
||||
id: session.id,
|
||||
source: safeText(session.source),
|
||||
model: safeText(session.model),
|
||||
title: session.title ?? null,
|
||||
started_at: Number(session.started_at || 0),
|
||||
ended_at: session.ended_at ?? null,
|
||||
last_active: session.last_active,
|
||||
message_count: Number(session.message_count || 0),
|
||||
tool_call_count: Number(session.tool_call_count || 0),
|
||||
input_tokens: Number(session.input_tokens || 0),
|
||||
output_tokens: Number(session.output_tokens || 0),
|
||||
cache_read_tokens: Number(session.cache_read_tokens || 0),
|
||||
cache_write_tokens: Number(session.cache_write_tokens || 0),
|
||||
reasoning_tokens: Number(session.reasoning_tokens || 0),
|
||||
billing_provider: session.billing_provider ?? null,
|
||||
estimated_cost_usd: Number(session.estimated_cost_usd || 0),
|
||||
actual_cost_usd: session.actual_cost_usd ?? null,
|
||||
cost_status: safeText(session.cost_status),
|
||||
preview: session.preview,
|
||||
is_active: session.is_active,
|
||||
thread_session_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateSummary(rootId: string, byId: Map<string, ConversationSession>, childrenByParent: Map<string | null, string[]>): ConversationSummary | null {
|
||||
const chain = collectConversationChain(rootId, byId, childrenByParent)
|
||||
if (!chain.length || !hasVisibleHumanMessages(chain)) return null
|
||||
const root = chain[0]
|
||||
const last = chain[chain.length - 1]
|
||||
const title = root.title || excerpt(firstVisibleHumanText(chain.flatMap(sessionMessages)), 72) || null
|
||||
const preview = root.preview || excerpt(firstVisibleHumanText(chain.flatMap(sessionMessages)))
|
||||
const costStatuses = Array.from(new Set(chain.map(session => safeText(session.cost_status)).filter(Boolean)))
|
||||
|
||||
return {
|
||||
...toSummary(root),
|
||||
title,
|
||||
preview,
|
||||
model: safeText(last?.model || root.model),
|
||||
ended_at: last?.ended_at ?? null,
|
||||
last_active: Math.max(...chain.map(session => session.last_active)),
|
||||
is_active: chain.some(session => session.is_active),
|
||||
billing_provider: last?.billing_provider ?? root.billing_provider ?? null,
|
||||
cost_status: costStatuses.length === 1 ? costStatuses[0] : 'mixed',
|
||||
thread_session_count: chain.length,
|
||||
message_count: chain.reduce((sum, session) => sum + Number(session.message_count || 0), 0),
|
||||
tool_call_count: chain.reduce((sum, session) => sum + Number(session.tool_call_count || 0), 0),
|
||||
input_tokens: chain.reduce((sum, session) => sum + Number(session.input_tokens || 0), 0),
|
||||
output_tokens: chain.reduce((sum, session) => sum + Number(session.output_tokens || 0), 0),
|
||||
cache_read_tokens: chain.reduce((sum, session) => sum + Number(session.cache_read_tokens || 0), 0),
|
||||
cache_write_tokens: chain.reduce((sum, session) => sum + Number(session.cache_write_tokens || 0), 0),
|
||||
reasoning_tokens: chain.reduce((sum, session) => sum + Number(session.reasoning_tokens || 0), 0),
|
||||
estimated_cost_usd: chain.reduce((sum, session) => sum + Number(session.estimated_cost_usd || 0), 0),
|
||||
actual_cost_usd: chain.reduce<number | null>((sum, session) => {
|
||||
const actual = session.actual_cost_usd
|
||||
if (actual == null) return sum
|
||||
return (sum || 0) + Number(actual)
|
||||
}, null),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessions(source?: string): Promise<ConversationSession[]> {
|
||||
const key = cacheKey(source)
|
||||
const nowMs = Date.now()
|
||||
const cached = exportCache.get(key)
|
||||
const raws = cached && cached.expires_at_ms > nowMs
|
||||
? cached.sessions
|
||||
: await exportSessionsRaw(source)
|
||||
|
||||
if (!cached || cached.expires_at_ms <= nowMs) {
|
||||
exportCache.set(key, {
|
||||
expires_at_ms: nowMs + EXPORT_CACHE_TTL_MS,
|
||||
sessions: raws,
|
||||
})
|
||||
}
|
||||
|
||||
const nowSeconds = nowMs / 1000
|
||||
return raws.map(raw => enrichSession(raw, nowSeconds))
|
||||
}
|
||||
|
||||
export async function listConversationSummaries(options: ConversationListOptions = {}): Promise<ConversationSummary[]> {
|
||||
const humanOnly = options.humanOnly !== false
|
||||
const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_CONVERSATION_LIMIT
|
||||
const sessions = await loadSessions(options.source)
|
||||
const byId = new Map(sessions.map(session => [session.id, session]))
|
||||
const childrenByParent = new Map<string | null, string[]>()
|
||||
for (const session of sessions) {
|
||||
const key = session.parent_session_id ?? null
|
||||
const siblings = childrenByParent.get(key) || []
|
||||
siblings.push(session.id)
|
||||
childrenByParent.set(key, siblings)
|
||||
}
|
||||
|
||||
if (!humanOnly) {
|
||||
return sortByRecency(
|
||||
sessions
|
||||
.filter(session => session.source !== 'tool')
|
||||
.map(toSummary),
|
||||
).slice(0, limit)
|
||||
}
|
||||
|
||||
const summaries = sessions
|
||||
.filter(session => isVisibleRoot(session, byId))
|
||||
.map(session => aggregateSummary(session.id, byId, childrenByParent))
|
||||
.filter((summary): summary is ConversationSummary => !!summary)
|
||||
|
||||
return sortByRecency(summaries).slice(0, limit)
|
||||
}
|
||||
|
||||
export async function getConversationDetail(sessionId: string, options: ConversationListOptions = {}): Promise<ConversationDetail | null> {
|
||||
const humanOnly = options.humanOnly !== false
|
||||
const sessions = await loadSessions(options.source)
|
||||
const byId = new Map(sessions.map(session => [session.id, session]))
|
||||
const childrenByParent = new Map<string | null, string[]>()
|
||||
for (const session of sessions) {
|
||||
const key = session.parent_session_id ?? null
|
||||
const siblings = childrenByParent.get(key) || []
|
||||
siblings.push(session.id)
|
||||
childrenByParent.set(key, siblings)
|
||||
}
|
||||
|
||||
if (!humanOnly) {
|
||||
const session = byId.get(sessionId)
|
||||
if (!session || session.source === 'tool') return null
|
||||
const messages = visibleMessagesForSessions([session])
|
||||
return {
|
||||
session_id: sessionId,
|
||||
messages,
|
||||
visible_count: messages.length,
|
||||
thread_session_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const root = byId.get(sessionId)
|
||||
if (!isVisibleRoot(root, byId)) return null
|
||||
const chain = collectConversationChain(sessionId, byId, childrenByParent)
|
||||
const messages = visibleMessagesForSessions(chain)
|
||||
if (!messages.length) return null
|
||||
return {
|
||||
session_id: sessionId,
|
||||
messages,
|
||||
visible_count: messages.length,
|
||||
thread_session_count: chain.length,
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export interface HermesSession {
|
||||
messages?: any[]
|
||||
}
|
||||
|
||||
interface HermesSessionFull {
|
||||
export interface HermesSessionFull {
|
||||
id: string
|
||||
source: string
|
||||
user_id: string | null
|
||||
@@ -67,10 +67,21 @@ interface HermesSessionFull {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions from Hermes CLI (without messages)
|
||||
*/
|
||||
export async function listSessions(source?: string, limit?: number): Promise<HermesSession[]> {
|
||||
function parseSessionExport(stdout: string): HermesSessionFull[] {
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
const sessions: HermesSessionFull[] = []
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const raw: HermesSessionFull = JSON.parse(line)
|
||||
sessions.push(raw)
|
||||
} catch {
|
||||
// Skip non-JSON lines such as "Session 'x' not found."
|
||||
}
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
export async function exportSessionsRaw(source?: string): Promise<HermesSessionFull[]> {
|
||||
const args = ['sessions', 'export', '-']
|
||||
if (source) args.push('--source', source)
|
||||
|
||||
@@ -80,58 +91,61 @@ export async function listSessions(source?: string, limit?: number): Promise<Her
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
const sessions: HermesSession[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const raw: HermesSessionFull = JSON.parse(line)
|
||||
let title = raw.title
|
||||
if (!title && raw.messages) {
|
||||
const firstUser = raw.messages.find((m: any) => m.role === 'user')
|
||||
if (firstUser?.content) {
|
||||
const t = String(firstUser.content).slice(0, 40)
|
||||
title = t + (String(firstUser.content).length > 40 ? '...' : '')
|
||||
}
|
||||
}
|
||||
sessions.push({
|
||||
id: raw.id,
|
||||
source: raw.source,
|
||||
user_id: raw.user_id,
|
||||
model: raw.model,
|
||||
title,
|
||||
started_at: raw.started_at,
|
||||
ended_at: raw.ended_at,
|
||||
end_reason: raw.end_reason,
|
||||
message_count: raw.message_count,
|
||||
tool_call_count: raw.tool_call_count,
|
||||
input_tokens: raw.input_tokens,
|
||||
output_tokens: raw.output_tokens,
|
||||
cache_read_tokens: raw.cache_read_tokens || 0,
|
||||
cache_write_tokens: raw.cache_write_tokens || 0,
|
||||
reasoning_tokens: raw.reasoning_tokens || 0,
|
||||
billing_provider: raw.billing_provider,
|
||||
estimated_cost_usd: raw.estimated_cost_usd,
|
||||
actual_cost_usd: raw.actual_cost_usd ?? null,
|
||||
cost_status: raw.cost_status || '',
|
||||
})
|
||||
} catch { /* skip malformed lines */ }
|
||||
}
|
||||
|
||||
// Sort by started_at descending
|
||||
sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
|
||||
if (limit && limit > 0) {
|
||||
return sessions.slice(0, limit)
|
||||
}
|
||||
return sessions
|
||||
return parseSessionExport(stdout)
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: sessions export failed')
|
||||
throw new Error(`Failed to list sessions: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions from Hermes CLI (without messages)
|
||||
*/
|
||||
export async function listSessions(source?: string, limit?: number): Promise<HermesSession[]> {
|
||||
const raws = await exportSessionsRaw(source)
|
||||
const sessions: HermesSession[] = []
|
||||
|
||||
for (const raw of raws) {
|
||||
let title = raw.title
|
||||
if (!title && raw.messages) {
|
||||
const firstUser = raw.messages.find((m: any) => m.role === 'user')
|
||||
if (firstUser?.content) {
|
||||
const t = String(firstUser.content).slice(0, 40)
|
||||
title = t + (String(firstUser.content).length > 40 ? '...' : '')
|
||||
}
|
||||
}
|
||||
sessions.push({
|
||||
id: raw.id,
|
||||
source: raw.source,
|
||||
user_id: raw.user_id,
|
||||
model: raw.model,
|
||||
title,
|
||||
started_at: raw.started_at,
|
||||
ended_at: raw.ended_at,
|
||||
end_reason: raw.end_reason,
|
||||
message_count: raw.message_count,
|
||||
tool_call_count: raw.tool_call_count,
|
||||
input_tokens: raw.input_tokens,
|
||||
output_tokens: raw.output_tokens,
|
||||
cache_read_tokens: raw.cache_read_tokens || 0,
|
||||
cache_write_tokens: raw.cache_write_tokens || 0,
|
||||
reasoning_tokens: raw.reasoning_tokens || 0,
|
||||
billing_provider: raw.billing_provider,
|
||||
estimated_cost_usd: raw.estimated_cost_usd,
|
||||
actual_cost_usd: raw.actual_cost_usd ?? null,
|
||||
cost_status: raw.cost_status || '',
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by started_at descending
|
||||
sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
|
||||
if (limit && limit > 0) {
|
||||
return sessions.slice(0, limit)
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single session with messages from Hermes CLI
|
||||
*/
|
||||
@@ -145,12 +159,10 @@ export async function getSession(id: string): Promise<HermesSession | null> {
|
||||
...execOpts,
|
||||
})
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
if (lines.length === 0) return null
|
||||
const raws = parseSessionExport(stdout)
|
||||
if (raws.length === 0) return null
|
||||
|
||||
if (!lines[0].startsWith('{')) return null
|
||||
|
||||
const raw: HermesSessionFull = JSON.parse(lines[0])
|
||||
const raw: HermesSessionFull = raws[0]
|
||||
return {
|
||||
id: raw.id,
|
||||
source: raw.source,
|
||||
|
||||
Reference in New Issue
Block a user