mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 18:01:22 +00:00
- CustomAgentTool renamed to CustomTool
- ToolAPI renamed to CustomToolAPI
- ToolContext renamed to CustomToolContext
- ToolSessionEvent renamed to CustomToolSessionEvent
- Added CustomToolContext parameter to execute() and onSession()
- CustomToolFactory now returns CustomTool<any, any> for type compatibility
- dispose() replaced with onSession({ reason: 'shutdown' })
- Added wrapCustomTool() to convert CustomTool to AgentTool
- Session exposes setToolUIContext() instead of leaking internals
- Fix ToolExecutionComponent to sync with toolOutputExpanded state
- Update all custom tool examples for new API
1123 lines
32 KiB
TypeScript
1123 lines
32 KiB
TypeScript
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai";
|
|
import { randomUUID } from "crypto";
|
|
import {
|
|
appendFileSync,
|
|
closeSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
readSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "fs";
|
|
import { join, resolve } from "path";
|
|
import { getAgentDir as getDefaultAgentDir } from "../config.js";
|
|
import {
|
|
type BashExecutionMessage,
|
|
createBranchSummaryMessage,
|
|
createCompactionSummaryMessage,
|
|
createHookMessage,
|
|
type HookMessage,
|
|
} from "./messages.js";
|
|
|
|
export const CURRENT_SESSION_VERSION = 2;
|
|
|
|
export interface SessionHeader {
|
|
type: "session";
|
|
version?: number; // v1 sessions don't have this
|
|
id: string;
|
|
timestamp: string;
|
|
cwd: string;
|
|
branchedFrom?: string;
|
|
}
|
|
|
|
export interface SessionEntryBase {
|
|
type: string;
|
|
id: string;
|
|
parentId: string | null;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface SessionMessageEntry extends SessionEntryBase {
|
|
type: "message";
|
|
message: AgentMessage;
|
|
}
|
|
|
|
export interface ThinkingLevelChangeEntry extends SessionEntryBase {
|
|
type: "thinking_level_change";
|
|
thinkingLevel: string;
|
|
}
|
|
|
|
export interface ModelChangeEntry extends SessionEntryBase {
|
|
type: "model_change";
|
|
provider: string;
|
|
modelId: string;
|
|
}
|
|
|
|
export interface CompactionEntry<T = unknown> extends SessionEntryBase {
|
|
type: "compaction";
|
|
summary: string;
|
|
firstKeptEntryId: string;
|
|
tokensBefore: number;
|
|
/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
|
details?: T;
|
|
/** True if generated by a hook, undefined/false if pi-generated (backward compatible) */
|
|
fromHook?: boolean;
|
|
}
|
|
|
|
export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
|
|
type: "branch_summary";
|
|
fromId: string;
|
|
summary: string;
|
|
/** Hook-specific data (not sent to LLM) */
|
|
details?: T;
|
|
/** True if generated by a hook, false if pi-generated */
|
|
fromHook?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Custom entry for hooks to store hook-specific data in the session.
|
|
* Use customType to identify your hook's entries.
|
|
*
|
|
* Purpose: Persist hook state across session reloads. On reload, hooks can
|
|
* scan entries for their customType and reconstruct internal state.
|
|
*
|
|
* Does NOT participate in LLM context (ignored by buildSessionContext).
|
|
* For injecting content into context, see CustomMessageEntry.
|
|
*/
|
|
export interface CustomEntry<T = unknown> extends SessionEntryBase {
|
|
type: "custom";
|
|
customType: string;
|
|
data?: T;
|
|
}
|
|
|
|
/** Label entry for user-defined bookmarks/markers on entries. */
|
|
export interface LabelEntry extends SessionEntryBase {
|
|
type: "label";
|
|
targetId: string;
|
|
label: string | undefined;
|
|
}
|
|
|
|
/**
|
|
* Custom message entry for hooks to inject messages into LLM context.
|
|
* Use customType to identify your hook's entries.
|
|
*
|
|
* Unlike CustomEntry, this DOES participate in LLM context.
|
|
* The content is converted to a user message in buildSessionContext().
|
|
* Use details for hook-specific metadata (not sent to LLM).
|
|
*
|
|
* display controls TUI rendering:
|
|
* - false: hidden entirely
|
|
* - true: rendered with distinct styling (different from user messages)
|
|
*/
|
|
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
|
|
type: "custom_message";
|
|
customType: string;
|
|
content: string | (TextContent | ImageContent)[];
|
|
details?: T;
|
|
display: boolean;
|
|
}
|
|
|
|
/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
|
|
export type SessionEntry =
|
|
| SessionMessageEntry
|
|
| ThinkingLevelChangeEntry
|
|
| ModelChangeEntry
|
|
| CompactionEntry
|
|
| BranchSummaryEntry
|
|
| CustomEntry
|
|
| CustomMessageEntry
|
|
| LabelEntry;
|
|
|
|
/** Raw file entry (includes header) */
|
|
export type FileEntry = SessionHeader | SessionEntry;
|
|
|
|
/** Tree node for getTree() - defensive copy of session structure */
|
|
export interface SessionTreeNode {
|
|
entry: SessionEntry;
|
|
children: SessionTreeNode[];
|
|
/** Resolved label for this entry, if any */
|
|
label?: string;
|
|
}
|
|
|
|
export interface SessionContext {
|
|
messages: AgentMessage[];
|
|
thinkingLevel: string;
|
|
model: { provider: string; modelId: string } | null;
|
|
}
|
|
|
|
export interface SessionInfo {
|
|
path: string;
|
|
id: string;
|
|
created: Date;
|
|
modified: Date;
|
|
messageCount: number;
|
|
firstMessage: string;
|
|
allMessagesText: string;
|
|
}
|
|
|
|
export type ReadonlySessionManager = Pick<
|
|
SessionManager,
|
|
| "getCwd"
|
|
| "getSessionDir"
|
|
| "getSessionId"
|
|
| "getSessionFile"
|
|
| "getLeafId"
|
|
| "getLeafEntry"
|
|
| "getEntry"
|
|
| "getLabel"
|
|
| "getBranch"
|
|
| "getHeader"
|
|
| "getEntries"
|
|
| "getTree"
|
|
>;
|
|
|
|
/** Generate a unique short ID (8 hex chars, collision-checked) */
|
|
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;
|
|
}
|
|
// Fallback to full UUID if somehow we have collisions
|
|
return randomUUID();
|
|
}
|
|
|
|
/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
|
|
function migrateV1ToV2(entries: FileEntry[]): void {
|
|
const ids = new Set<string>();
|
|
let prevId: string | null = null;
|
|
|
|
for (const entry of entries) {
|
|
if (entry.type === "session") {
|
|
entry.version = 2;
|
|
continue;
|
|
}
|
|
|
|
entry.id = generateId(ids);
|
|
entry.parentId = prevId;
|
|
prevId = entry.id;
|
|
|
|
// Convert firstKeptEntryIndex to firstKeptEntryId for compaction
|
|
if (entry.type === "compaction") {
|
|
const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };
|
|
if (typeof comp.firstKeptEntryIndex === "number") {
|
|
const targetEntry = entries[comp.firstKeptEntryIndex];
|
|
if (targetEntry && targetEntry.type !== "session") {
|
|
comp.firstKeptEntryId = targetEntry.id;
|
|
}
|
|
delete comp.firstKeptEntryIndex;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add future migrations here:
|
|
// function migrateV2ToV3(entries: FileEntry[]): void { ... }
|
|
|
|
/**
|
|
* Run all necessary migrations to bring entries to current version.
|
|
* Mutates entries in place. Returns true if any migration was applied.
|
|
*/
|
|
function migrateToCurrentVersion(entries: FileEntry[]): boolean {
|
|
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
|
|
const version = header?.version ?? 1;
|
|
|
|
if (version >= CURRENT_SESSION_VERSION) return false;
|
|
|
|
if (version < 2) migrateV1ToV2(entries);
|
|
// if (version < 3) migrateV2ToV3(entries);
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Exported for testing */
|
|
export function migrateSessionEntries(entries: FileEntry[]): void {
|
|
migrateToCurrentVersion(entries);
|
|
}
|
|
|
|
/** Exported for compaction.test.ts */
|
|
export function parseSessionEntries(content: string): FileEntry[] {
|
|
const entries: FileEntry[] = [];
|
|
const lines = content.trim().split("\n");
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const entry = JSON.parse(line) as FileEntry;
|
|
entries.push(entry);
|
|
} catch {
|
|
// Skip malformed lines
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
if (entries[i].type === "compaction") {
|
|
return entries[i] as CompactionEntry;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build the session context from entries using tree traversal.
|
|
* If leafId is provided, walks from that entry to root.
|
|
* Handles compaction and branch summaries along the path.
|
|
*/
|
|
export function buildSessionContext(
|
|
entries: SessionEntry[],
|
|
leafId?: string | null,
|
|
byId?: Map<string, SessionEntry>,
|
|
): SessionContext {
|
|
// Build uuid index if not available
|
|
if (!byId) {
|
|
byId = new Map<string, SessionEntry>();
|
|
for (const entry of entries) {
|
|
byId.set(entry.id, entry);
|
|
}
|
|
}
|
|
|
|
// Find leaf
|
|
let leaf: SessionEntry | undefined;
|
|
if (leafId === null) {
|
|
// Explicitly null - return no messages (navigated to before first entry)
|
|
return { messages: [], thinkingLevel: "off", model: null };
|
|
}
|
|
if (leafId) {
|
|
leaf = byId.get(leafId);
|
|
}
|
|
if (!leaf) {
|
|
// Fallback to last entry (when leafId is undefined)
|
|
leaf = entries[entries.length - 1];
|
|
}
|
|
|
|
if (!leaf) {
|
|
return { messages: [], thinkingLevel: "off", model: null };
|
|
}
|
|
|
|
// Walk from leaf to root, collecting path
|
|
const path: SessionEntry[] = [];
|
|
let current: SessionEntry | undefined = leaf;
|
|
while (current) {
|
|
path.unshift(current);
|
|
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
}
|
|
|
|
// Extract settings and find compaction
|
|
let thinkingLevel = "off";
|
|
let model: { provider: string; modelId: string } | null = null;
|
|
let compaction: CompactionEntry | null = null;
|
|
|
|
for (const entry of path) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Build messages and collect corresponding entries
|
|
// When there's a compaction, we need to:
|
|
// 1. Emit summary first (entry = compaction)
|
|
// 2. Emit kept messages (from firstKeptEntryId up to compaction)
|
|
// 3. Emit messages after compaction
|
|
const messages: AgentMessage[] = [];
|
|
|
|
const appendMessage = (entry: SessionEntry) => {
|
|
if (entry.type === "message") {
|
|
messages.push(entry.message);
|
|
} else if (entry.type === "custom_message") {
|
|
messages.push(
|
|
createHookMessage(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) {
|
|
// Emit summary first
|
|
messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));
|
|
|
|
// Find compaction index in path
|
|
const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
|
|
|
|
// Emit kept messages (before compaction, starting from firstKeptEntryId)
|
|
let foundFirstKept = false;
|
|
for (let i = 0; i < compactionIdx; i++) {
|
|
const entry = path[i];
|
|
if (entry.id === compaction.firstKeptEntryId) {
|
|
foundFirstKept = true;
|
|
}
|
|
if (foundFirstKept) {
|
|
appendMessage(entry);
|
|
}
|
|
}
|
|
|
|
// Emit messages after compaction
|
|
for (let i = compactionIdx + 1; i < path.length; i++) {
|
|
const entry = path[i];
|
|
appendMessage(entry);
|
|
}
|
|
} else {
|
|
// No compaction - emit all messages, handle branch summaries and custom messages
|
|
for (const entry of path) {
|
|
appendMessage(entry);
|
|
}
|
|
}
|
|
|
|
return { messages, thinkingLevel, model };
|
|
}
|
|
|
|
/**
|
|
* Compute the default session directory for a cwd.
|
|
* Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
|
|
*/
|
|
function getDefaultSessionDir(cwd: string): string {
|
|
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
const sessionDir = join(getDefaultAgentDir(), "sessions", safePath);
|
|
if (!existsSync(sessionDir)) {
|
|
mkdirSync(sessionDir, { recursive: true });
|
|
}
|
|
return sessionDir;
|
|
}
|
|
|
|
/** Exported for testing */
|
|
export function loadEntriesFromFile(filePath: string): FileEntry[] {
|
|
if (!existsSync(filePath)) return [];
|
|
|
|
const content = readFileSync(filePath, "utf8");
|
|
const entries: FileEntry[] = [];
|
|
const lines = content.trim().split("\n");
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const entry = JSON.parse(line) as FileEntry;
|
|
entries.push(entry);
|
|
} catch {
|
|
// Skip malformed lines
|
|
}
|
|
}
|
|
|
|
// Validate session header
|
|
if (entries.length === 0) return entries;
|
|
const header = entries[0];
|
|
if (header.type !== "session" || typeof (header as any).id !== "string") {
|
|
return [];
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function isValidSessionFile(filePath: string): boolean {
|
|
try {
|
|
const fd = openSync(filePath, "r");
|
|
const buffer = Buffer.alloc(512);
|
|
const bytesRead = readSync(fd, buffer, 0, 512, 0);
|
|
closeSync(fd);
|
|
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0];
|
|
if (!firstLine) return false;
|
|
const header = JSON.parse(firstLine);
|
|
return header.type === "session" && typeof header.id === "string";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Exported for testing */
|
|
export function findMostRecentSession(sessionDir: string): string | null {
|
|
try {
|
|
const files = readdirSync(sessionDir)
|
|
.filter((f) => f.endsWith(".jsonl"))
|
|
.map((f) => join(sessionDir, f))
|
|
.filter(isValidSessionFile)
|
|
.map((path) => ({ path, mtime: statSync(path).mtime }))
|
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
|
|
return files[0]?.path || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manages conversation sessions as append-only trees stored in JSONL files.
|
|
*
|
|
* Each session entry has an id and parentId forming a tree structure. The "leaf"
|
|
* pointer tracks the current position. Appending creates a child of the current leaf.
|
|
* Branching moves the leaf to an earlier entry, allowing new branches without
|
|
* modifying history.
|
|
*
|
|
* Use buildSessionContext() to get the resolved message list for the LLM, which
|
|
* handles compaction summaries and follows the path from root to current leaf.
|
|
*/
|
|
export class SessionManager {
|
|
private sessionId: string = "";
|
|
private sessionFile: string | undefined;
|
|
private sessionDir: string;
|
|
private cwd: string;
|
|
private persist: boolean;
|
|
private flushed: boolean = false;
|
|
private fileEntries: FileEntry[] = [];
|
|
private byId: Map<string, SessionEntry> = new Map();
|
|
private labelsById: Map<string, string> = new Map();
|
|
private leafId: string | null = null;
|
|
|
|
private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
|
|
this.cwd = cwd;
|
|
this.sessionDir = sessionDir;
|
|
this.persist = persist;
|
|
if (persist && sessionDir && !existsSync(sessionDir)) {
|
|
mkdirSync(sessionDir, { recursive: true });
|
|
}
|
|
|
|
if (sessionFile) {
|
|
this.setSessionFile(sessionFile);
|
|
} else {
|
|
this.newSession();
|
|
}
|
|
}
|
|
|
|
/** Switch to a different session file (used for resume and branching) */
|
|
setSessionFile(sessionFile: string): void {
|
|
this.sessionFile = resolve(sessionFile);
|
|
if (existsSync(this.sessionFile)) {
|
|
this.fileEntries = loadEntriesFromFile(this.sessionFile);
|
|
const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
|
|
this.sessionId = header?.id ?? randomUUID();
|
|
|
|
if (migrateToCurrentVersion(this.fileEntries)) {
|
|
this._rewriteFile();
|
|
}
|
|
|
|
this._buildIndex();
|
|
this.flushed = true;
|
|
} else {
|
|
this.newSession();
|
|
}
|
|
}
|
|
|
|
newSession(): string | undefined {
|
|
this.sessionId = randomUUID();
|
|
const timestamp = new Date().toISOString();
|
|
const header: SessionHeader = {
|
|
type: "session",
|
|
version: CURRENT_SESSION_VERSION,
|
|
id: this.sessionId,
|
|
timestamp,
|
|
cwd: this.cwd,
|
|
};
|
|
this.fileEntries = [header];
|
|
this.byId.clear();
|
|
this.leafId = null;
|
|
this.flushed = false;
|
|
// Only generate filename if persisting and not already set (e.g., via --session flag)
|
|
if (this.persist && !this.sessionFile) {
|
|
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
|
|
}
|
|
return this.sessionFile;
|
|
}
|
|
|
|
private _buildIndex(): void {
|
|
this.byId.clear();
|
|
this.labelsById.clear();
|
|
this.leafId = null;
|
|
for (const entry of this.fileEntries) {
|
|
if (entry.type === "session") continue;
|
|
this.byId.set(entry.id, entry);
|
|
this.leafId = entry.id;
|
|
if (entry.type === "label") {
|
|
if (entry.label) {
|
|
this.labelsById.set(entry.targetId, entry.label);
|
|
} else {
|
|
this.labelsById.delete(entry.targetId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private _rewriteFile(): void {
|
|
if (!this.persist || !this.sessionFile) return;
|
|
const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`;
|
|
writeFileSync(this.sessionFile, content);
|
|
}
|
|
|
|
isPersisted(): boolean {
|
|
return this.persist;
|
|
}
|
|
|
|
getCwd(): string {
|
|
return this.cwd;
|
|
}
|
|
|
|
getSessionDir(): string {
|
|
return this.sessionDir;
|
|
}
|
|
|
|
getSessionId(): string {
|
|
return this.sessionId;
|
|
}
|
|
|
|
getSessionFile(): string | undefined {
|
|
return this.sessionFile;
|
|
}
|
|
|
|
_persist(entry: SessionEntry): void {
|
|
if (!this.persist || !this.sessionFile) return;
|
|
|
|
const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
|
|
if (!hasAssistant) return;
|
|
|
|
if (!this.flushed) {
|
|
for (const e of this.fileEntries) {
|
|
appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
|
|
}
|
|
this.flushed = true;
|
|
} else {
|
|
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
}
|
|
}
|
|
|
|
private _appendEntry(entry: SessionEntry): void {
|
|
this.fileEntries.push(entry);
|
|
this.byId.set(entry.id, entry);
|
|
this.leafId = entry.id;
|
|
this._persist(entry);
|
|
}
|
|
|
|
/** Append a message as child of current leaf, then advance leaf. Returns entry id.
|
|
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
|
|
* Reason: we want these to be top-level entries in the session, not message session entries,
|
|
* so it is easier to find them.
|
|
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
|
|
*/
|
|
appendMessage(message: Message | HookMessage | BashExecutionMessage): string {
|
|
const entry: SessionMessageEntry = {
|
|
type: "message",
|
|
id: generateId(this.byId),
|
|
parentId: this.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
message,
|
|
};
|
|
this._appendEntry(entry);
|
|
return entry.id;
|
|
}
|
|
|
|
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
|
|
appendThinkingLevelChange(thinkingLevel: string): string {
|
|
const entry: ThinkingLevelChangeEntry = {
|
|
type: "thinking_level_change",
|
|
id: generateId(this.byId),
|
|
parentId: this.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
thinkingLevel,
|
|
};
|
|
this._appendEntry(entry);
|
|
return entry.id;
|
|
}
|
|
|
|
/** Append a model change as child of current leaf, then advance leaf. Returns entry id. */
|
|
appendModelChange(provider: string, modelId: string): string {
|
|
const entry: ModelChangeEntry = {
|
|
type: "model_change",
|
|
id: generateId(this.byId),
|
|
parentId: this.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
provider,
|
|
modelId,
|
|
};
|
|
this._appendEntry(entry);
|
|
return entry.id;
|
|
}
|
|
|
|
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
|
|
appendCompaction<T = unknown>(
|
|
summary: string,
|
|
firstKeptEntryId: string,
|
|
tokensBefore: number,
|
|
details?: T,
|
|
fromHook?: boolean,
|
|
): string {
|
|
const entry: CompactionEntry<T> = {
|
|
type: "compaction",
|
|
id: generateId(this.byId),
|
|
parentId: this.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
summary,
|
|
firstKeptEntryId,
|
|
tokensBefore,
|
|
details,
|
|
fromHook,
|
|
};
|
|
this._appendEntry(entry);
|
|
return entry.id;
|
|
}
|
|
|
|
/** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */
|
|
appendCustomEntry(customType: string, data?: unknown): string {
|
|
const entry: CustomEntry = {
|
|
type: "custom",
|
|
customType,
|
|
data,
|
|
id: generateId(this.byId),
|
|
parentId: this.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
this._appendEntry(entry);
|
|
return entry.id;
|
|
}
|
|
|
|
/**
|
|
* Append a custom message entry (for hooks) that participates in LLM context.
|
|
* @param customType Hook identifier for filtering on reload
|
|
* @param content Message content (string or TextContent/ImageContent array)
|
|
* @param display Whether to show in TUI (true = styled display, false = hidden)
|
|
* @param details Optional hook-specific metadata (not sent to LLM)
|
|
* @returns Entry id
|
|
*/
|
|
appendCustomMessageEntry<T = unknown>(
|
|
customType: string,
|
|
content: string | (TextContent | ImageContent)[],
|
|
display: boolean,
|
|
details?: T,
|
|
): string {
|
|
const entry: CustomMessageEntry<T> = {
|
|
type: "custom_message",
|
|
customType,
|
|
content,
|
|
display,
|
|
details,
|
|
id: generateId(this.byId),
|
|
parentId: this.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
this._appendEntry(entry);
|
|
return entry.id;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Tree Traversal
|
|
// =========================================================================
|
|
|
|
getLeafId(): string | null {
|
|
return this.leafId;
|
|
}
|
|
|
|
getLeafEntry(): SessionEntry | undefined {
|
|
return this.leafId ? this.byId.get(this.leafId) : undefined;
|
|
}
|
|
|
|
getEntry(id: string): SessionEntry | undefined {
|
|
return this.byId.get(id);
|
|
}
|
|
|
|
/**
|
|
* Get all direct children of an entry.
|
|
*/
|
|
getChildren(parentId: string): SessionEntry[] {
|
|
const children: SessionEntry[] = [];
|
|
for (const entry of this.byId.values()) {
|
|
if (entry.parentId === parentId) {
|
|
children.push(entry);
|
|
}
|
|
}
|
|
return children;
|
|
}
|
|
|
|
/**
|
|
* Get the label for an entry, if any.
|
|
*/
|
|
getLabel(id: string): string | undefined {
|
|
return this.labelsById.get(id);
|
|
}
|
|
|
|
/**
|
|
* Set or clear a label on an entry.
|
|
* Labels are user-defined markers for bookmarking/navigation.
|
|
* Pass undefined or empty string to clear the label.
|
|
*/
|
|
appendLabelChange(targetId: string, label: string | undefined): string {
|
|
if (!this.byId.has(targetId)) {
|
|
throw new Error(`Entry ${targetId} not found`);
|
|
}
|
|
const entry: LabelEntry = {
|
|
type: "label",
|
|
id: generateId(this.byId),
|
|
parentId: this.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
targetId,
|
|
label,
|
|
};
|
|
this._appendEntry(entry);
|
|
if (label) {
|
|
this.labelsById.set(targetId, label);
|
|
} else {
|
|
this.labelsById.delete(targetId);
|
|
}
|
|
return entry.id;
|
|
}
|
|
|
|
/**
|
|
* Walk from entry to root, returning all entries in path order.
|
|
* Includes all entry types (messages, compaction, model changes, etc.).
|
|
* Use buildSessionContext() to get the resolved messages for the LLM.
|
|
*/
|
|
getBranch(fromId?: string): SessionEntry[] {
|
|
const path: SessionEntry[] = [];
|
|
const startId = fromId ?? this.leafId;
|
|
let current = startId ? this.byId.get(startId) : undefined;
|
|
while (current) {
|
|
path.unshift(current);
|
|
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Build the session context (what gets sent to the LLM).
|
|
* Uses tree traversal from current leaf.
|
|
*/
|
|
buildSessionContext(): SessionContext {
|
|
return buildSessionContext(this.getEntries(), this.leafId, this.byId);
|
|
}
|
|
|
|
/**
|
|
* Get session header.
|
|
*/
|
|
getHeader(): SessionHeader | null {
|
|
const h = this.fileEntries.find((e) => e.type === "session");
|
|
return h ? (h as SessionHeader) : null;
|
|
}
|
|
|
|
/**
|
|
* Get all session entries (excludes header). Returns a shallow copy.
|
|
* The session is append-only: use appendXXX() to add entries, branch() to
|
|
* change the leaf pointer. Entries cannot be modified or deleted.
|
|
*/
|
|
getEntries(): SessionEntry[] {
|
|
return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session");
|
|
}
|
|
|
|
/**
|
|
* Get the session as a tree structure. Returns a shallow defensive copy of all entries.
|
|
* A well-formed session has exactly one root (first entry with parentId === null).
|
|
* Orphaned entries (broken parent chain) are also returned as roots.
|
|
*/
|
|
getTree(): SessionTreeNode[] {
|
|
const entries = this.getEntries();
|
|
const nodeMap = new Map<string, SessionTreeNode>();
|
|
const roots: SessionTreeNode[] = [];
|
|
|
|
// Create nodes with resolved labels
|
|
for (const entry of entries) {
|
|
const label = this.labelsById.get(entry.id);
|
|
nodeMap.set(entry.id, { entry, children: [], label });
|
|
}
|
|
|
|
// Build tree
|
|
for (const entry of entries) {
|
|
const node = nodeMap.get(entry.id)!;
|
|
if (entry.parentId === null || entry.parentId === entry.id) {
|
|
roots.push(node);
|
|
} else {
|
|
const parent = nodeMap.get(entry.parentId);
|
|
if (parent) {
|
|
parent.children.push(node);
|
|
} else {
|
|
// Orphan - treat as root
|
|
roots.push(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort children by timestamp (oldest first, newest at bottom)
|
|
// Use iterative approach to avoid stack overflow on deep trees
|
|
const stack: SessionTreeNode[] = [...roots];
|
|
while (stack.length > 0) {
|
|
const node = stack.pop()!;
|
|
node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
|
|
stack.push(...node.children);
|
|
}
|
|
|
|
return roots;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Branching
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Start a new branch from an earlier entry.
|
|
* Moves the leaf pointer to the specified entry. The next appendXXX() call
|
|
* will create a child of that entry, forming a new branch. Existing entries
|
|
* are not modified or deleted.
|
|
*/
|
|
branch(branchFromId: string): void {
|
|
if (!this.byId.has(branchFromId)) {
|
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
}
|
|
this.leafId = branchFromId;
|
|
}
|
|
|
|
/**
|
|
* Reset the leaf pointer to null (before any entries).
|
|
* The next appendXXX() call will create a new root entry (parentId = null).
|
|
* Use this when navigating to re-edit the first user message.
|
|
*/
|
|
resetLeaf(): void {
|
|
this.leafId = null;
|
|
}
|
|
|
|
/**
|
|
* Start a new branch with a summary of the abandoned path.
|
|
* Same as branch(), but also appends a branch_summary entry that captures
|
|
* context from the abandoned conversation path.
|
|
*/
|
|
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string {
|
|
if (branchFromId !== null && !this.byId.has(branchFromId)) {
|
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
}
|
|
this.leafId = branchFromId;
|
|
const entry: BranchSummaryEntry = {
|
|
type: "branch_summary",
|
|
id: generateId(this.byId),
|
|
parentId: branchFromId,
|
|
timestamp: new Date().toISOString(),
|
|
fromId: branchFromId ?? "root",
|
|
summary,
|
|
details,
|
|
fromHook,
|
|
};
|
|
this._appendEntry(entry);
|
|
return entry.id;
|
|
}
|
|
|
|
/**
|
|
* Create a new session file containing only the path from root to the specified leaf.
|
|
* Useful for extracting a single conversation path from a branched session.
|
|
* Returns the new session file path, or undefined if not persisting.
|
|
*/
|
|
createBranchedSession(leafId: string): string | undefined {
|
|
const path = this.getBranch(leafId);
|
|
if (path.length === 0) {
|
|
throw new Error(`Entry ${leafId} not found`);
|
|
}
|
|
|
|
// Filter out LabelEntry from path - we'll recreate them from the resolved map
|
|
const pathWithoutLabels = path.filter((e) => e.type !== "label");
|
|
|
|
const newSessionId = randomUUID();
|
|
const timestamp = new Date().toISOString();
|
|
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
|
|
|
|
const header: SessionHeader = {
|
|
type: "session",
|
|
version: CURRENT_SESSION_VERSION,
|
|
id: newSessionId,
|
|
timestamp,
|
|
cwd: this.cwd,
|
|
branchedFrom: this.persist ? this.sessionFile : undefined,
|
|
};
|
|
|
|
// Collect labels for entries in the path
|
|
const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));
|
|
const labelsToWrite: Array<{ targetId: string; label: string }> = [];
|
|
for (const [targetId, label] of this.labelsById) {
|
|
if (pathEntryIds.has(targetId)) {
|
|
labelsToWrite.push({ targetId, label });
|
|
}
|
|
}
|
|
|
|
if (this.persist) {
|
|
appendFileSync(newSessionFile, `${JSON.stringify(header)}\n`);
|
|
for (const entry of pathWithoutLabels) {
|
|
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
|
}
|
|
// Write fresh label entries at the end
|
|
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
let parentId = lastEntryId;
|
|
const labelEntries: LabelEntry[] = [];
|
|
for (const { targetId, label } of labelsToWrite) {
|
|
const labelEntry: LabelEntry = {
|
|
type: "label",
|
|
id: generateId(new Set(pathEntryIds)),
|
|
parentId,
|
|
timestamp: new Date().toISOString(),
|
|
targetId,
|
|
label,
|
|
};
|
|
appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`);
|
|
pathEntryIds.add(labelEntry.id);
|
|
labelEntries.push(labelEntry);
|
|
parentId = labelEntry.id;
|
|
}
|
|
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
this.sessionId = newSessionId;
|
|
this._buildIndex();
|
|
return newSessionFile;
|
|
}
|
|
|
|
// In-memory mode: replace current session with the path + labels
|
|
const labelEntries: LabelEntry[] = [];
|
|
let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
for (const { targetId, label } of labelsToWrite) {
|
|
const labelEntry: LabelEntry = {
|
|
type: "label",
|
|
id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),
|
|
parentId,
|
|
timestamp: new Date().toISOString(),
|
|
targetId,
|
|
label,
|
|
};
|
|
labelEntries.push(labelEntry);
|
|
parentId = labelEntry.id;
|
|
}
|
|
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
this.sessionId = newSessionId;
|
|
this._buildIndex();
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Create a new session.
|
|
* @param cwd Working directory (stored in session header)
|
|
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
|
|
*/
|
|
static create(cwd: string, sessionDir?: string): SessionManager {
|
|
const dir = sessionDir ?? getDefaultSessionDir(cwd);
|
|
return new SessionManager(cwd, dir, undefined, true);
|
|
}
|
|
|
|
/**
|
|
* Open a specific session file.
|
|
* @param path Path to session file
|
|
* @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
|
|
*/
|
|
static open(path: string, sessionDir?: string): SessionManager {
|
|
// Extract cwd from session header if possible, otherwise use process.cwd()
|
|
const entries = loadEntriesFromFile(path);
|
|
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
|
|
const cwd = header?.cwd ?? process.cwd();
|
|
// If no sessionDir provided, derive from file's parent directory
|
|
const dir = sessionDir ?? resolve(path, "..");
|
|
return new SessionManager(cwd, dir, path, true);
|
|
}
|
|
|
|
/**
|
|
* Continue the most recent session, or create new if none.
|
|
* @param cwd Working directory
|
|
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
|
|
*/
|
|
static continueRecent(cwd: string, sessionDir?: string): SessionManager {
|
|
const dir = sessionDir ?? getDefaultSessionDir(cwd);
|
|
const mostRecent = findMostRecentSession(dir);
|
|
if (mostRecent) {
|
|
return new SessionManager(cwd, dir, mostRecent, true);
|
|
}
|
|
return new SessionManager(cwd, dir, undefined, true);
|
|
}
|
|
|
|
/** Create an in-memory session (no file persistence) */
|
|
static inMemory(cwd: string = process.cwd()): SessionManager {
|
|
return new SessionManager(cwd, "", undefined, false);
|
|
}
|
|
|
|
/**
|
|
* List all sessions.
|
|
* @param cwd Working directory (used to compute default session directory)
|
|
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
|
|
*/
|
|
static list(cwd: string, sessionDir?: string): SessionInfo[] {
|
|
const dir = sessionDir ?? getDefaultSessionDir(cwd);
|
|
const sessions: SessionInfo[] = [];
|
|
|
|
try {
|
|
const files = readdirSync(dir)
|
|
.filter((f) => f.endsWith(".jsonl"))
|
|
.map((f) => join(dir, f));
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const content = readFileSync(file, "utf8");
|
|
const lines = content.trim().split("\n");
|
|
if (lines.length === 0) continue;
|
|
|
|
// Check first line for valid session header
|
|
let header: { type: string; id: string; timestamp: string } | null = null;
|
|
try {
|
|
const first = JSON.parse(lines[0]);
|
|
if (first.type === "session" && first.id) {
|
|
header = first;
|
|
}
|
|
} catch {
|
|
// Not valid JSON
|
|
}
|
|
if (!header) continue;
|
|
|
|
const stats = statSync(file);
|
|
let messageCount = 0;
|
|
let firstMessage = "";
|
|
const allMessages: string[] = [];
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
try {
|
|
const entry = JSON.parse(lines[i]);
|
|
|
|
if (entry.type === "message") {
|
|
messageCount++;
|
|
|
|
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
|
const textContent = entry.message.content
|
|
.filter((c: any) => c.type === "text")
|
|
.map((c: any) => c.text)
|
|
.join(" ");
|
|
|
|
if (textContent) {
|
|
allMessages.push(textContent);
|
|
|
|
if (!firstMessage && entry.message.role === "user") {
|
|
firstMessage = textContent;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Skip malformed lines
|
|
}
|
|
}
|
|
|
|
sessions.push({
|
|
path: file,
|
|
id: header.id,
|
|
created: new Date(header.timestamp),
|
|
modified: stats.mtime,
|
|
messageCount,
|
|
firstMessage: firstMessage || "(no messages)",
|
|
allMessagesText: allMessages.join(" "),
|
|
});
|
|
} catch {
|
|
// Skip files that can't be read
|
|
}
|
|
}
|
|
|
|
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
} catch {
|
|
// Return empty list on error
|
|
}
|
|
|
|
return sessions;
|
|
}
|
|
}
|