Files
hermes-agent/apps/desktop/src/store/transcript-tail.ts
T
hermes-seaeye[bot] f377140e3d fmt(js): npm run fix on merge (#92815)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-23 08:45:37 +00:00

193 lines
5.9 KiB
TypeScript

/**
* REST TAIL-HYDRATION BOOKKEEPING — keyed by STORED session id.
*
* `getLatestSessionMessages` loads a small newest-first page instead of a
* fixed 500-row transcript. When that page comes back full (returned ===
* limit), older rows likely exist on the backend; this store records that
* fact plus the offset the next older page starts at, so the transcript
* window's "Show earlier" action knows to backfill over REST once the
* in-memory store is fully materialized (see app/chat/transcript-backfill).
*
* Offsets use the backend's `order: 'latest'` semantics: measured back from
* the NEWEST row, with each page returned in chronological order — so the
* page immediately older than N already-loaded tail rows starts at offset N.
*/
import { atom } from 'nanostores'
import type { SessionMessagesResponse } from '@/types/hermes'
export interface TranscriptTailState {
/** Offset (back from the newest row) where the next older page starts. */
nextOffset: number
/** The last hydration page was exactly the page limit, so older rows
* likely exist beyond what the in-memory store holds. */
possiblyTruncated: boolean
/** Owning profile captured at hydration time, so a later backfill routes
* its REST read to the same backend that served the tail. */
profile?: TranscriptProfileScope
}
export type TranscriptProfileScope =
| null
| string
| {
connectionId?: null | string
profile?: null | string
}
export const $transcriptTailBySessionId = atom<Record<string, TranscriptTailState>>({})
const TRANSCRIPT_TAIL_LIMIT = 256
let transcriptTailOrder: string[] = []
type TailPage = Pick<SessionMessagesResponse, 'messages' | 'pagination'>
function normalizedScope(profile?: TranscriptProfileScope): { connectionId: string; profile: string } | null {
if (typeof profile === 'string') {
return { connectionId: '', profile: profile.trim() || 'default' }
}
if (!profile) {
return null
}
return {
connectionId: String(profile.connectionId || '').trim(),
profile: String(profile.profile || '').trim() || 'default'
}
}
function transcriptTailKey(storedSessionId: string, profile?: TranscriptProfileScope): string {
const scope = normalizedScope(profile)
return scope ? JSON.stringify([scope.connectionId, scope.profile, storedSessionId]) : storedSessionId
}
function matchingTailEntries(storedSessionId: string): Array<[string, TranscriptTailState]> {
return Object.entries($transcriptTailBySessionId.get()).filter(([key]) => {
if (key === storedSessionId) {
return true
}
try {
const parsed = JSON.parse(key)
return Array.isArray(parsed) && parsed.length === 3 && parsed[2] === storedSessionId
} catch {
return false
}
})
}
function tailStateFromPage(page: TailPage, profile?: TranscriptProfileScope): TranscriptTailState {
const pagination = page.pagination
// No pagination metadata is a legacy backend that ignored the paging query
// and returned the full transcript: nothing is truncated.
if (!pagination || pagination.limit <= 0) {
return { nextOffset: page.messages.length, possiblyTruncated: false, profile }
}
return {
nextOffset: pagination.offset + page.messages.length,
possiblyTruncated: page.messages.length >= pagination.limit,
profile
}
}
function setTranscriptTailEntry(key: string, state: TranscriptTailState): void {
const current = $transcriptTailBySessionId.get()
const existing = new Set(Object.keys(current))
transcriptTailOrder = transcriptTailOrder.filter(candidate => candidate !== key && existing.has(candidate))
transcriptTailOrder.push(key)
const next = { ...current, [key]: state }
while (transcriptTailOrder.length > TRANSCRIPT_TAIL_LIMIT) {
const oldest = transcriptTailOrder.shift()
if (oldest !== undefined) {
delete next[oldest]
}
}
$transcriptTailBySessionId.set(next)
}
/** Record the outcome of a tail hydration (`getLatestSessionMessages`). */
export function recordTranscriptTail(storedSessionId: string, page: TailPage, profile?: TranscriptProfileScope): void {
if (!storedSessionId) {
return
}
const key = transcriptTailKey(storedSessionId, profile)
setTranscriptTailEntry(key, tailStateFromPage(page, profile))
}
/** Advance the bookkeeping after one older backfill page landed. */
export function recordTranscriptBackfillPage(
storedSessionId: string,
page: TailPage,
profile?: TranscriptProfileScope
): void {
const current = $transcriptTailBySessionId.get()
const selected: Array<[string, TranscriptTailState | undefined]> =
profile === undefined
? matchingTailEntries(storedSessionId)
: [[transcriptTailKey(storedSessionId, profile), current[transcriptTailKey(storedSessionId, profile)]]]
if (selected.length !== 1) {
return
}
const [key, previous] = selected[0]
if (!previous) {
return
}
setTranscriptTailEntry(key, tailStateFromPage(page, previous.profile))
}
export function transcriptTailState(
storedSessionId: null | string | undefined,
profile?: TranscriptProfileScope
): TranscriptTailState | undefined {
if (!storedSessionId) {
return undefined
}
if (profile !== undefined) {
return $transcriptTailBySessionId.get()[transcriptTailKey(storedSessionId, profile)]
}
const matches = matchingTailEntries(storedSessionId)
return matches.length === 1 ? matches[0][1] : undefined
}
export function clearTranscriptTail(storedSessionId: string, profile?: TranscriptProfileScope): void {
const current = $transcriptTailBySessionId.get()
const keys =
profile === undefined
? matchingTailEntries(storedSessionId).map(([key]) => key)
: [transcriptTailKey(storedSessionId, profile)]
if (keys.length === 0) {
return
}
const next = { ...current }
for (const key of keys) {
delete next[key]
}
const removed = new Set(keys)
transcriptTailOrder = transcriptTailOrder.filter(key => !removed.has(key))
$transcriptTailBySessionId.set(next)
}