branch:
index.ts
12482 bytesRaw
/**
* SessionManager — persistent conversation state with branching and compaction.
*
* Provides:
* - Multiple named sessions (conversations)
* - Tree-structured messages (parent_id for branching)
* - History retrieval following a branch path
* - Compaction (summarize old messages to save context)
* - Compatible with AI SDK's UIMessage type
*
* Usage:
* const sessions = new SessionManager(agent);
* const session = sessions.create("my-chat");
* sessions.append(session.id, { id: "msg1", role: "user", parts: [...] });
* const history = sessions.getHistory(session.id); // UIMessage[]
*/
import type { UIMessage } from "ai";
import { SessionStorage } from "./storage";
import type { Session, Compaction } from "./storage";
export type { Session, Compaction } from "./storage";
// ── Truncation utilities ──────────────────────────────────────────
const DEFAULT_MAX_CHARS = 30_000;
const ELLIPSIS = "\n\n... [truncated] ...\n\n";
/**
* Truncate from the head (keep the end of the content).
*/
export function truncateHead(
text: string,
maxChars: number = DEFAULT_MAX_CHARS
): string {
if (text.length <= maxChars) return text;
const keep = maxChars - ELLIPSIS.length;
if (keep <= 0) return text.slice(-maxChars);
return ELLIPSIS + text.slice(-keep);
}
/**
* Truncate from the tail (keep the start of the content).
*/
export function truncateTail(
text: string,
maxChars: number = DEFAULT_MAX_CHARS
): string {
if (text.length <= maxChars) return text;
const keep = maxChars - ELLIPSIS.length;
if (keep <= 0) return text.slice(0, maxChars);
return text.slice(0, keep) + ELLIPSIS;
}
/**
* Truncate by line count (keep the first N lines).
*/
export function truncateLines(text: string, maxLines: number = 200): string {
const lines = text.split("\n");
if (lines.length <= maxLines) return text;
const kept = lines.slice(0, maxLines).join("\n");
const omitted = lines.length - maxLines;
return kept + `\n\n... [${omitted} more lines truncated] ...`;
}
/**
* Truncate from both ends, keeping the start and end.
*/
export function truncateMiddle(
text: string,
maxChars: number = DEFAULT_MAX_CHARS
): string {
if (text.length <= maxChars) return text;
const halfKeep = Math.floor((maxChars - ELLIPSIS.length) / 2);
if (halfKeep <= 0) return text.slice(0, maxChars);
return text.slice(0, halfKeep) + ELLIPSIS + text.slice(-halfKeep);
}
/**
* Smart truncation for tool output.
*/
export function truncateToolOutput(
output: string,
options: {
maxChars?: number;
maxLines?: number;
strategy?: "head" | "tail" | "middle";
} = {}
): string {
const {
maxChars = DEFAULT_MAX_CHARS,
maxLines = 500,
strategy = "tail"
} = options;
let result = truncateLines(output, maxLines);
if (result.length > maxChars) {
switch (strategy) {
case "head":
result = truncateHead(result, maxChars);
break;
case "middle":
result = truncateMiddle(result, maxChars);
break;
case "tail":
default:
result = truncateTail(result, maxChars);
break;
}
}
return result;
}
// Mirrors Agent.sql — kept structural to avoid importing the 4k-line Agent class.
interface AgentLike {
sql: (
strings: TemplateStringsArray,
...values: (string | number | boolean | null)[]
) => Array<Record<string, unknown>>;
}
export interface SessionManagerOptions {
/**
* Maximum number of messages on the current branch before
* needsCompaction() returns true. Default: 100.
*/
maxContextMessages?: number;
/**
* Raw SQL exec function for batch operations (e.g. DELETE ... WHERE id IN (...)).
* When provided, batch deletes use a single query instead of N individual ones.
*
* Typically: `(query, ...values) => { agent.ctx.storage.sql.exec(query, ...values); }`
*/
exec?: (
query: string,
...values: (string | number | boolean | null)[]
) => void;
}
export class SessionManager {
private _storage: SessionStorage;
private _options: SessionManagerOptions;
constructor(agent: AgentLike, options: SessionManagerOptions = {}) {
this._storage = new SessionStorage(agent.sql.bind(agent), options.exec);
this._options = {
maxContextMessages: 100,
...options
};
}
// ── Session lifecycle ──────────────────────────────────────────
/**
* Create a new session with a name.
*/
create(name: string): Session {
return this._storage.createSession(crypto.randomUUID(), name);
}
/**
* Get a session by ID.
*/
get(sessionId: string): Session | null {
return this._storage.getSession(sessionId);
}
/**
* List all sessions, most recently updated first.
*/
list(): Session[] {
return this._storage.listSessions();
}
/**
* Delete a session and all its messages and compactions.
*/
delete(sessionId: string): void {
this._storage.deleteSession(sessionId);
}
/**
* Clear all messages and compactions for a session without
* deleting the session itself.
*/
clearMessages(sessionId: string): void {
this._storage.clearSessionMessages(sessionId);
}
/**
* Rename a session.
*/
rename(sessionId: string, name: string): void {
this._storage.renameSession(sessionId, name);
}
// ── Messages ───────────────────────────────────────────────────
/**
* Append a message to a session. If parentId is not provided,
* the message is appended after the latest leaf.
*
* Idempotent — appending the same message.id twice is a no-op.
*
* Returns the stored message ID.
*/
append(sessionId: string, message: UIMessage, parentId?: string): string {
const resolvedParent =
parentId ?? this._storage.getLatestLeaf(sessionId)?.id ?? null;
const id = message.id || crypto.randomUUID();
this._storage.appendMessage(id, sessionId, resolvedParent, message);
return id;
}
/**
* Insert or update a message. First call inserts, subsequent calls
* update the content. Enables incremental persistence.
*
* Idempotent on insert, content-updating on subsequent calls.
*/
upsert(sessionId: string, message: UIMessage, parentId?: string): string {
const resolvedParent =
parentId ?? this._storage.getLatestLeaf(sessionId)?.id ?? null;
const id = message.id || crypto.randomUUID();
this._storage.upsertMessage(id, sessionId, resolvedParent, message);
return id;
}
/**
* Delete a single message by ID.
* Children of the deleted message naturally become path roots
* (their parent_id points to a missing row, truncating the CTE walk).
*/
deleteMessage(messageId: string): void {
this._storage.deleteMessage(messageId);
}
/**
* Delete multiple messages by ID.
*/
deleteMessages(messageIds: string[]): void {
this._storage.deleteMessages(messageIds);
}
/**
* Append multiple messages in sequence (each parented to the previous).
* Returns the ID of the last appended message.
*/
appendAll(
sessionId: string,
messages: UIMessage[],
parentId?: string
): string | null {
let lastId = parentId ?? null;
for (const msg of messages) {
const resolvedParent =
lastId ?? this._storage.getLatestLeaf(sessionId)?.id ?? null;
const id = msg.id || crypto.randomUUID();
this._storage.appendMessage(id, sessionId, resolvedParent, msg);
lastId = id;
}
return lastId;
}
/**
* Get the conversation history for a session as UIMessage[].
*
* If leafId is provided, returns the path from root to that leaf
* (a specific branch). Otherwise returns the path to the most
* recent leaf (the "current" branch).
*
* If compactions exist, older messages covered by a compaction
* are replaced with a system message containing the summary.
*/
getHistory(sessionId: string, leafId?: string): UIMessage[] {
const leaf = leafId
? this._storage.getMessage(leafId)
: this._storage.getLatestLeaf(sessionId);
if (!leaf) return [];
const storedPath = this._storage.getMessagePath(leaf.id);
const compactions = this._storage.getCompactions(sessionId);
if (compactions.length === 0) {
return storedPath.map((m) => this._storage.parseMessage(m));
}
return this._applyCompactions(storedPath, compactions);
}
/**
* Get the total message count for a session (across all branches).
*/
getMessageCount(sessionId: string): number {
return this._storage.getMessageCount(sessionId);
}
/**
* Check if the session's current branch needs compaction.
* Uses a count-only query — does not load message content.
*/
needsCompaction(sessionId: string): boolean {
const leaf = this._storage.getLatestLeaf(sessionId);
if (!leaf) return false;
const pathLen = this._storage.getPathLength(leaf.id);
return pathLen > (this._options.maxContextMessages ?? 100);
}
// ── Branching ──────────────────────────────────────────────────
/**
* Get the children of a message (branches from that point).
*/
getBranches(messageId: string): UIMessage[] {
const children = this._storage.getChildren(messageId);
return children.map((m) => this._storage.parseMessage(m));
}
/**
* Fork a session at a specific message, creating a new session
* with the history up to that point copied over.
*/
fork(atMessageId: string, newName: string): Session {
const newSession = this.create(newName);
const path = this._storage.getMessagePath(atMessageId);
let parentId: string | null = null;
for (const stored of path) {
const msg = this._storage.parseMessage(stored);
const newId = crypto.randomUUID();
this._storage.appendMessage(newId, newSession.id, parentId, msg);
parentId = newId;
}
return newSession;
}
// ── Compaction ─────────────────────────────────────────────────
/**
* Add a compaction record. The summary replaces messages from
* fromMessageId to toMessageId in context assembly.
*
* Typically called after using an LLM to summarize older messages.
*/
addCompaction(
sessionId: string,
summary: string,
fromMessageId: string,
toMessageId: string
): Compaction {
return this._storage.addCompaction(
crypto.randomUUID(),
sessionId,
summary,
fromMessageId,
toMessageId
);
}
/**
* Get all compaction records for a session.
*/
getCompactions(sessionId: string): Compaction[] {
return this._storage.getCompactions(sessionId);
}
// ── Internal ───────────────────────────────────────────────────
private _applyCompactions(
path: Array<{ id: string; content: string }>,
compactions: Compaction[]
): UIMessage[] {
const pathIds = path.map((m) => m.id);
const result: UIMessage[] = [];
let i = 0;
while (i < path.length) {
// Check if any compaction starts at this message
const compaction = compactions.find(
(c) => c.from_message_id === pathIds[i]
);
if (compaction) {
// Only apply if the compaction's end is also on this path
const endIdx = pathIds.indexOf(compaction.to_message_id);
if (endIdx >= i) {
result.push({
id: `compaction_${compaction.id}`,
role: "system",
parts: [
{
type: "text",
text: `[Previous conversation summary]\n${compaction.summary}`
}
]
});
i = endIdx + 1;
} else {
// Compaction doesn't span this path — skip it, emit message as-is
result.push(JSON.parse(path[i].content) as UIMessage);
i++;
}
} else {
result.push(JSON.parse(path[i].content) as UIMessage);
i++;
}
}
return result;
}
}