feat(agent): split harness compaction and session modules
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user