Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions server/conversation-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { createHash, randomUUID } from "node:crypto";

import type { ConversationMetadata, ConversationTurn } from "../shared/protocol.ts";

/**
* A transcript is read a page at a time, the newest page re-read on every append
* while an agent works: Codex rollouts reach hundreds of MB (a 400MB one took 1.1s
* and 1.7GB of memory to parse whole). 16MB still holds dozens of turns of a
* tool-heavy session; the chat asks for the pages before it as the reader scrolls up.
*/
export const TRANSCRIPT_WINDOW_BYTES = 16 * 1024 * 1024;

export class ConversationUnavailable extends Error {
/** `screen`: the model and effort the agent's own live status line states (start-screen.ts), for the scrollback standing in */
constructor(reason: string, readonly screen?: ConversationMetadata) {
super(reason);
this.name = "ConversationUnavailable";
}
}

/** The pane's agent holds a session it has not written yet: a conversation with no turns, not a missing one. */
export class ConversationNotStarted extends ConversationUnavailable {
constructor(readonly sessionId: string, readonly source: RecognizedConversation["source"] = "omo-transcript", readonly identity: string = sessionId) {
super("session_not_written");
this.name = "ConversationNotStarted";
}
}

/** A cursor from another transcript: the pane started a new session, or a Codex backtrack replaced the file. */
export class HistoryChanged extends Error {
constructor() {
super("the conversation's transcript changed; reload it from its newest turns");
this.name = "HistoryChanged";
}
}

/** What paneConversation resolved: which store the turns came from, and where they start. */
export type RecognizedConversation = {
activity?: "thinking" | "working" | "idle";
source: "grok-transcript" | "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "cmd-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript";
turns: ConversationTurn[];
metadata: ConversationMetadata;
/** `metadata` was read off the agent's own screen (ConversationResponse.screen) */
screen?: true;
/** the first turn's position, for the page before it; null at the conversation's beginning */
cursor: string | null;
/**
* Turns the file holds on paths a `/tree` walked away from, which no page of this conversation
* can reach; absent for every agent that keeps no entry tree. pi moves its leaf without writing
* anything, so the chat would otherwise drop those turns with no sign they were ever there.
*/
abandoned?: { count: number; branches: number; summary: string | null };
history_id: string;
/** changes whenever the answer could: the route's ETag, so an unchanged poll costs no body */
version: string;
};

/** The stores read as one append-only file: OpenCode's and Devin's databases are paged by opencode.ts and devin.ts. */
export type StreamSource = Exclude<RecognizedConversation["source"], "opencode-transcript" | "devin-transcript" | "hermes-transcript">;

/** A restart may parse the same files differently: its answers never match an earlier ETag. */
const PROCESS_VERSION = randomUUID();

export function answerVersion(key: string, signature: string): string {
return createHash("sha256").update(`${PROCESS_VERSION}\0${key}\0${signature}`).digest("base64url").slice(0, 22);
}

/**
* Which turns: without `before`, the newest page (with `from`, from that held start
* while it is still inside the newest page); with `before`, the page ending there,
* never reaching back past `since`.
*/
export type ConversationPage = { before?: string; since?: string; from?: string };
71 changes: 2 additions & 69 deletions server/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { resolveAgyStore, sameAgyStore, type AgyStore } from "./agy-store.ts";
import { withinRetentionBudget } from "./retention-budget.ts";
import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchRecords, piBranchSegments } from "./pi-tree.ts";
import { parseConversationMetadata } from "./conversation-metadata.ts";
import { answerVersion, ConversationNotStarted, ConversationUnavailable, type ConversationPage, HistoryChanged, type RecognizedConversation, type StreamSource, TRANSCRIPT_WINDOW_BYTES } from "./conversation-shared.ts";
import { forgetHermesTranscriptState, hermesConversationPage, hermesToolOutput, hermesTranscriptForPane } from "./hermes.ts";

import { claudePeerNotice, createOmpTranscriptParser, isContextClear, MAX_TURNS, parseOmpTranscript, piImageBlock, piMessage, piResults, type TranscriptParser } from "./transcript-records.ts";
Expand All @@ -69,14 +70,8 @@ import { commandTranscriptRecords, type CommandTranscriptRecord } from "./outcom

export { isOmoProcess } from "./omo.ts";
export { createClaudeTranscriptParser, parseClaudeTranscript, unwrapPastes } from "./claude-transcript.ts";
export { TRANSCRIPT_WINDOW_BYTES, ConversationUnavailable, ConversationNotStarted, HistoryChanged, answerVersion, type RecognizedConversation, type ConversationPage } from "./conversation-shared.ts";

/**
* A transcript is read a page at a time, the newest page re-read on every append
* while an agent works: Codex rollouts reach hundreds of MB (a 400MB one took 1.1s
* and 1.7GB of memory to parse whole). 16MB still holds dozens of turns of a
* tool-heavy session; the chat asks for the pages before it as the reader scrolls up.
*/
export const TRANSCRIPT_WINDOW_BYTES = 16 * 1024 * 1024;
const PI_PAGE_CACHE_BYTES = 4 * 1024 * 1024;

/** A page never holds more prompts than this (each opens a user + assistant pair). */
Expand All @@ -97,68 +92,6 @@ const writtenSessions = new Set<string>();
/** Re-parse on file changes, including replacement and same-size rewrites. */
const cache = new Map<string, { source: RecognizedConversation["source"]; signature: string; turns: ConversationTurn[]; metadata: ConversationMetadata; cursor: string | null; abandoned?: { count: number; branches: number; summary: string | null } }>();

export class ConversationUnavailable extends Error {
/** `screen`: the model and effort the agent's own live status line states (start-screen.ts), for the scrollback standing in */
constructor(reason: string, readonly screen?: ConversationMetadata) {
super(reason);
this.name = "ConversationUnavailable";
}
}

/** The pane's agent holds a session it has not written yet: a conversation with no turns, not a missing one. */
export class ConversationNotStarted extends ConversationUnavailable {
constructor(readonly sessionId: string, readonly source: RecognizedConversation["source"] = "omo-transcript", readonly identity: string = sessionId) {
super("session_not_written");
this.name = "ConversationNotStarted";
}
}

/** A cursor from another transcript: the pane started a new session, or a Codex backtrack replaced the file. */
export class HistoryChanged extends Error {
constructor() {
super("the conversation's transcript changed; reload it from its newest turns");
this.name = "HistoryChanged";
}
}

/** What paneConversation resolved: which store the turns came from, and where they start. */
export type RecognizedConversation = {
activity?: "thinking" | "working" | "idle";
source: "grok-transcript" | "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "cmd-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript";
turns: ConversationTurn[];
metadata: ConversationMetadata;
/** `metadata` was read off the agent's own screen (ConversationResponse.screen) */
screen?: true;
/** the first turn's position, for the page before it; null at the conversation's beginning */
cursor: string | null;
/**
* Turns the file holds on paths a `/tree` walked away from, which no page of this conversation
* can reach; absent for every agent that keeps no entry tree. pi moves its leaf without writing
* anything, so the chat would otherwise drop those turns with no sign they were ever there.
*/
abandoned?: { count: number; branches: number; summary: string | null };
history_id: string;
/** changes whenever the answer could: the route's ETag, so an unchanged poll costs no body */
version: string;
};

/** The stores read as one append-only file: OpenCode's and Devin's databases are paged by opencode.ts and devin.ts. */
type StreamSource = Exclude<RecognizedConversation["source"], "opencode-transcript" | "devin-transcript" | "hermes-transcript">;

/** A restart may parse the same files differently: its answers never match an earlier ETag. */
const PROCESS_VERSION = randomUUID();

export function answerVersion(key: string, signature: string): string {
return createHash("sha256").update(`${PROCESS_VERSION}\0${key}\0${signature}`).digest("base64url").slice(0, 22);
}

/**
* Which turns: without `before`, the newest page (with `from`, from that held start
* while it is still inside the newest page); with `before`, the page ending there,
* never reaching back past `since`.
*/
export type ConversationPage = { before?: string; since?: string; from?: string };

/**
* A transcript as one byte stream: for a paginated Codex rollout the history it
* continues comes first (codexHistorySegments). Transcripts only grow at the end,
Expand Down
2 changes: 1 addition & 1 deletion server/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { readFile, stat } from "node:fs/promises";

import type { ConversationMetadata, ConversationPart, ConversationTurn, HerdrPane } from "../shared/protocol.ts";
import { herdrRpc } from "./herdr/client.ts";
import { answerVersion, ConversationNotStarted, ConversationUnavailable, type ConversationPage, HistoryChanged, type RecognizedConversation, TRANSCRIPT_WINDOW_BYTES } from "./conversation.ts";
import { answerVersion, ConversationNotStarted, ConversationUnavailable, type ConversationPage, HistoryChanged, type RecognizedConversation, TRANSCRIPT_WINDOW_BYTES } from "./conversation-shared.ts";
import { toolSummary } from "./transcript-records.ts";
import { TOOL_OUTPUT_CHARS, trimOutput } from "./tool-output.ts";
import { processStartedAt } from "./process-start.ts";
Expand Down
Loading