mirror of
https://github.com/Heretek-AI/ompweb.git
synced 2026-11-07 10:56:00 -10:00
* fix(chat): catch up history with persisted cursors * test(chat): use canonical hook dependency imports * fix(chat): harden catch-up settlement and indexed paging * fix(chat): publish complete catch-up snapshots atomically
1270 lines
54 KiB
TypeScript
1270 lines
54 KiB
TypeScript
import { closeSync, existsSync, fstatSync, openSync, readSync, statSync, type Stats } from "fs";
|
|
import { normalize as normalizePath } from "path";
|
|
import { getAgentDir } from "./omp/paths";
|
|
import {
|
|
containsBlobRef,
|
|
invalidateAllSessionScanCaches,
|
|
invalidateSessionFileListCache,
|
|
invalidateSessionScanCache,
|
|
listAllSessionInfos,
|
|
loadSessionFile,
|
|
MAX_SESSION_LOAD_BYTES,
|
|
readSessionHeaderSync,
|
|
resolveBlobRefsInEntries,
|
|
type OmpSessionInfo,
|
|
type ResolveBlobOptions,
|
|
} from "./omp/session-files";
|
|
import type {
|
|
AgentMessage,
|
|
CompactionEntry,
|
|
CustomMessage,
|
|
SessionContext,
|
|
SessionEntry,
|
|
SessionHeader,
|
|
SessionInfo,
|
|
} from "./types";
|
|
import { normalizeToolCalls } from "./normalize";
|
|
import { isRecord } from "./type-guards";
|
|
import { taskResultRetryFailure, taskResultStructuredOutput, taskResultUsageCost } from "./task-result-details";
|
|
import type { TodoPhase } from "./pi-types";
|
|
import { projectIdentityKey, sessionPathKey } from "./paths";
|
|
import { resolveProject, type ProjectInfo } from "./worktree";
|
|
import { selectHistoryRange, type SessionHistoryCursor, type SessionHistoryPage } from "./session-sync";
|
|
|
|
export { getAgentDir };
|
|
|
|
/**
|
|
* `header.parentSession` has two forms in omp: a session FILE PATH (branch /
|
|
* createBranchedSession, the RPC path omp-web drives) and a bare SESSION ID
|
|
* (SessionManager.fork, reached from the TUI /fork, `omp --fork` and /tan).
|
|
* Resolve the path form first, then fall back to an id match, so TUI-forked
|
|
* sessions are not rendered as unrelated roots.
|
|
*/
|
|
function matchParentSessionId(
|
|
parentSession: string,
|
|
pathToId: Map<string, string>,
|
|
knownIds: Set<string>,
|
|
): string | undefined {
|
|
const byPath = pathToId.get(sessionPathKey(parentSession));
|
|
if (byPath) return byPath;
|
|
return knownIds.has(parentSession) ? parentSession : undefined;
|
|
}
|
|
|
|
async function loadAllSessions(): Promise<SessionInfo[]> {
|
|
const ompSessions: OmpSessionInfo[] = await listAllSessionInfos();
|
|
const pathToId = new Map<string, string>();
|
|
const knownIds = new Set<string>();
|
|
for (const s of ompSessions) {
|
|
pathToId.set(sessionPathKey(s.path), s.id);
|
|
knownIds.add(s.id);
|
|
}
|
|
|
|
// Resolve each unique cwd to its project root (main repo shared by all
|
|
// worktrees). resolveProject caches per-cwd, so this is cheap after warmup.
|
|
// Bound concurrency so 100+ unique cwds don't spawn 100 parallel git processes.
|
|
const uniqueCwds = [...new Set(ompSessions.map((s) => s.cwd).filter(Boolean))];
|
|
const projectByCwd = new Map<string, ProjectInfo>();
|
|
const CONCURRENCY = 6;
|
|
for (let i = 0; i < uniqueCwds.length; i += CONCURRENCY) {
|
|
const chunk = uniqueCwds.slice(i, i + CONCURRENCY);
|
|
await Promise.all(chunk.map(async (cwd) => {
|
|
projectByCwd.set(cwd, await resolveProject(cwd));
|
|
}));
|
|
}
|
|
|
|
return ompSessions.map((s) => {
|
|
cacheSessionPath(s.id, s.path);
|
|
const project = s.cwd ? projectByCwd.get(s.cwd) : undefined;
|
|
return {
|
|
path: s.path,
|
|
id: s.id,
|
|
cwd: s.cwd,
|
|
// omp renamed the display field to `title`; the internal shape keeps `name`.
|
|
name: s.title,
|
|
created: s.created instanceof Date && !Number.isNaN(s.created.getTime())
|
|
? s.created.toISOString()
|
|
: s.modified.toISOString(),
|
|
modified: s.modified.toISOString(),
|
|
messageCount: s.messageCount,
|
|
firstMessage: s.firstMessage || "(no messages)",
|
|
parentSessionId: s.parentSessionPath
|
|
? matchParentSessionId(s.parentSessionPath, pathToId, knownIds)
|
|
: undefined,
|
|
projectRoot: project?.projectRoot ?? s.cwd,
|
|
projectKey: projectIdentityKey(project?.projectRoot ?? s.cwd),
|
|
...(project?.isWorktree && project.branch ? { worktreeBranch: project.branch } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function listAllSessions(): Promise<SessionInfo[]> {
|
|
const generation = globalThis.__piSessionListGeneration ?? 0;
|
|
|
|
// Return cached result if still fresh (avoids re-scanning session files
|
|
// and re-spawning git processes on every page load).
|
|
if (globalThis.__piSessionListCache && Date.now() - globalThis.__piSessionListCache.ts < SESSION_LIST_CACHE_TTL_MS) {
|
|
return globalThis.__piSessionListCache.data;
|
|
}
|
|
|
|
// Coalescing dedup: concurrent callers share the same in-flight promise
|
|
// only while it belongs to the current cache generation.
|
|
if (globalThis.__piSessionListPromise && globalThis.__piSessionListPromiseGeneration === generation) {
|
|
return globalThis.__piSessionListPromise;
|
|
}
|
|
|
|
// Flipped once the watchdog below retires this scan: a hung load that
|
|
// resolves after the slot moved on must not overwrite fresher cache data.
|
|
let retired = false;
|
|
const loadPromise = loadAllSessions().then((data) => {
|
|
// An invalidation may happen while the scan is in flight. Do not let that
|
|
// older result repopulate the cache after a session mutation.
|
|
if ((globalThis.__piSessionListGeneration ?? 0) === generation && !retired) {
|
|
globalThis.__piSessionListCache = { data, ts: Date.now() };
|
|
}
|
|
return data;
|
|
});
|
|
const trackedPromise = loadPromise.finally(() => {
|
|
if (globalThis.__piSessionListPromise === trackedPromise) {
|
|
globalThis.__piSessionListPromise = undefined;
|
|
globalThis.__piSessionListPromiseGeneration = undefined;
|
|
}
|
|
});
|
|
// A load that never settles (a stuck sync syscall, a hung git spawn that
|
|
// slipped past its timeout) must not be handed to every future caller
|
|
// forever — the coalescing slot would pin the wedge until process restart.
|
|
// Drop the slot after a generous deadline; the cache stays unset, so the
|
|
// next request starts a fresh scan.
|
|
const watchdog = setTimeout(() => {
|
|
if (globalThis.__piSessionListPromise === trackedPromise) {
|
|
globalThis.__piSessionListPromise = undefined;
|
|
globalThis.__piSessionListPromiseGeneration = undefined;
|
|
retired = true;
|
|
}
|
|
}, SESSION_LIST_LOAD_DEADLINE_MS);
|
|
watchdog.unref?.();
|
|
|
|
globalThis.__piSessionListPromise = trackedPromise;
|
|
globalThis.__piSessionListPromiseGeneration = generation;
|
|
watchdog.unref?.();
|
|
|
|
globalThis.__piSessionListPromise = trackedPromise;
|
|
globalThis.__piSessionListPromiseGeneration = generation;
|
|
return trackedPromise;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Session path caches, stored in globalThis for hot-reload safety.
|
|
// ============================================================================
|
|
declare global {
|
|
var __piSessionPathCache: Map<string, string> | undefined;
|
|
var __piPathToSessionIdCache: Map<string, string> | undefined;
|
|
var __piSessionListPromise: Promise<SessionInfo[]> | undefined;
|
|
var __piSessionListPromiseGeneration: number | undefined;
|
|
var __piSessionListGeneration: number | undefined;
|
|
var __piSessionListCache: { data: SessionInfo[]; ts: number } | undefined;
|
|
}
|
|
|
|
const SESSION_LIST_CACHE_TTL_MS = 30_000;
|
|
/** Beyond this, an unsettled in-flight list load stops being coalesced. */
|
|
const SESSION_LIST_LOAD_DEADLINE_MS = 60_000;
|
|
|
|
/** Invalidate the session LIST metadata (30s TTL result + generation gate)
|
|
* and the directory-walk cache, but leave per-session parse caches intact.
|
|
* Use this when a session changed but you know which one: call
|
|
* invalidateSessionEntriesCache(path) for the specific file instead of paying
|
|
* for a full parse-cache flush. */
|
|
export function invalidateSessionListMeta(): void {
|
|
globalThis.__piSessionListGeneration = (globalThis.__piSessionListGeneration ?? 0) + 1;
|
|
globalThis.__piSessionListCache = undefined;
|
|
// The session-file walk cache keys on the sessions-root mtime, which does
|
|
// not change when a file is added inside an existing project subdirectory
|
|
// (Windows/NTFS). Clear it too so new sessions appear immediately.
|
|
invalidateSessionFileListCache();
|
|
}
|
|
|
|
export function invalidateSessionListCache(): void {
|
|
invalidateSessionListMeta();
|
|
// Drop cached entry parses so a mutation preserving size+mtime (rare) can
|
|
// never serve stale entries; the (size, mtimeMs) key already invalidates
|
|
// the common case, this closes the remaining window.
|
|
globalThis.__ompSessionEntriesCache?.clear();
|
|
globalThis.__ompSessionHistoryIndexes?.clear();
|
|
// Unknown-source changes (full-flush fallback) have no known path to target
|
|
// with invalidateSessionEntriesCache: clear every per-file SCAN memo too, or
|
|
// an unknown same-size + same-mtime rewrite would keep serving the OLD list
|
|
// summary (stale title/parent) from __ompSessionScanCache.
|
|
invalidateAllSessionScanCaches();
|
|
}
|
|
|
|
/** Invalidate session-list metadata plus ONLY the given file's parse caches
|
|
* when the path is known; without a path, fall back to the full flush (list
|
|
* + every session's parse caches). This is the shared entry point for code
|
|
* that edits a session and knows which file it touched — a busy session must
|
|
* not force unrelated open sessions to re-parse their transcripts. */
|
|
export function invalidateSessionCaches(filePath?: string): void {
|
|
if (filePath) {
|
|
invalidateSessionListMeta();
|
|
invalidateSessionEntriesCache(filePath);
|
|
} else {
|
|
invalidateSessionListCache();
|
|
}
|
|
}
|
|
|
|
/** Invalidate ONLY the per-session caches for one session file (full-entry
|
|
* parse + list scan window). Callers that know exactly which session changed
|
|
* (live RPC events carrying the session file path, the file watcher with a
|
|
* concrete filename) use this instead of invalidateSessionListCache() so
|
|
* unrelated sessions keep their parsed-entry cache — a single busy session no
|
|
* longer forces every other open session to re-parse multi-MB transcripts.
|
|
* The key is normalized with sessionPathKey so Windows case/spacing variants
|
|
* still hit the same entry. */
|
|
export function invalidateSessionEntriesCache(filePath: string): void {
|
|
globalThis.__ompSessionEntriesCache?.delete(sessionPathKey(filePath));
|
|
for (const [key, index] of globalThis.__ompSessionHistoryIndexes ?? []) {
|
|
if (index.pathKey === sessionPathKey(filePath)) globalThis.__ompSessionHistoryIndexes?.delete(key);
|
|
}
|
|
invalidateSessionScanCache(filePath);
|
|
}
|
|
|
|
function getPathCache(): Map<string, string> {
|
|
if (!globalThis.__piSessionPathCache) globalThis.__piSessionPathCache = new Map();
|
|
return globalThis.__piSessionPathCache;
|
|
}
|
|
|
|
function getPathToIdCache(): Map<string, string> {
|
|
if (!globalThis.__piPathToSessionIdCache) globalThis.__piPathToSessionIdCache = new Map();
|
|
return globalThis.__piPathToSessionIdCache;
|
|
}
|
|
|
|
export async function resolveSessionPath(sessionId: string): Promise<string | null> {
|
|
const cached = getPathCache().get(sessionId);
|
|
if (cached) {
|
|
if (existsSync(cached)) return cached;
|
|
// A deleted session must never resolve: callers spawn omp with --resume
|
|
// against the path, and omp silently creates a NEW session when the file
|
|
// is gone. Drop the dead entry directly; the list cache stays valid, so
|
|
// repeated 404 polls for the same id do not force a full rescan each time.
|
|
// (listAllSessions would drop it anyway on the next real list mutation.)
|
|
invalidateSessionPathCache(sessionId);
|
|
}
|
|
|
|
// Cache miss: scan all sessions to populate cache, then retry
|
|
await listAllSessions();
|
|
const resolved = getPathCache().get(sessionId);
|
|
if (!resolved) return null;
|
|
if (!existsSync(resolved)) {
|
|
invalidateSessionPathCache(sessionId);
|
|
return null;
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
export async function resolveSessionIdByPath(filePath: string): Promise<string | undefined> {
|
|
const pathKey = sessionPathKey(filePath);
|
|
const cached = getPathToIdCache().get(pathKey);
|
|
if (cached) return cached;
|
|
|
|
await listAllSessions();
|
|
return getPathToIdCache().get(pathKey);
|
|
}
|
|
|
|
/**
|
|
* Resolve a `header.parentSession` value (either a session file path or a bare
|
|
* session id — see matchParentSessionId) to the parent's session id.
|
|
*/
|
|
export async function resolveParentSessionId(parentSession: string): Promise<string | undefined> {
|
|
if (!parentSession) return undefined;
|
|
const byPath = await resolveSessionIdByPath(parentSession);
|
|
if (byPath) return byPath;
|
|
// Id form: only accept it when a session file with that id still exists.
|
|
return (await resolveSessionPath(parentSession)) ? parentSession : undefined;
|
|
}
|
|
|
|
export function cacheSessionPath(sessionId: string, filePath: string): void {
|
|
const normalizedPath = normalizePath(filePath);
|
|
const pathKey = sessionPathKey(normalizedPath);
|
|
const pathCache = getPathCache();
|
|
const reverseCache = getPathToIdCache();
|
|
const previousPath = pathCache.get(sessionId);
|
|
const previousPathKey = previousPath ? sessionPathKey(previousPath) : undefined;
|
|
const previousSessionId = reverseCache.get(pathKey);
|
|
const previousOwnerPath = previousSessionId ? pathCache.get(previousSessionId) : undefined;
|
|
if (previousPathKey && previousPathKey !== pathKey && reverseCache.get(previousPathKey) === sessionId) {
|
|
reverseCache.delete(previousPathKey);
|
|
}
|
|
if (
|
|
previousSessionId &&
|
|
previousSessionId !== sessionId &&
|
|
previousOwnerPath &&
|
|
sessionPathKey(previousOwnerPath) === pathKey
|
|
) {
|
|
pathCache.delete(previousSessionId);
|
|
}
|
|
pathCache.set(sessionId, normalizedPath);
|
|
reverseCache.set(pathKey, sessionId);
|
|
}
|
|
|
|
export function invalidateSessionPathCache(sessionId: string): void {
|
|
const pathCache = getPathCache();
|
|
const reverseCache = getPathToIdCache();
|
|
const filePath = pathCache.get(sessionId);
|
|
pathCache.delete(sessionId);
|
|
const pathKey = filePath ? sessionPathKey(filePath) : undefined;
|
|
if (pathKey && reverseCache.get(pathKey) === sessionId) {
|
|
reverseCache.delete(pathKey);
|
|
}
|
|
}
|
|
|
|
/** Bounded, title-slot-aware header read (never loads message bodies). */
|
|
export function readSessionHeader(filePath: string): SessionHeader | null {
|
|
return readSessionHeaderSync(filePath);
|
|
}
|
|
|
|
/** Full-file entry parse memoized on (path, size, mtimeMs). Read-only scans
|
|
* (subagent history, file-reference checks, thinking routes) re-parse MB-scale
|
|
* session files on every 15s reconcile poll and again at run end; the memo
|
|
* turns each UNCHANGED file's parse into a single stat — the same convention
|
|
* as scanSessionInfoCached. Stored on globalThis for hot-reload safety and
|
|
* LRU-bounded. Entries are SHARED across cache hits: callers must treat them
|
|
* as immutable (all current callers only read). */
|
|
interface SessionEntriesCacheEntry {
|
|
size: number;
|
|
mtimeMs: number;
|
|
entries: SessionEntry[];
|
|
}
|
|
|
|
declare global {
|
|
var __ompSessionEntriesCache: Map<string, SessionEntriesCacheEntry> | undefined;
|
|
}
|
|
|
|
const MAX_SESSION_ENTRIES_CACHE_ENTRIES = 32;
|
|
/** Byte cap on CACHED FILE SIZES (parsed JS expands several-fold). Without it,
|
|
* 32 cached near-1GiB transcripts could pin tens of GB of heap. */
|
|
const MAX_SESSION_ENTRIES_CACHE_BYTES = 256 * 1024 * 1024;
|
|
|
|
function getSessionEntriesCache(): Map<string, SessionEntriesCacheEntry> {
|
|
if (!globalThis.__ompSessionEntriesCache) globalThis.__ompSessionEntriesCache = new Map();
|
|
return globalThis.__ompSessionEntriesCache;
|
|
}
|
|
|
|
function loadSessionEntriesCached(filePath: string): SessionEntry[] {
|
|
let size: number;
|
|
let mtimeMs: number;
|
|
try {
|
|
const stat = statSync(filePath);
|
|
size = stat.size;
|
|
mtimeMs = stat.mtimeMs;
|
|
} catch {
|
|
// Missing/unreadable file — mirror loadSessionFile's lenient empty result.
|
|
return loadSessionFile(filePath).entries;
|
|
}
|
|
const cache = getSessionEntriesCache();
|
|
const key = sessionPathKey(filePath);
|
|
const cached = cache.get(key);
|
|
if (cached && cached.size === size && cached.mtimeMs === mtimeMs) {
|
|
cache.delete(key);
|
|
cache.set(key, cached);
|
|
return cached.entries;
|
|
}
|
|
const entries = loadSessionFile(filePath).entries;
|
|
// A single file larger than the WHOLE cache budget would pin the entire
|
|
// budget for itself (the old `cache.size > 1` guard never evicted it) and
|
|
// starve every other session's cached parse. Skip the cache for such
|
|
// giants: they re-parse per request, which is bounded work (the read is
|
|
// chunked; parse is O(entries)) and far cheaper than evicting all peers.
|
|
// The per-request parse cost is the accepted trade-off for staying within
|
|
// the memory budget — the alternative (caching) makes concurrent large
|
|
// sessions OOM the server.
|
|
if (size > MAX_SESSION_ENTRIES_CACHE_BYTES) {
|
|
// The file outgrew the budget: any cached entry for this path is stale
|
|
// (keyed on the OLD smaller size) and would keep burning eviction budget
|
|
// until LRU got to it — drop it now.
|
|
cache.delete(key);
|
|
return entries;
|
|
}
|
|
cache.set(key, { size, mtimeMs, entries });
|
|
// Two bounds: entry count and total cached file bytes (parsed JS expands
|
|
// several-fold). Total-bytes eviction keeps the cache within budget.
|
|
let totalBytes = 0;
|
|
for (const entry of cache.values()) totalBytes += entry.size;
|
|
while (cache.size > 1 && (cache.size > MAX_SESSION_ENTRIES_CACHE_ENTRIES || totalBytes > MAX_SESSION_ENTRIES_CACHE_BYTES)) {
|
|
const oldestKey = cache.keys().next().value;
|
|
if (oldestKey === undefined) break;
|
|
totalBytes -= cache.get(oldestKey)?.size ?? 0;
|
|
cache.delete(oldestKey);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/** Session entries without blob resolution (fine for reference/thinking scans). */
|
|
export function getSessionEntries(filePath: string): SessionEntry[] {
|
|
return loadSessionEntriesCached(filePath);
|
|
}
|
|
|
|
/** Surface loadSessionFile's `too_large` marker (loadSessionEntriesCached
|
|
* drops it by returning only `.entries`) without paying for a fresh parse:
|
|
* the size check is a stat, and the cached read below serves everything
|
|
* else. Header reads are bounded and succeed on giant files, so this check is
|
|
* what tells a 413 (file too large) from a 404 (missing/malformed). */
|
|
function loadEntriesOrThrowTooLarge(filePath: string): SessionEntry[] {
|
|
try {
|
|
if (statSync(filePath).size > MAX_SESSION_LOAD_BYTES) {
|
|
// The cached entry (keyed on the old, smaller size) can never match
|
|
// again — drop it instead of letting it linger until LRU eviction.
|
|
getSessionEntriesCache().delete(sessionPathKey(filePath));
|
|
throw new SessionFileTooLargeError(filePath);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof SessionFileTooLargeError) throw error;
|
|
// Missing/unreadable — mirror loadSessionFile's lenient empty result;
|
|
// loadSessionEntriesCached handles the same case identically.
|
|
}
|
|
return loadSessionEntriesCached(filePath);
|
|
}
|
|
|
|
/** Thrown by getSessionEntriesForDisplay when the session file exists but is
|
|
* larger than MAX_SESSION_LOAD_BYTES. The header read is bounded and succeeds
|
|
* on such files, so routes cannot rely on a null header to detect the
|
|
* overload — without this error the loader's too_large marker was dropped
|
|
* (`.entries` is the empty array) and routes returned 200 with an empty
|
|
* transcript, making history look deleted. Routes catch this and respond 413
|
|
* with the stable `session_file_too_large` code. */
|
|
export class SessionFileTooLargeError extends Error {
|
|
readonly code = "session_file_too_large" as const;
|
|
constructor(filePath: string) {
|
|
super(`Session file is too large to open in omp-web: ${filePath}`);
|
|
this.name = "SessionFileTooLargeError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display-path entry read for the session/context routes: reuses the memoized
|
|
* (size, mtimeMs) parse cache WITHOUT blob resolution for entries that don't
|
|
* reference blobs, and deep-copies ONLY the entries that do before resolving
|
|
* their blob refs. The cache stays clean (shared objects are never mutated),
|
|
* and the expensive deep copy is paid only for blob-bearing entries — the
|
|
* common image-free session clones nothing (the array is shallow-copied).
|
|
* Throws SessionFileTooLargeError when the file exceeds the load ceiling.
|
|
*/
|
|
export function getSessionEntriesForDisplay(filePath: string, options: ResolveBlobOptions = {}): SessionEntry[] {
|
|
const entries = loadEntriesOrThrowTooLarge(filePath);
|
|
return toDisplayEntries(entries, options);
|
|
}
|
|
|
|
/** Blob-resolution transform shared by the sync and deduplicated display
|
|
* reads. */
|
|
function toDisplayEntries(entries: SessionEntry[], options: ResolveBlobOptions): SessionEntry[] {
|
|
if (entries.length === 0) return entries;
|
|
// Blob-bearing entries are deep-copied so resolution never mutates the
|
|
// shared cache object; blob-free entries are shared as-is.
|
|
const out: SessionEntry[] = new Array(entries.length);
|
|
let copiedAny = false;
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const entry = entries[i];
|
|
if (
|
|
options.skipToolResultImages &&
|
|
entry.type === "message" &&
|
|
((entry.message as { role?: string } | null | undefined)?.role === "toolResult")
|
|
) {
|
|
// Caller omits these images anyway — share the entry without blob work.
|
|
out[i] = entry;
|
|
continue;
|
|
}
|
|
if (!containsBlobRef(entry)) {
|
|
out[i] = entry;
|
|
continue;
|
|
}
|
|
// Blob-bearing entries are deep-copied so resolution never mutates the
|
|
// shared cache object.
|
|
out[i] = structuredClone(entry) as SessionEntry;
|
|
copiedAny = true;
|
|
}
|
|
if (copiedAny) {
|
|
resolveBlobRefsInEntries(out, options);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// In-flight parse dedup, keyed like the entries cache. Concurrent display
|
|
// reads for the SAME file share one parse instead of stacking duplicate
|
|
// work: for cache-skipped oversized files this removes the double parse on
|
|
// same-tick requests, and it is the load-bearing correctness point once the
|
|
// parse moves into the (async) worker queue — without the map, concurrent
|
|
// callers would each enqueue their own parse. Stored on globalThis for
|
|
// hot-reload safety; bounded by the number of distinct files being read
|
|
// concurrently, and entries are removed as soon as their parse settles.
|
|
declare global {
|
|
var __ompEntriesInFlight: Map<string, Promise<SessionEntry[]>> | undefined;
|
|
}
|
|
|
|
function getInFlightEntries(): Map<string, Promise<SessionEntry[]>> {
|
|
if (!globalThis.__ompEntriesInFlight) globalThis.__ompEntriesInFlight = new Map();
|
|
return globalThis.__ompEntriesInFlight;
|
|
}
|
|
|
|
/** Deduplicated display-path entry read. Semantically identical to
|
|
* getSessionEntriesForDisplay; concurrent calls for the same file share one
|
|
* parse and resolve to the SAME entry array (callers must treat it as
|
|
* immutable, like all cached entry reads). Rejections are never memoized: a
|
|
* transient failure must not poison later reads. */
|
|
export async function getSessionEntriesForDisplayAsync(
|
|
filePath: string,
|
|
options: ResolveBlobOptions = {},
|
|
): Promise<SessionEntry[]> {
|
|
const key = sessionPathKey(filePath);
|
|
const cache = getSessionEntriesCache();
|
|
// Same too-large gate as the sync display read: past the load ceiling the
|
|
// routes must get the structured 413 error, not an empty transcript — and
|
|
// the stale (small-size) cache entry for this path can never match again.
|
|
let stat: { size: number; mtimeMs: number } | null = null;
|
|
try {
|
|
stat = statSync(filePath);
|
|
} catch {
|
|
stat = null; // missing/unreadable: fall through to the lenient load
|
|
}
|
|
if (stat && stat.size > MAX_SESSION_LOAD_BYTES) {
|
|
getSessionEntriesCache().delete(key);
|
|
throw new SessionFileTooLargeError(filePath);
|
|
}
|
|
// Fast path: unchanged file is a single stat away (same key convention as
|
|
// loadSessionEntriesCached) — no parse, no dedup bookkeeping.
|
|
if (stat) {
|
|
const cached = cache.get(key);
|
|
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
|
|
cache.delete(key);
|
|
cache.set(key, cached);
|
|
return toDisplayEntries(cached.entries, options);
|
|
}
|
|
}
|
|
|
|
const inFlight = getInFlightEntries().get(key);
|
|
if (inFlight) return inFlight.then((entries) => toDisplayEntries(entries, options));
|
|
|
|
// Defer the synchronous parse by one microtask so same-tick callers land
|
|
// on the in-flight map instead of each starting their own parse.
|
|
const parse = Promise.resolve().then(() => loadSessionEntriesCached(filePath));
|
|
getInFlightEntries().set(key, parse);
|
|
try {
|
|
const entries = await parse;
|
|
return toDisplayEntries(entries, options);
|
|
} finally {
|
|
const map = getInFlightEntries();
|
|
if (map.get(key) === parse) map.delete(key);
|
|
}
|
|
}
|
|
|
|
interface HistoryEntryRange {
|
|
id: string;
|
|
offset: number;
|
|
length: number;
|
|
firstKeptEntryId?: string;
|
|
legacyCustom?: boolean;
|
|
}
|
|
|
|
interface SessionHistoryIndex {
|
|
pathKey: string;
|
|
version: string;
|
|
bytes: number;
|
|
rows: HistoryEntryRange[];
|
|
entryIds: string[];
|
|
positions: Map<string, number>;
|
|
compactionId: string | null;
|
|
metadata: Omit<SessionContext, "messages" | "entryIds">;
|
|
leafId: string | null;
|
|
tipId: string | null;
|
|
}
|
|
|
|
declare global {
|
|
var __ompSessionHistoryIndexes: Map<string, SessionHistoryIndex> | undefined;
|
|
}
|
|
|
|
// Independent of the 256 MiB RAW parse cache: only offsets, IDs and selected
|
|
// branch metadata live here, never transcript bodies or compaction summaries.
|
|
// Charge UTF-16 strings plus per-row/map overhead; both LRU bounds apply.
|
|
const MAX_HISTORY_INDEX_BYTES = 32 * 1024 * 1024;
|
|
const MAX_HISTORY_INDEXES = 32;
|
|
|
|
function historyFileVersion(stat: Stats): string {
|
|
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
|
|
}
|
|
|
|
/** The shared loader still owns title slots, lenient parsing and migrations.
|
|
* Discard bodies before retaining each entry; keep only selection inputs. */
|
|
function historyEntryMetadata(entry: SessionEntry): SessionEntry {
|
|
const compact: Record<string, unknown> = {
|
|
type: entry.type, id: entry.id, parentId: entry.parentId, timestamp: entry.timestamp,
|
|
};
|
|
if (entry.type === "message" && isRecord(entry.message)) {
|
|
const message = entry.message;
|
|
compact.message = {
|
|
role: message.role, provider: message.provider, model: message.model,
|
|
...(message.role === "toolResult" && message.toolName === "todo" ? {
|
|
toolName: message.toolName, isError: message.isError,
|
|
details: isRecord(message.details) ? { phases: message.details.phases } : undefined,
|
|
} : {}),
|
|
};
|
|
} else if (entry.type === "model_change") {
|
|
Object.assign(compact, { model: entry.model, role: entry.role, provider: entry.provider, modelId: entry.modelId });
|
|
} else if (entry.type === "thinking_level_change") {
|
|
compact.thinkingLevel = entry.thinkingLevel;
|
|
} else if (entry.type === "compaction") {
|
|
compact.firstKeptEntryId = entry.firstKeptEntryId;
|
|
compact.firstKeptEntryIndex = (entry as unknown as Record<string, unknown>).firstKeptEntryIndex;
|
|
} else if (entry.type === "branch_summary") {
|
|
compact.summary = entry.summary ? "present" : "";
|
|
} else if (entry.type === "custom" && entry.customType === "user_todo_edit") {
|
|
compact.customType = entry.customType;
|
|
compact.data = isRecord(entry.data) ? { phases: entry.data.phases } : undefined;
|
|
}
|
|
return compact as unknown as SessionEntry;
|
|
}
|
|
|
|
function buildHistoryIndex(
|
|
filePath: string, version: string, leafId?: string, includePreCompaction?: boolean,
|
|
): SessionHistoryIndex {
|
|
const ranges = new WeakMap<SessionEntry, { offset: number; length: number }>();
|
|
const loaded = loadSessionFile(filePath, {
|
|
projectEntry(entry, offset, length) {
|
|
const compact = historyEntryMetadata(entry);
|
|
ranges.set(compact, { offset, length });
|
|
return compact;
|
|
},
|
|
});
|
|
if (loaded.error === "too_large") throw new SessionFileTooLargeError(filePath);
|
|
const { selected, compactionId, ...metadata } = selectSessionContext(loaded.entries, leafId, { includePreCompaction });
|
|
const rows = selected.map((entry): HistoryEntryRange => ({
|
|
id: entry.id,
|
|
...ranges.get(entry)!,
|
|
...(entry.type === "compaction" ? { firstKeptEntryId: entry.firstKeptEntryId } : {}),
|
|
...(entry.type === "message" && entry.message.role === "custom" ? { legacyCustom: true } : {}),
|
|
}));
|
|
const tipId = loaded.entries.at(-1)?.id ?? null;
|
|
// Detach metadata strings from the parser's JSON source: an ID represented
|
|
// by a sliced V8 string must not pin a multi-megabyte message body.
|
|
const serialized = JSON.stringify({
|
|
rows, compactionId, metadata, tipId,
|
|
leafId: leafId && loaded.entries.some((entry) => entry.id === leafId) ? leafId : tipId,
|
|
});
|
|
const detached = JSON.parse(serialized) as Pick<SessionHistoryIndex, "rows" | "compactionId" | "metadata" | "tipId" | "leafId">;
|
|
const entryIds = detached.rows.map((row) => row.id);
|
|
const positions = new Map(entryIds.map((id, position) => [id, position]));
|
|
const bytes = 256 + serialized.length * 2 + rows.length * 192;
|
|
return { ...detached, pathKey: sessionPathKey(filePath), version, bytes, entryIds, positions };
|
|
}
|
|
|
|
/** Rebuild on any file-version change: growth alone cannot prove that old
|
|
* offsets survived a prefix rewrite. Both readers share the same bounded LRU. */
|
|
function getSessionHistoryIndex(filePath: string, leafId?: string, includePreCompaction = false) {
|
|
const cache = globalThis.__ompSessionHistoryIndexes ??= new Map();
|
|
const pathKey = sessionPathKey(filePath);
|
|
const key = JSON.stringify([pathKey, leafId ?? null, includePreCompaction]);
|
|
let stat: Stats | undefined;
|
|
try { stat = statSync(filePath); } catch { /* Missing/unreadable: retain the lenient empty result. */ }
|
|
if (stat && stat.size > MAX_SESSION_LOAD_BYTES) {
|
|
invalidateSessionEntriesCache(filePath);
|
|
throw new SessionFileTooLargeError(filePath);
|
|
}
|
|
const version = stat ? historyFileVersion(stat) : "";
|
|
for (const [cachedKey, cached] of cache) {
|
|
if (cached.pathKey === pathKey && cached.version !== version) cache.delete(cachedKey);
|
|
}
|
|
let index = cache.get(key);
|
|
if (!index) {
|
|
index = buildHistoryIndex(filePath, version, leafId, includePreCompaction);
|
|
if (stat && historyFileVersion(statSync(filePath)) !== version) throw new Error("Session changed while indexing history");
|
|
// An unusually metadata-heavy file still opens, but cannot evict every
|
|
// peer or retain more than the entire index budget by itself.
|
|
if (stat && index.bytes <= MAX_HISTORY_INDEX_BYTES) cache.set(key, index);
|
|
} else {
|
|
cache.delete(key);
|
|
cache.set(key, index);
|
|
}
|
|
let totalBytes = 0;
|
|
for (const cached of cache.values()) totalBytes += cached.bytes;
|
|
while (cache.size > MAX_HISTORY_INDEXES || totalBytes > MAX_HISTORY_INDEX_BYTES) {
|
|
const oldest = cache.keys().next().value!;
|
|
totalBytes -= cache.get(oldest)!.bytes;
|
|
cache.delete(oldest);
|
|
}
|
|
return { index, cache, key };
|
|
}
|
|
|
|
/** Current active context IDs only; no body seeks or blob hydration. The
|
|
* returned IDs are cached and immutable, like getSessionEntries results. */
|
|
export function getSessionContextBoundary(filePath: string): { entryIds: string[] } {
|
|
const { index } = getSessionHistoryIndex(filePath);
|
|
return { entryIds: index.entryIds };
|
|
}
|
|
|
|
/** Seek only page bodies. A file changed during indexing/reading must not
|
|
* advance the browser cursor using offsets from a different file version. */
|
|
export function getSessionHistoryPage(
|
|
filePath: string,
|
|
cursor: SessionHistoryCursor | null,
|
|
limit: number,
|
|
leafId?: string,
|
|
options: { deferThinking?: boolean; deferToolResultImages?: boolean; includePreCompaction?: boolean } = {},
|
|
): { history: SessionHistoryPage; leafId: string | null; tipId: string | null } {
|
|
const { index, cache, key } = getSessionHistoryIndex(filePath, leafId, !!options.includePreCompaction);
|
|
const version = index.version;
|
|
|
|
const { start, end, ...page } = selectHistoryRange(index.entryIds, cursor, limit, index.positions);
|
|
const rawEntries: SessionEntry[] = [];
|
|
if (start < end) {
|
|
const fd = openSync(filePath, "r");
|
|
try {
|
|
if (historyFileVersion(fstatSync(fd)) !== version) throw new Error("Session changed before reading history");
|
|
for (let i = start; i < end; i++) {
|
|
const row = index.rows[i];
|
|
const buffer = Buffer.allocUnsafe(row.length);
|
|
let read = 0;
|
|
while (read < buffer.length) {
|
|
const count = readSync(fd, buffer, read, buffer.length - read, row.offset + read);
|
|
if (count === 0) throw new Error("Session truncated while reading history");
|
|
read += count;
|
|
}
|
|
const entry = JSON.parse(buffer.toString("utf8").trim()) as SessionEntry;
|
|
// Reuse IDs/compaction links assigned by v1 migration; v2 renamed the
|
|
// hookMessage role. No migrated body is retained between requests.
|
|
entry.id = row.id;
|
|
if (entry.type === "compaction") entry.firstKeptEntryId = row.firstKeptEntryId!;
|
|
if (entry.type === "message" && row.legacyCustom) {
|
|
const message = entry.message as unknown as Record<string, unknown>;
|
|
if (message.role === "hookMessage") message.role = "custom";
|
|
}
|
|
rawEntries.push(entry);
|
|
}
|
|
if (historyFileVersion(fstatSync(fd)) !== version || historyFileVersion(statSync(filePath)) !== version) {
|
|
throw new Error("Session changed while reading history");
|
|
}
|
|
} catch (error) {
|
|
cache.delete(key);
|
|
throw error;
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
const entries = toDisplayEntries(rawEntries, { skipToolResultImages: options.deferToolResultImages });
|
|
const history: SessionHistoryPage = {
|
|
...page,
|
|
context: {
|
|
...index.metadata,
|
|
entryIds: index.entryIds.slice(start, end),
|
|
messages: entries.map((entry) => entry.type === "compaction"
|
|
? compactionUiMessage(entry, entry.id === index.compactionId)
|
|
: entryToUiMessage(entry, options)!),
|
|
},
|
|
};
|
|
return { history, leafId: index.leafId, tipId: index.tipId };
|
|
}
|
|
|
|
function parseTodoPhases(value: unknown): TodoPhase[] | null {
|
|
if (!Array.isArray(value)) return null;
|
|
const statuses = new Set(["pending", "in_progress", "completed", "blocked", "abandoned"]);
|
|
const phases: TodoPhase[] = [];
|
|
for (const phase of value) {
|
|
if (!isRecord(phase) || typeof phase.name !== "string" || !Array.isArray(phase.tasks)) return null;
|
|
const tasks = [];
|
|
for (const task of phase.tasks) {
|
|
if (!isRecord(task) || typeof task.content !== "string" || typeof task.status !== "string" || !statuses.has(task.status)) return null;
|
|
if (task.blocker !== undefined && typeof task.blocker !== "string") return null;
|
|
tasks.push({
|
|
content: task.content,
|
|
status: task.status as TodoPhase["tasks"][number]["status"],
|
|
...(typeof task.blocker === "string" ? { blocker: task.blocker } : {}),
|
|
});
|
|
}
|
|
phases.push({ name: phase.name, tasks });
|
|
}
|
|
return phases;
|
|
}
|
|
|
|
/** Latest valid todo snapshot on the selected branch, without exposing tool metadata to the client. */
|
|
export function getTodoPhasesFromEntries(entries: SessionEntry[], leafId?: string | null): TodoPhase[] {
|
|
if (leafId === null || entries.length === 0) return [];
|
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
let entry = leafId ? byId.get(leafId) : entries[entries.length - 1];
|
|
const path: SessionEntry[] = [];
|
|
const seen = new Set<string>();
|
|
while (entry && !seen.has(entry.id)) {
|
|
seen.add(entry.id);
|
|
path.push(entry);
|
|
entry = entry.parentId ? byId.get(entry.parentId) : undefined;
|
|
}
|
|
|
|
for (let index = 0; index < path.length; index++) {
|
|
const current = path[index];
|
|
if (current.type === "custom" && current.customType === "user_todo_edit") {
|
|
const phases = isRecord(current.data) ? parseTodoPhases(current.data.phases) : null;
|
|
if (phases) return phases;
|
|
}
|
|
if (current.type === "message" && isRecord(current.message) && current.message.role === "toolResult") {
|
|
const message = current.message as { role?: string; toolName?: string; isError?: boolean; details?: unknown };
|
|
if (message.toolName !== "todo" || message.isError) continue;
|
|
const phases = isRecord(message.details) ? parseTodoPhases(message.details.phases) : null;
|
|
if (phases) return phases;
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
const SUPERSEDED_COMPACTION_SUMMARY = "[Superseded compaction summary elided after a newer compaction]";
|
|
|
|
/**
|
|
* Build the display context for a leaf. Port of oh-my-pi's buildSessionContext
|
|
* (session/session-context.ts) path-walk semantics — leaf→root walk with a
|
|
* cycle guard, firstKeptEntryId compaction collapsing, role-based model
|
|
* tracking with legacy assistant-message inference — combined with pi-web's
|
|
* UI message conversion: messages/entryIds stay parallel so fork/navigation
|
|
* targets remain aligned, and the active compaction summary is emitted first
|
|
* (the collapsed view the pi-web UI is built around).
|
|
*/
|
|
export function buildSessionContext(
|
|
entries: SessionEntry[],
|
|
leafId?: string | null,
|
|
options: { deferThinking?: boolean; deferToolResultImages?: boolean; includePreCompaction?: boolean } = {},
|
|
): SessionContext {
|
|
const { selected, compactionId, ...metadata } = selectSessionContext(entries, leafId, options);
|
|
return {
|
|
...metadata,
|
|
entryIds: selected.map((entry) => entry.id),
|
|
messages: selected.map((entry) => entry.type === "compaction"
|
|
? compactionUiMessage(entry, entry.id === compactionId)
|
|
: entryToUiMessage(entry, options)!),
|
|
};
|
|
}
|
|
|
|
/** Select ordering and branch metadata without converting message bodies. */
|
|
function selectSessionContext(
|
|
entries: SessionEntry[],
|
|
leafId?: string | null,
|
|
options: { includePreCompaction?: boolean } = {},
|
|
) {
|
|
const empty = { selected: [] as SessionEntry[], compactionId: null as string | null, thinkingLevel: "off", model: null as SessionContext["model"], todoPhases: [] as TodoPhase[] };
|
|
const byId = new Map<string, SessionEntry>();
|
|
for (const e of entries) byId.set(e.id, e);
|
|
if (leafId === null) return empty;
|
|
const leaf = (leafId ? byId.get(leafId) : undefined) ?? entries.at(-1);
|
|
if (!leaf) return empty;
|
|
|
|
// Walk leaf → root, stopping at the first corrupt parent cycle.
|
|
const path: SessionEntry[] = [];
|
|
const seen = new Set<string>();
|
|
let current: SessionEntry | undefined = leaf;
|
|
while (current && !seen.has(current.id)) {
|
|
seen.add(current.id);
|
|
path.push(current);
|
|
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
}
|
|
path.reverse();
|
|
|
|
let thinkingLevel = "off";
|
|
const models: Record<string, string> = {};
|
|
// Explicit defaults take precedence over temporary assistant fallbacks.
|
|
let hasExplicitDefaultModel = false;
|
|
let compaction: CompactionEntry | null = null;
|
|
for (const entry of path) {
|
|
if (entry.type === "thinking_level_change") {
|
|
thinkingLevel = entry.thinkingLevel ?? "off";
|
|
} else if (entry.type === "model_change") {
|
|
if (entry.model) {
|
|
const role = entry.role ?? "default";
|
|
models[role] = entry.model;
|
|
if (role === "default") hasExplicitDefaultModel = true;
|
|
} else if (entry.provider && entry.modelId) {
|
|
models.default = `${entry.provider}/${entry.modelId}`;
|
|
hasExplicitDefaultModel = true;
|
|
}
|
|
} else if (entry.type === "message" && isRecord(entry.message) && entry.message.role === "assistant") {
|
|
if (!hasExplicitDefaultModel && entry.message.provider && entry.message.model) {
|
|
models.default = `${entry.message.provider}/${entry.message.model}`;
|
|
}
|
|
} else if (entry.type === "compaction") {
|
|
compaction = entry;
|
|
}
|
|
}
|
|
|
|
const selected: SessionEntry[] = [];
|
|
const appendEntry = (entry: SessionEntry) => {
|
|
if (entry.type === "compaction" || entry.type === "custom_message"
|
|
|| (entry.type === "message" && isRecord(entry.message))
|
|
|| (entry.type === "branch_summary" && entry.summary)) {
|
|
selected.push(entry);
|
|
}
|
|
};
|
|
if (compaction && !options.includePreCompaction) {
|
|
const activeCompaction = compaction;
|
|
appendEntry(activeCompaction);
|
|
const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === activeCompaction.id);
|
|
let foundFirstKept = false;
|
|
for (let i = 0; i < compactionIdx; i++) {
|
|
const entry = path[i];
|
|
if (entry.id === activeCompaction.firstKeptEntryId) foundFirstKept = true;
|
|
if (foundFirstKept) appendEntry(entry);
|
|
}
|
|
for (let i = compactionIdx + 1; i < path.length; i++) appendEntry(path[i]);
|
|
} else {
|
|
for (const entry of path) appendEntry(entry);
|
|
}
|
|
|
|
let model: SessionContext["model"] = null;
|
|
const defaultModel = models.default;
|
|
if (defaultModel) {
|
|
const separator = defaultModel.indexOf("/");
|
|
model = separator > 0
|
|
? { provider: defaultModel.slice(0, separator), modelId: defaultModel.slice(separator + 1) }
|
|
: { provider: "", modelId: defaultModel };
|
|
}
|
|
return { selected, compactionId: compaction?.id ?? null, thinkingLevel, model, todoPhases: getTodoPhasesFromEntries(entries, leafId) };
|
|
}
|
|
|
|
function parseEntryTimestamp(timestamp: string): number | undefined {
|
|
const parsed = Date.parse(timestamp);
|
|
return Number.isNaN(parsed) ? undefined : parsed;
|
|
}
|
|
|
|
/** Message-level timestamps arrive as epoch numbers on the wire but ISO
|
|
* strings in hand-edited/imported files — normalize both to number|undefined
|
|
* so downstream arithmetic never sees a string (NaN / Invalid Date). */
|
|
function normalizeMessageTimestamp(value: unknown): number | undefined {
|
|
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
if (typeof value === "string") return parseEntryTimestamp(value);
|
|
return undefined;
|
|
}
|
|
|
|
/** Pass-through branches (user/assistant/toolResult) return the raw message
|
|
* object — repair only its timestamp, copying when it needs it. */
|
|
function withNormalizedTimestamp(message: AgentMessage): AgentMessage {
|
|
const raw = message.timestamp;
|
|
const timestamp = normalizeMessageTimestamp(raw);
|
|
return timestamp === raw ? message : { ...message, timestamp };
|
|
}
|
|
|
|
function base64ImageInfo(block: unknown): { bytes: number; mime?: string } | null {
|
|
if (!isRecord(block) || block.type !== "image") return null;
|
|
|
|
let data: string | undefined;
|
|
let mime: string | undefined;
|
|
if (typeof block.data === "string") {
|
|
data = block.data;
|
|
mime = typeof block.mimeType === "string" ? block.mimeType : undefined;
|
|
} else if (isRecord(block.source) && block.source.type === "base64" && typeof block.source.data === "string") {
|
|
data = block.source.data;
|
|
mime = typeof block.source.media_type === "string" ? block.source.media_type : undefined;
|
|
}
|
|
if (!data) return null;
|
|
|
|
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
return { bytes: Math.max(0, Math.floor(data.length * 3 / 4) - padding), mime };
|
|
}
|
|
|
|
/**
|
|
* toolResult `details` is provider/tool-internal metadata that dominates real
|
|
* history payloads (measured 643KB of a 1.34MB context on a 533-entry session),
|
|
* and the history UI reads exactly two keys from it: details.patch and
|
|
* details.diff (components/MessageView.tsx getResultDiff). Serialization
|
|
* allowlists those keys — extend the allowlist if MessageView ever reads more.
|
|
* Task toolResults additionally keep a SIZE-BOUNDED subset of their
|
|
* results/progress (telemetry, no full output) so the message flow can render
|
|
* a per-subagent summary. On-disk parsing (loadSessionFile/getSessionEntries)
|
|
* keeps full details.
|
|
*/
|
|
const TASK_DETAIL_MAX_TEXT = 240;
|
|
const TASK_DETAIL_MAX_ROWS = 50;
|
|
|
|
function truncateTaskDetailText(value: string): string {
|
|
if (value.length <= TASK_DETAIL_MAX_TEXT) return value;
|
|
// Cut by code point so a boundary can never split a surrogate pair.
|
|
return `${[...value].slice(0, TASK_DETAIL_MAX_TEXT).join("")}…`;
|
|
}
|
|
|
|
function taskDetailAsync(value: unknown): Record<string, string> | undefined {
|
|
// Project async to its documented fields only (state/jobId/type) — extra
|
|
// payload fields must not ride the bounded history response.
|
|
if (!isRecord(value)) return undefined;
|
|
const out: Record<string, string> = {};
|
|
for (const key of ["state", "jobId", "type"] as const) {
|
|
if (typeof value[key] === "string") out[key] = truncateTaskDetailText(value[key]);
|
|
}
|
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
}
|
|
|
|
const TASK_DETAIL_PROGRESS_KEYS = [
|
|
"index", "id", "agent", "agentSource", "status", "task", "assignment", "lastIntent",
|
|
"toolCount", "requests", "tokens", "contextTokens", "contextWindow", "cost", "durationMs",
|
|
"modelRole", "resolvedModel", "resolvedModelIsFallback", "retryFailure",
|
|
] as const;
|
|
|
|
const TASK_DETAIL_RESULT_KEYS = [
|
|
"index", "id", "agent", "agentSource", "status", "task", "assignment", "exitCode",
|
|
"truncated", "structuredOutput", "durationMs", "tokens", "requests", "contextTokens",
|
|
"contextWindow", "modelRole", "resolvedModel", "resolvedModelIsFallback", "error",
|
|
"aborted", "abortReason", "outputPath", "patchPath", "branchName", "retryFailure",
|
|
] as const;
|
|
|
|
function keepTaskToolResultDetails(details: Record<string, unknown>): Record<string, unknown> | null {
|
|
const kept: Record<string, unknown> = {};
|
|
const results = Array.isArray(details.results) ? details.results : [];
|
|
const progress = Array.isArray(details.progress) ? details.progress : [];
|
|
if (results.length === 0 && progress.length === 0 && !isRecord(details.async)) return null;
|
|
|
|
if (typeof details.totalDurationMs === "number") kept.totalDurationMs = details.totalDurationMs;
|
|
if (isRecord(details.async)) {
|
|
const asyncInfo = taskDetailAsync(details.async);
|
|
if (asyncInfo) kept.async = asyncInfo;
|
|
}
|
|
|
|
const pickRecord = (raw: unknown, keys: readonly string[]): Record<string, unknown> | null => {
|
|
if (!isRecord(raw)) return null;
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of keys) {
|
|
if (raw[key] !== undefined) out[key] = raw[key];
|
|
}
|
|
// Bound every string field that rides the history payload.
|
|
for (const key of ["task", "assignment", "error", "abortReason", "lastIntent", "agent", "modelRole", "resolvedModel", "outputPath", "patchPath", "branchName"] as const) {
|
|
if (typeof out[key] === "string") out[key] = truncateTaskDetailText(out[key]);
|
|
}
|
|
// Settled results carry cost in `usage.cost`, not top-level `cost`.
|
|
if (typeof out.cost !== "number") {
|
|
const usageCost = taskResultUsageCost(raw.usage);
|
|
if (usageCost !== undefined) out.cost = usageCost;
|
|
}
|
|
// Project nested objects to bounded, UI-only shapes.
|
|
if (raw.retryFailure !== undefined) {
|
|
const retryFailure = taskResultRetryFailure(raw.retryFailure, truncateTaskDetailText);
|
|
if (retryFailure) out.retryFailure = retryFailure;
|
|
else delete out.retryFailure;
|
|
}
|
|
if (raw.structuredOutput !== undefined) {
|
|
const structuredOutput = taskResultStructuredOutput(raw.structuredOutput, truncateTaskDetailText);
|
|
if (structuredOutput) out.structuredOutput = structuredOutput;
|
|
else delete out.structuredOutput;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
if (progress.length > 0) {
|
|
kept.progress = progress
|
|
.slice(0, TASK_DETAIL_MAX_ROWS)
|
|
.map((raw) => pickRecord(raw, TASK_DETAIL_PROGRESS_KEYS))
|
|
.filter((entry): entry is Record<string, unknown> => entry !== null);
|
|
}
|
|
if (results.length > 0) {
|
|
kept.results = results
|
|
.slice(0, TASK_DETAIL_MAX_ROWS)
|
|
.map((raw) => pickRecord(raw, TASK_DETAIL_RESULT_KEYS))
|
|
.filter((entry): entry is Record<string, unknown> => entry !== null);
|
|
}
|
|
return Object.keys(kept).length > 0 ? kept : null;
|
|
}
|
|
|
|
function keepHubToolResultDetails(details: Record<string, unknown>): Record<string, unknown> | null {
|
|
const op = details.op;
|
|
if (typeof op !== "string" || (op !== "send" && op !== "jobs")) return null;
|
|
const kept: Record<string, unknown> = { op };
|
|
if (op === "send") {
|
|
const to = hubDetailTargets(details.to);
|
|
if (to) kept.to = to;
|
|
if (Array.isArray(details.receipts)) {
|
|
const receipts = details.receipts
|
|
.slice(0, TASK_DETAIL_MAX_ROWS)
|
|
.map((raw) => {
|
|
if (!isRecord(raw)) return null;
|
|
const out: Record<string, string> = {};
|
|
if (typeof raw.to === "string") out.to = truncateTaskDetailText(raw.to);
|
|
if (typeof raw.outcome === "string") out.outcome = truncateTaskDetailText(raw.outcome);
|
|
return Object.keys(out).length > 0 ? out : null;
|
|
})
|
|
.filter((entry): entry is Record<string, string> => entry !== null);
|
|
if (receipts.length > 0) kept.receipts = receipts;
|
|
}
|
|
} else {
|
|
if (Array.isArray(details.jobs)) {
|
|
const jobs = details.jobs
|
|
.slice(0, TASK_DETAIL_MAX_ROWS)
|
|
.map((raw) => {
|
|
if (!isRecord(raw)) return null;
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of ["id", "type", "status", "label", "resolvedModel"] as const) {
|
|
if (typeof raw[key] === "string") out[key] = truncateTaskDetailText(raw[key]);
|
|
}
|
|
if (typeof raw.durationMs === "number" && Number.isFinite(raw.durationMs)) out.durationMs = raw.durationMs;
|
|
return Object.keys(out).length > 0 ? out : null;
|
|
})
|
|
.filter((entry): entry is Record<string, unknown> => entry !== null);
|
|
if (jobs.length > 0) kept.jobs = jobs;
|
|
}
|
|
}
|
|
return Object.keys(kept).length > 1 ? kept : null;
|
|
}
|
|
|
|
function hubDetailTargets(value: unknown): string[] | undefined {
|
|
const list = Array.isArray(value) ? value : typeof value === "string" ? [value] : undefined;
|
|
if (!list) return undefined;
|
|
const out = list
|
|
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
|
.slice(0, TASK_DETAIL_MAX_ROWS)
|
|
.map((entry) => truncateTaskDetailText(entry));
|
|
return out.length > 0 ? out : undefined;
|
|
}
|
|
|
|
function stripToolResultDetails(message: AgentMessage): AgentMessage {
|
|
if (message.role !== "toolResult" || message.details === undefined) return message;
|
|
const { details, ...rest } = message;
|
|
if (isRecord(details)) {
|
|
const kept: Record<string, unknown> = {};
|
|
if (typeof details.patch === "string") kept.patch = details.patch;
|
|
if (typeof details.diff === "string") kept.diff = details.diff;
|
|
if (message.toolName === "task") {
|
|
const taskDetails = keepTaskToolResultDetails(details);
|
|
if (taskDetails) Object.assign(kept, taskDetails);
|
|
}
|
|
if (message.toolName === "hub") {
|
|
const hubDetails = keepHubToolResultDetails(details);
|
|
if (hubDetails) Object.assign(kept, hubDetails);
|
|
}
|
|
if (Object.keys(kept).length > 0) return { ...rest, details: kept };
|
|
}
|
|
return rest;
|
|
}
|
|
|
|
function omitToolResultBase64Images(message: AgentMessage): AgentMessage {
|
|
if (message.role !== "toolResult") return message;
|
|
// Shape-malformed-but-JSON-valid files can carry a string content here
|
|
// (import accepts arbitrary content); the loader tolerates such lines, so
|
|
// the converter must too instead of crashing the whole session view.
|
|
if (!Array.isArray(message.content)) return message;
|
|
|
|
let omitted = 0;
|
|
let bytes = 0;
|
|
const mimes = new Set<string>();
|
|
const content = message.content.filter((block) => {
|
|
const image = base64ImageInfo(block);
|
|
if (!image) return true;
|
|
omitted += 1;
|
|
bytes += image.bytes;
|
|
if (image.mime) mimes.add(image.mime);
|
|
return false;
|
|
});
|
|
if (omitted === 0) return message;
|
|
|
|
const mimeText = mimes.size > 0 ? `: ${[...mimes].join(", ")}` : "";
|
|
content.push({
|
|
type: "text",
|
|
text: `[${omitted} tool result image${omitted === 1 ? "" : "s"} omitted from initial history payload${mimeText}, ~${bytes} bytes]`,
|
|
});
|
|
return { ...message, content };
|
|
}
|
|
|
|
function compactionUiMessage(entry: CompactionEntry, active: boolean): CustomMessage {
|
|
return {
|
|
role: "custom",
|
|
customType: "compaction",
|
|
content: active ? entry.summary : SUPERSEDED_COMPACTION_SUMMARY,
|
|
display: true,
|
|
details: {
|
|
tokensBefore: entry.tokensBefore,
|
|
...(entry.tokensAfter !== undefined ? { tokensAfter: entry.tokensAfter } : {}),
|
|
...(entry.method !== undefined ? { method: entry.method } : {}),
|
|
firstKeptEntryId: entry.firstKeptEntryId,
|
|
},
|
|
timestamp: parseEntryTimestamp(entry.timestamp),
|
|
};
|
|
}
|
|
|
|
// Convert a session entry on the active branch into a UI message.
|
|
// Returns null for entries that do not map to chat history (metadata, non-message types).
|
|
export function entryToUiMessage(
|
|
entry: SessionEntry,
|
|
options: { deferThinking?: boolean; deferToolResultImages?: boolean },
|
|
): AgentMessage | null {
|
|
switch (entry.type) {
|
|
case "message": {
|
|
// Imports and hand-edits can produce message entries whose `message`
|
|
// object is missing; skip them like any other unmappable entry instead
|
|
// of crashing every reader of the file.
|
|
if (!isRecord(entry.message)) return null;
|
|
const raw = entry.message;
|
|
// omp-only roles are folded into displayable custom messages so the
|
|
// existing role-keyed UI renders them without new components.
|
|
if (raw.role === "developer") {
|
|
return {
|
|
role: "custom",
|
|
customType: "developer",
|
|
content: raw.content,
|
|
display: true,
|
|
timestamp: normalizeMessageTimestamp(raw.timestamp),
|
|
};
|
|
}
|
|
if (raw.role === "pythonExecution") {
|
|
const output = raw.output ? `\n\`\`\`\n${raw.output}\n\`\`\`` : "\n(no output)";
|
|
const status = raw.cancelled
|
|
? "\n(execution cancelled)"
|
|
: raw.exitCode ? `\nExecution failed with code ${raw.exitCode}` : "";
|
|
return {
|
|
role: "custom",
|
|
customType: "python-execution",
|
|
content: `Ran Python:\n\`\`\`python\n${raw.code}\n\`\`\`${output}${status}`,
|
|
display: true,
|
|
timestamp: normalizeMessageTimestamp(raw.timestamp),
|
|
};
|
|
}
|
|
if (raw.role === "fileMention") {
|
|
// Guard against shape-malformed entries (missing/non-array `files`)
|
|
// the same way the loader tolerates bad lines: one bad entry must not
|
|
// 500 the entire session view.
|
|
const files = Array.isArray(raw.files)
|
|
? (raw.files as unknown[]).filter((f): f is { path: string } =>
|
|
!!f && typeof f === "object" && typeof (f as { path?: unknown }).path === "string")
|
|
: [];
|
|
return {
|
|
role: "custom",
|
|
customType: "file-mention",
|
|
content: `Attached file${files.length === 1 ? "" : "s"}:\n${files.map((f) => `- ${f.path}`).join("\n")}`,
|
|
display: true,
|
|
timestamp: normalizeMessageTimestamp(raw.timestamp),
|
|
};
|
|
}
|
|
const normalized = options.deferToolResultImages
|
|
? omitToolResultBase64Images(normalizeToolCalls(raw))
|
|
: normalizeToolCalls(raw);
|
|
const message = stripToolResultDetails(normalized);
|
|
if (!options.deferThinking || message.role !== "assistant") return withNormalizedTimestamp(message);
|
|
// Guard like the loader does for bad lines: normalizeToolCalls passes
|
|
// non-array content through unchanged, so a string-content assistant
|
|
// entry must not 500 the whole context route.
|
|
if (!Array.isArray(message.content)) return withNormalizedTimestamp(message);
|
|
const deferred = {
|
|
...message,
|
|
content: message.content.map((block) => (
|
|
block.type === "thinking" && typeof block.thinking === "string" && block.thinking.trim() !== ""
|
|
? { ...block, thinking: "", deferred: true }
|
|
: block
|
|
)),
|
|
};
|
|
return withNormalizedTimestamp(deferred);
|
|
}
|
|
case "branch_summary":
|
|
if (!entry.summary) return null;
|
|
return {
|
|
role: "user",
|
|
content: `*The conversation briefly explored another branch and returned with this summary:*\n\n${entry.summary}`,
|
|
timestamp: parseEntryTimestamp(entry.timestamp),
|
|
};
|
|
case "custom_message":
|
|
return {
|
|
role: "custom",
|
|
customType: entry.customType,
|
|
content: entry.content,
|
|
display: entry.display,
|
|
details: entry.details,
|
|
timestamp: parseEntryTimestamp(entry.timestamp),
|
|
};
|
|
default:
|
|
// model_change, thinking_level_change, service_tier_change, label,
|
|
// title_change, session_init, ttsr_injection, mode_change, custom,
|
|
// session_info: metadata entries with no chat rendering.
|
|
return null;
|
|
}
|
|
}
|