feat(agent): split harness compaction and session modules

This commit is contained in:
Mario Zechner
2026-05-03 03:13:45 +02:00
parent a5b27367d3
commit 83599e789d
13 changed files with 1910 additions and 939 deletions
@@ -0,0 +1,152 @@
import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import type { CodingAgentSessionInfo, SessionInfo, SessionTreeEntry, SessionTreeStorage } from "../types.js";
interface SessionHeader {
type: "session";
version: 3;
id: string;
timestamp: string;
cwd: string;
parentSession?: string;
}
function headerToSessionInfo(header: SessionHeader, filePath?: string): CodingAgentSessionInfo {
return {
id: header.id,
createdAt: header.timestamp,
parentSession: header.parentSession,
projectCwd: header.cwd,
filePath,
};
}
async function loadJsonlStorage(
filePath: string,
): Promise<{ header?: SessionHeader; entries: SessionTreeEntry[]; leafId: string | null }> {
try {
const content = await readFile(filePath, "utf8");
const entries: SessionTreeEntry[] = [];
let header: SessionHeader | undefined;
let leafId: string | null = null;
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
const record = JSON.parse(line) as SessionHeader | SessionTreeEntry;
if (record.type === "session") {
header = record as SessionHeader;
continue;
}
entries.push(record as SessionTreeEntry);
leafId = (record as SessionTreeEntry).id;
} catch {
// ignore malformed lines
}
}
return { header, entries, leafId };
} catch {
return { entries: [], leafId: null };
}
}
export class JsonlSessionTreeStorage implements SessionTreeStorage {
private filePath: string;
private cwd: string;
private headerInitialized = false;
private cacheLoaded = false;
private sessionInfo?: CodingAgentSessionInfo;
private entries: SessionTreeEntry[] = [];
private byId = new Map<string, SessionTreeEntry>();
private currentLeafId: string | null = null;
private requestedSessionId?: string;
private parentSession?: string;
constructor(filePath: string, options: { cwd: string; sessionId?: string; parentSession?: string }) {
this.filePath = resolve(filePath);
this.cwd = options.cwd;
this.requestedSessionId = options.sessionId;
this.parentSession = options.parentSession;
}
private async ensureParentDir(): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
}
private async ensureLoaded(): Promise<void> {
if (this.cacheLoaded) {
return;
}
const loaded = await loadJsonlStorage(this.filePath);
this.entries = loaded.entries;
this.byId = new Map(loaded.entries.map((entry) => [entry.id, entry]));
this.currentLeafId = loaded.leafId;
this.headerInitialized = loaded.header !== undefined;
if (loaded.header) {
this.sessionInfo = headerToSessionInfo(loaded.header, this.filePath);
}
this.cacheLoaded = true;
}
private async ensureHeader(): Promise<void> {
await this.ensureLoaded();
if (this.headerInitialized) return;
await this.ensureParentDir();
const header: SessionHeader = {
type: "session",
version: 3,
id: this.requestedSessionId ?? randomUUID(),
timestamp: new Date().toISOString(),
cwd: this.cwd,
parentSession: this.parentSession,
};
await writeFile(this.filePath, `${JSON.stringify(header)}\n`);
this.sessionInfo = headerToSessionInfo(header, this.filePath);
this.headerInitialized = true;
}
async getSessionInfo(): Promise<SessionInfo> {
await this.ensureHeader();
return this.sessionInfo!;
}
async getLeafId(): Promise<string | null> {
await this.ensureLoaded();
return this.currentLeafId;
}
async setLeafId(leafId: string | null): Promise<void> {
await this.ensureLoaded();
this.currentLeafId = leafId;
}
async appendEntry(entry: SessionTreeEntry): Promise<void> {
await this.ensureHeader();
await appendFile(this.filePath, `${JSON.stringify(entry)}\n`);
this.entries.push(entry);
this.byId.set(entry.id, entry);
this.currentLeafId = entry.id;
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
await this.ensureLoaded();
return this.byId.get(id);
}
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
await this.ensureLoaded();
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
while (current) {
path.unshift(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
}
return path;
}
async getEntries(): Promise<SessionTreeEntry[]> {
await this.ensureLoaded();
return [...this.entries];
}
}
@@ -0,0 +1,51 @@
import { randomUUID } from "crypto";
import type { SessionInfo, SessionTreeEntry, SessionTreeStorage } from "../types.js";
export class InMemorySessionTreeStorage implements SessionTreeStorage {
private entries: SessionTreeEntry[];
private leafId: string | null;
private sessionInfo: SessionInfo;
constructor(options?: { entries?: SessionTreeEntry[]; leafId?: string | null; sessionInfo?: SessionInfo }) {
this.entries = options?.entries ? [...options.entries] : [];
this.leafId = options?.leafId ?? this.entries[this.entries.length - 1]?.id ?? null;
this.sessionInfo = options?.sessionInfo ?? { id: randomUUID(), createdAt: new Date().toISOString() };
}
async getSessionInfo(): Promise<SessionInfo> {
return this.sessionInfo;
}
async getLeafId(): Promise<string | null> {
return this.leafId;
}
async setLeafId(leafId: string | null): Promise<void> {
this.leafId = leafId;
}
async appendEntry(entry: SessionTreeEntry): Promise<void> {
this.entries.push(entry);
this.leafId = entry.id;
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
return this.entries.find((entry) => entry.id === id);
}
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) return [];
const byId = new Map<string, SessionTreeEntry>(this.entries.map((entry) => [entry.id, entry]));
const path: SessionTreeEntry[] = [];
let current = byId.get(leafId);
while (current) {
path.unshift(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return path;
}
async getEntries(): Promise<SessionTreeEntry[]> {
return [...this.entries];
}
}
@@ -0,0 +1,231 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { v7 as uuidv7 } from "uuid";
import type {
CodingAgentSessionInfo,
CodingAgentSessionRepo,
Session,
SessionInfo,
SessionRepo,
SessionTreeEntry,
} from "../types.js";
import { JsonlSessionTreeStorage } from "./jsonl-session-storage.js";
import { InMemorySessionTreeStorage } from "./memory-session-storage.js";
import { DefaultSessionTree } from "./session-tree.js";
function createSessionId(): string {
return uuidv7();
}
function createTimestamp(): string {
return new Date().toISOString();
}
function toSession<TInfo extends SessionInfo>(info: TInfo, tree: DefaultSessionTree): Session<TInfo> {
return { info, tree };
}
function getPathEntriesToFork(
entries: SessionTreeEntry[],
entryId: string,
position: "before" | "at",
): SessionTreeEntry[] {
const byId = new Map<string, SessionTreeEntry>(entries.map((entry) => [entry.id, entry]));
const target = byId.get(entryId);
if (!target) {
throw new Error(`Entry ${entryId} not found`);
}
let effectiveLeafId: string | null;
if (position === "at") {
effectiveLeafId = target.id;
} else {
if (target.type !== "message" || target.message.role !== "user") {
throw new Error(`Entry ${entryId} is not a user message`);
}
effectiveLeafId = target.parentId;
}
if (effectiveLeafId === null) {
return [];
}
const path: SessionTreeEntry[] = [];
let current = byId.get(effectiveLeafId);
while (current) {
path.unshift(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return path;
}
export class InMemorySessionRepo implements SessionRepo<string> {
private sessions = new Map<string, Session<SessionInfo>>();
async create(options?: { id?: string; parentSession?: string }): Promise<Session<SessionInfo>> {
const info: SessionInfo = {
id: options?.id ?? createSessionId(),
createdAt: createTimestamp(),
parentSession: options?.parentSession,
};
const storage = new InMemorySessionTreeStorage({ sessionInfo: info });
const session = toSession(info, new DefaultSessionTree(storage));
this.sessions.set(info.id, session);
return session;
}
async open(ref: string): Promise<Session<SessionInfo>> {
const session = this.sessions.get(ref);
if (!session) {
throw new Error(`Session not found: ${ref}`);
}
return session;
}
async list(): Promise<Array<Session<SessionInfo>>> {
return [...this.sessions.values()];
}
async delete(ref: string): Promise<void> {
this.sessions.delete(ref);
}
async fork(
ref: string,
options: { entryId: string; position?: "before" | "at"; id?: string },
): Promise<Session<SessionInfo>> {
const source = await this.open(ref);
const entries = await source.tree.getEntries();
const forkedEntries = getPathEntriesToFork(entries, options.entryId, options.position ?? "before");
const info: SessionInfo = {
id: options.id ?? createSessionId(),
createdAt: createTimestamp(),
parentSession: source.info.id,
};
const leafId = forkedEntries[forkedEntries.length - 1]?.id ?? null;
const storage = new InMemorySessionTreeStorage({ sessionInfo: info, entries: forkedEntries, leafId });
const session = toSession(info, new DefaultSessionTree(storage));
this.sessions.set(info.id, session);
return session;
}
}
function readJsonlHeader(filePath: string): CodingAgentSessionInfo | undefined {
try {
const content = readFileSync(filePath, "utf8");
const firstLine = content.split("\n")[0];
if (!firstLine) return undefined;
const header = JSON.parse(firstLine) as {
type: string;
id: string;
timestamp: string;
cwd: string;
parentSession?: string;
};
if (header.type !== "session") return undefined;
return {
id: header.id,
createdAt: header.timestamp,
parentSession: header.parentSession,
projectCwd: header.cwd,
filePath,
};
} catch {
return undefined;
}
}
export class JsonlCodingAgentSessionRepo implements CodingAgentSessionRepo<string> {
private sessionDir: string;
private cwd: string;
constructor(options: { sessionDir: string; cwd: string }) {
this.sessionDir = resolve(options.sessionDir);
this.cwd = options.cwd;
mkdirSync(this.sessionDir, { recursive: true });
}
private createSessionFilePath(sessionId: string, timestamp: string): string {
return join(this.sessionDir, `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
}
async create(options?: { id?: string; parentSession?: string }): Promise<Session<CodingAgentSessionInfo>> {
const id = options?.id ?? createSessionId();
const createdAt = createTimestamp();
const filePath = this.createSessionFilePath(id, createdAt);
const storage = new JsonlSessionTreeStorage(filePath, {
cwd: this.cwd,
sessionId: id,
parentSession: options?.parentSession,
});
const info = (await storage.getSessionInfo()) as CodingAgentSessionInfo;
return toSession(info, new DefaultSessionTree(storage));
}
async open(ref: string): Promise<Session<CodingAgentSessionInfo>> {
const filePath = ref.includes("/") || ref.endsWith(".jsonl") ? resolve(ref) : join(this.sessionDir, ref);
if (!existsSync(filePath)) {
throw new Error(`Session not found: ${ref}`);
}
const storage = new JsonlSessionTreeStorage(filePath, { cwd: this.cwd });
const info = (await storage.getSessionInfo()) as CodingAgentSessionInfo;
return toSession(info, new DefaultSessionTree(storage));
}
async list(): Promise<Array<Session<CodingAgentSessionInfo>>> {
if (!existsSync(this.sessionDir)) {
return [];
}
const files = readdirSync(this.sessionDir)
.filter((file) => file.endsWith(".jsonl"))
.map((file) => join(this.sessionDir, file));
const sessions: Array<Session<CodingAgentSessionInfo>> = [];
for (const filePath of files) {
const info = readJsonlHeader(filePath);
if (!info) continue;
sessions.push(
toSession(info, new DefaultSessionTree(new JsonlSessionTreeStorage(filePath, { cwd: info.projectCwd }))),
);
}
return sessions;
}
async listByCwd(cwd: string): Promise<Array<Session<CodingAgentSessionInfo>>> {
return (await this.list()).filter((session) => session.info.projectCwd === cwd);
}
async getMostRecentByCwd(cwd: string): Promise<Session<CodingAgentSessionInfo> | undefined> {
const sessions = await this.listByCwd(cwd);
sessions.sort((a, b) => new Date(b.info.createdAt).getTime() - new Date(a.info.createdAt).getTime());
return sessions[0];
}
async delete(ref: string): Promise<void> {
const filePath = ref.includes("/") || ref.endsWith(".jsonl") ? resolve(ref) : join(this.sessionDir, ref);
if (existsSync(filePath)) {
rmSync(filePath, { force: true });
}
}
async fork(
ref: string,
options: { entryId: string; position?: "before" | "at"; id?: string },
): Promise<Session<CodingAgentSessionInfo>> {
const source = await this.open(ref);
const entries = await source.tree.getEntries();
const forkedEntries = getPathEntriesToFork(entries, options.entryId, options.position ?? "before");
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const filePath = this.createSessionFilePath(id, createdAt);
const storage = new JsonlSessionTreeStorage(filePath, {
cwd: source.info.projectCwd,
sessionId: id,
parentSession: source.info.filePath ?? source.info.id,
});
for (const entry of forkedEntries) {
await storage.appendEntry(entry);
}
if (forkedEntries.length === 0) {
await storage.getSessionInfo();
}
const info = (await storage.getSessionInfo()) as CodingAgentSessionInfo;
return toSession(info, new DefaultSessionTree(storage));
}
}
@@ -0,0 +1,271 @@
import { randomUUID } from "node:crypto";
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import type { AgentMessage } from "../../types.js";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.js";
import type {
BranchSummaryEntry,
CompactionEntry,
CustomEntry,
CustomMessageEntry,
LabelEntry,
MessageEntry,
ModelChangeEntry,
SessionContext,
SessionInfo,
SessionInfoEntry,
SessionTree,
SessionTreeEntry,
SessionTreeStorage,
ThinkingLevelChangeEntry,
} from "../types.js";
import { InMemorySessionTreeStorage } from "./memory-session-storage.js";
function generateId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = randomUUID().slice(0, 8);
if (!byId.has(id)) return id;
}
return randomUUID();
}
export function buildSessionContext(entries: SessionTreeEntry[]): SessionContext {
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
let compaction: CompactionEntry | null = null;
for (const entry of entries) {
if (entry.type === "thinking_level_change") {
thinkingLevel = entry.thinkingLevel;
} else if (entry.type === "model_change") {
model = { provider: entry.provider, modelId: entry.modelId };
} else if (entry.type === "message" && entry.message.role === "assistant") {
model = { provider: entry.message.provider, modelId: entry.message.model };
} else if (entry.type === "compaction") {
compaction = entry;
}
}
const messages: AgentMessage[] = [];
const appendMessage = (entry: SessionTreeEntry) => {
if (entry.type === "message") {
messages.push(entry.message);
} else if (entry.type === "custom_message") {
messages.push(
createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
);
} else if (entry.type === "branch_summary" && entry.summary) {
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
}
};
if (compaction) {
messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));
const compactionIdx = entries.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = entries[i]!;
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
if (foundFirstKept) appendMessage(entry);
}
for (let i = compactionIdx + 1; i < entries.length; i++) {
appendMessage(entries[i]!);
}
} else {
for (const entry of entries) {
appendMessage(entry);
}
}
return { messages, thinkingLevel, model };
}
export class DefaultSessionTree implements SessionTree {
private storage: SessionTreeStorage;
constructor(storage?: SessionTreeStorage) {
this.storage = storage ?? new InMemorySessionTreeStorage();
}
getLeafId(): Promise<string | null> {
return this.storage.getLeafId();
}
getEntry(id: string): Promise<SessionTreeEntry | undefined> {
return this.storage.getEntry(id);
}
getEntries(): Promise<SessionTreeEntry[]> {
return this.storage.getEntries();
}
async getBranch(fromId?: string): Promise<SessionTreeEntry[]> {
const leafId = fromId ?? (await this.storage.getLeafId());
return this.storage.getPathToRoot(leafId);
}
async buildContext(): Promise<SessionContext> {
return buildSessionContext(await this.getBranch());
}
getSessionInfo(): Promise<SessionInfo> {
return this.storage.getSessionInfo();
}
async getLabel(id: string): Promise<string | undefined> {
const entries = await this.storage.getEntries();
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]!;
if (entry.type === "label" && entry.targetId === id) {
return entry.label?.trim() || undefined;
}
}
return undefined;
}
async getSessionName(): Promise<string | undefined> {
const entries = await this.storage.getEntries();
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]!;
if (entry.type === "session_info") {
return entry.name?.trim() || undefined;
}
}
return undefined;
}
private async makeEntryId(): Promise<string> {
const entries = await this.storage.getEntries();
return generateId(new Set(entries.map((entry) => entry.id)));
}
private async appendTypedEntry<TEntry extends SessionTreeEntry>(entry: TEntry): Promise<string> {
await this.storage.appendEntry(entry);
return entry.id;
}
async appendMessage(message: AgentMessage): Promise<string> {
return this.appendTypedEntry({
type: "message",
id: await this.makeEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
message,
} satisfies MessageEntry);
}
async appendThinkingLevelChange(thinkingLevel: string): Promise<string> {
return this.appendTypedEntry({
type: "thinking_level_change",
id: await this.makeEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
thinkingLevel,
} satisfies ThinkingLevelChangeEntry);
}
async appendModelChange(provider: string, modelId: string): Promise<string> {
return this.appendTypedEntry({
type: "model_change",
id: await this.makeEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
provider,
modelId,
} satisfies ModelChangeEntry);
}
async appendCompaction<T = unknown>(
summary: string,
firstKeptEntryId: string,
tokensBefore: number,
details?: T,
fromHook?: boolean,
): Promise<string> {
return this.appendTypedEntry({
type: "compaction",
id: await this.makeEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
summary,
firstKeptEntryId,
tokensBefore,
details,
fromHook,
} satisfies CompactionEntry<T>);
}
async appendBranchSummary<T = unknown>(
fromId: string,
summary: string,
details?: T,
fromHook?: boolean,
): Promise<string> {
return this.appendTypedEntry({
type: "branch_summary",
id: await this.makeEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
fromId,
summary,
details,
fromHook,
} satisfies BranchSummaryEntry<T>);
}
async appendCustomEntry(customType: string, data?: unknown): Promise<string> {
return this.appendTypedEntry({
type: "custom",
id: await this.makeEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
customType,
data,
} satisfies CustomEntry);
}
async appendCustomMessageEntry<T = unknown>(
customType: string,
content: string | (TextContent | ImageContent)[],
display: boolean,
details?: T,
): Promise<string> {
return this.appendTypedEntry({
type: "custom_message",
id: await this.makeEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
customType,
content,
display,
details,
} satisfies CustomMessageEntry<T>);
}
async appendLabelChange(targetId: string, label: string | undefined): Promise<string> {
return this.appendTypedEntry({
type: "label",
id: await this.makeEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
targetId,
label,
} satisfies LabelEntry);
}
async appendSessionInfo(name: string): Promise<string> {
return this.appendTypedEntry({
type: "session_info",
id: await this.makeEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
name: name.trim(),
} satisfies SessionInfoEntry);
}
async moveTo(entryId: string | null): Promise<void> {
if (entryId !== null && !(await this.storage.getEntry(entryId))) {
throw new Error(`Entry ${entryId} not found`);
}
await this.storage.setLeafId(entryId);
}
}