271 lines
7.6 KiB
TypeScript
271 lines
7.6 KiB
TypeScript
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";
|
|
|
|
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<TInfo extends SessionInfo = SessionInfo> implements SessionTree {
|
|
private storage: SessionTreeStorage<TInfo>;
|
|
|
|
constructor(storage: SessionTreeStorage<TInfo>) {
|
|
this.storage = storage;
|
|
}
|
|
|
|
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<TInfo> {
|
|
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);
|
|
}
|
|
}
|