1
import type { AgentMessage } from "@earendil-works/pi-agent-core";2
import {3
type AssistantMessage,4
getCurrentSystemMessage,5
type ImageContent,6
type Message,7
type SystemMessage,8
type TextContent,9
type ToolResultMessage,10
type Usage,11
type UserMessage,12
uuidv7,13
} from "@earendil-works/pi-ai";14
import { randomUUID } from "crypto";15
import {16
appendFileSync,17
closeSync,18
createReadStream,19
existsSync,20
mkdirSync,21
openSync,22
readdirSync,23
readSync,24
type Stats,25
statSync,26
writeFileSync,27
} from "fs";28
import { readdir, stat } from "fs/promises";29
import { basename, join, resolve } from "path";30
import { createInterface } from "readline";31
import { StringDecoder } from "string_decoder";32
import { APP_NAME, getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";33
import { normalizePath, resolvePath } from "../utils/paths.ts";34
import {35
type BashExecutionMessage,36
type CustomMessage,37
createBranchSummaryMessage,38
createCompactionSummaryMessage,39
createCustomMessage,40
} from "./messages.ts";41
export const CURRENT_SESSION_VERSION = 3;43
export interface SessionHeader {44
type: "session";45
version?: number; // v1 sessions don't have this46
id: string;47
timestamp: string;48
cwd: string;49
parentSession?: string;50
}52
export interface NewSessionOptions {53
id?: string;54
parentSession?: string;55
}57
export interface SessionEntryBase {58
type: string;59
id: string;60
parentId: string | null;61
timestamp: string;62
}64
export interface SessionMessageEntry extends SessionEntryBase {65
type: "message";66
message: AgentMessage;67
}69
export interface ThinkingLevelChangeEntry extends SessionEntryBase {70
type: "thinking_level_change";71
thinkingLevel: string;72
}74
export interface ModelChangeEntry extends SessionEntryBase {75
type: "model_change";76
provider: string;77
modelId: string;78
}80
export interface UsageEntry extends SessionEntryBase {81
type: "usage";82
/** Arbitrary usage category, such as "cache_warm". */83
kind: string;84
provider: string;85
model: string;86
usage: Usage;87
/** Optional human-readable qualifier for usage notices. */88
note?: string;89
}91
export interface CompactionEntry<T = unknown> extends SessionEntryBase {92
type: "compaction";93
summary: string;94
firstKeptEntryId: string;95
tokensBefore: number;96
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */97
details?: T;98
/** Usage from the LLM call(s) that generated this summary, if available */99
usage?: Usage;100
/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */101
fromHook?: boolean;102
/** Complete prompt and tool state at this compaction boundary. */103
systemMessage?: SystemMessage;104
}106
export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {107
type: "branch_summary";108
fromId: string;109
summary: string;110
/** Extension-specific data (not sent to LLM) */111
details?: T;112
/** Usage from the LLM call that generated this summary, if available */113
usage?: Usage;114
/** True if generated by an extension, false if pi-generated */115
fromHook?: boolean;116
}118
/**119
* Custom entry for extensions to store extension-specific data in the session.120
* Use customType to identify your extension's entries.121
*122
* Purpose: Persist extension state across session reloads. On reload, extensions can123
* scan entries for their customType and reconstruct internal state.124
*125
* Does NOT participate in LLM context (ignored by buildSessionContext).126
* For injecting content into context, see CustomMessageEntry.127
*/128
export interface CustomEntry<T = unknown> extends SessionEntryBase {129
type: "custom";130
customType: string;131
data?: T;132
}134
/** Label entry for user-defined bookmarks/markers on entries. */135
export interface LabelEntry extends SessionEntryBase {136
type: "label";137
targetId: string;138
label: string | undefined;139
}141
/** Session metadata entry (e.g., user-defined display name). */142
export interface SessionInfoEntry extends SessionEntryBase {143
type: "session_info";144
name?: string;145
}147
/**148
* Custom message entry for extensions to inject messages into LLM context.149
* Use customType to identify your extension's entries.150
*151
* Unlike CustomEntry, this DOES participate in LLM context.152
* The content is converted to a user message in buildSessionContext().153
* Use details for extension-specific metadata (not sent to LLM).154
*155
* display controls TUI rendering:156
* - false: hidden entirely157
* - true: rendered with distinct styling (different from user messages)158
*/159
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {160
type: "custom_message";161
customType: string;162
content: string | (TextContent | ImageContent)[];163
details?: T;164
display: boolean;165
}167
/** Content that an append-only context edit may replace without changing message metadata. */168
export type ContextEditableContent =169
| UserMessage["content"]170
| AssistantMessage["content"]171
| ToolResultMessage["content"]172
| CustomMessage["content"];174
/** Append-only change to one earlier entry's contribution to model context. */175
export interface ContextEditEntry extends SessionEntryBase {176
type: "context_edit";177
targetId: string;178
/** Null omits the target from model context. A value replaces only its content. */179
replacement: { content: ContextEditableContent } | null;180
}182
/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */183
export type SessionEntry =184
| SessionMessageEntry185
| ThinkingLevelChangeEntry186
| ModelChangeEntry187
| UsageEntry188
| CompactionEntry189
| BranchSummaryEntry190
| CustomEntry191
| CustomMessageEntry192
| ContextEditEntry193
| LabelEntry194
| SessionInfoEntry;196
/** Raw file entry (includes header) */197
export type FileEntry = SessionHeader | SessionEntry;199
/** Tree node for getTree() - defensive copy of session structure */200
export interface SessionTreeNode {201
entry: SessionEntry;202
children: SessionTreeNode[];203
/** Resolved label for this entry, if any */204
label?: string;205
/** Timestamp of the latest label change for this entry, if any */206
labelTimestamp?: string;207
}209
export interface ProjectedSessionEntry {210
/** Raw append-only entry that owns this projected contribution. */211
sourceEntry: SessionEntry;212
/** Model-visible messages after context edits. Empty for state-only entries and omissions. */213
messages: AgentMessage[];214
}216
export interface SessionProjection {217
entries: ProjectedSessionEntry[];218
messages: AgentMessage[];219
thinkingLevel: string;220
model: { provider: string; modelId: string } | null;221
}223
export interface SessionContext {224
messages: AgentMessage[];225
thinkingLevel: string;226
model: { provider: string; modelId: string } | null;227
}229
export interface SessionInfo {230
path: string;231
id: string;232
/** Working directory where the session was started. Empty string for old sessions. */233
cwd: string;234
/** User-defined display name from session_info entries. */235
name?: string;236
/** Path to the parent session (if this session was forked). */237
parentSessionPath?: string;238
created: Date;239
modified: Date;240
messageCount: number;241
firstMessage: string;242
allMessagesText: string;243
}245
export type ReadonlySessionManager = Pick<246
SessionManager,247
| "getCwd"248
| "getSessionDir"249
| "getSessionId"250
| "getSessionFile"251
| "getLeafId"252
| "getLeafEntry"253
| "getEntry"254
| "getLabel"255
| "getBranch"256
| "buildContextEntries"257
| "buildSessionProjection"258
| "getHeader"259
| "getEntries"260
| "getTree"261
| "getSessionName"262
>;264
function createSessionId(): string {265
return uuidv7();266
}268
export function assertValidSessionId(id: string): void {269
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {270
throw new Error(271
"Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character",272
);273
}274
}276
/** Generate a unique short ID (8 hex chars, collision-checked) */277
function generateId(byId: { has(id: string): boolean }): string {278
for (let i = 0; i < 100; i++) {279
const id = randomUUID().slice(0, 8);280
if (!byId.has(id)) return id;281
}282
// Fallback to full UUID if somehow we have collisions283
return randomUUID();284
}286
/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */287
function migrateV1ToV2(entries: FileEntry[]): void {288
const ids = new Set<string>();289
let prevId: string | null = null;291
for (const entry of entries) {292
if (entry.type === "session") {293
entry.version = 2;294
continue;295
}297
entry.id = generateId(ids);298
entry.parentId = prevId;299
prevId = entry.id;301
// Convert firstKeptEntryIndex to firstKeptEntryId for compaction302
if (entry.type === "compaction") {303
const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };304
if (typeof comp.firstKeptEntryIndex === "number") {305
const targetEntry = entries[comp.firstKeptEntryIndex];306
if (targetEntry && targetEntry.type !== "session") {307
comp.firstKeptEntryId = targetEntry.id;308
}309
delete comp.firstKeptEntryIndex;310
}311
}312
}313
}315
/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */316
function migrateV2ToV3(entries: FileEntry[]): void {317
for (const entry of entries) {318
if (entry.type === "session") {319
entry.version = 3;320
continue;321
}323
// Update message entries with hookMessage role324
if (entry.type === "message") {325
const msgEntry = entry as SessionMessageEntry;326
if (msgEntry.message && (msgEntry.message as { role: string }).role === "hookMessage") {327
(msgEntry.message as { role: string }).role = "custom";328
}329
}330
}331
}333
/**334
* Run all necessary migrations to bring entries to current version.335
* Mutates entries in place. Returns true if any migration was applied.336
*/337
function migrateToCurrentVersion(entries: FileEntry[]): boolean {338
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;339
const version = header?.version ?? 1;341
if (version >= CURRENT_SESSION_VERSION) return false;343
if (version < 2) migrateV1ToV2(entries);344
if (version < 3) migrateV2ToV3(entries);346
return true;347
}349
/** Exported for testing */350
export function migrateSessionEntries(entries: FileEntry[]): void {351
migrateToCurrentVersion(entries);352
}354
/** Exported for compaction.test.ts */355
export function parseSessionEntries(content: string): FileEntry[] {356
const entries: FileEntry[] = [];357
const lines = content.trim().split("\n");359
for (const line of lines) {360
if (!line.trim()) continue;361
try {362
const entry = JSON.parse(line) as FileEntry;363
entries.push(entry);364
} catch {365
// Skip malformed lines366
}367
}369
return entries;370
}372
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {373
for (let i = entries.length - 1; i >= 0; i--) {374
if (entries[i].type === "compaction") {375
return entries[i] as CompactionEntry;376
}377
}378
return null;379
}381
function buildEntryIndex(entries: SessionEntry[], byId?: Map<string, SessionEntry>): Map<string, SessionEntry> {382
if (byId) return byId;383
const index = new Map<string, SessionEntry>();384
for (const entry of entries) {385
index.set(entry.id, entry);386
}387
return index;388
}390
function buildSessionPath(391
entries: SessionEntry[],392
leafId?: string | null,393
byId?: Map<string, SessionEntry>,394
): SessionEntry[] {395
const index = buildEntryIndex(entries, byId);396
let leaf: SessionEntry | undefined;397
if (leafId === null) {398
return [];399
}400
if (leafId) {401
leaf = index.get(leafId);402
}403
leaf ??= entries[entries.length - 1];404
if (!leaf) {405
return [];406
}408
const path: SessionEntry[] = [];409
let current: SessionEntry | undefined = leaf;410
while (current) {411
path.push(current);412
current = current.parentId ? index.get(current.parentId) : undefined;413
}414
path.reverse();415
return path;416
}418
function getSessionContextSettings(path: SessionEntry[]): Pick<SessionContext, "thinkingLevel" | "model"> {419
let thinkingLevel = "off";420
let model: { provider: string; modelId: string } | null = null;422
for (const entry of path) {423
if (entry.type === "thinking_level_change") {424
thinkingLevel = entry.thinkingLevel;425
} else if (entry.type === "model_change") {426
model = { provider: entry.provider, modelId: entry.modelId };427
} else if (entry.type === "message" && entry.message.role === "assistant") {428
model = { provider: entry.message.provider, modelId: entry.message.model };429
}430
}432
return { thinkingLevel, model };433
}435
/**436
* Project one selected session entry into LLM/runtime messages.437
* Plain custom entries are display/state entries and do not participate in context.438
*/439
export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] {440
if (entry.type === "message") {441
const message = entry.message;442
// Session files are parsed without validation; old versions, forks, or443
// hand-edited files can contain messages with null/missing content.444
if (message.role === "system" && message.content == null) return [{ ...message, content: "" }];445
if (446
(message.role === "user" || message.role === "assistant" || message.role === "toolResult") &&447
message.content == null448
) {449
return [{ ...message, content: [] }];450
}451
return [message];452
}453
if (entry.type === "custom_message") {454
return [455
createCustomMessage(entry.customType, entry.content ?? [], entry.display, entry.details, entry.timestamp),456
];457
}458
if (entry.type === "branch_summary" && entry.summary) {459
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];460
}461
if (entry.type === "compaction") {462
const summary = createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);463
return entry.systemMessage ? [entry.systemMessage, summary] : [summary];464
}465
return [];466
}468
/**469
* Build the active, compaction-aware session entry list.470
*471
* This follows the current leaf path. If the path contains compaction entries,472
* the latest compaction is represented by the compaction entry itself, followed473
* by the kept entries starting at firstKeptEntryId and all entries after the474
* compaction entry. Older summarized entries are omitted.475
*/476
export function buildContextEntries(477
entries: SessionEntry[],478
leafId?: string | null,479
byId?: Map<string, SessionEntry>,480
): SessionEntry[] {481
const path = buildSessionPath(entries, leafId, byId);482
let compaction: CompactionEntry | null = null;484
for (const entry of path) {485
if (entry.type === "compaction") {486
compaction = entry;487
}488
}490
if (!compaction) {491
return path;492
}494
const compactionIdx = path.findIndex((entry) => entry.id === compaction.id);495
if (compactionIdx < 0) {496
return path;497
}499
const contextEntries: SessionEntry[] = [compaction];500
let foundFirstKept = false;501
for (let i = 0; i < compactionIdx; i++) {502
const entry = path[i];503
if (entry.id === compaction.firstKeptEntryId) {504
foundFirstKept = true;505
}506
if (foundFirstKept && !(entry.type === "message" && entry.message.role === "system")) {507
contextEntries.push(entry);508
}509
}510
contextEntries.push(...path.slice(compactionIdx + 1));511
return contextEntries;512
}514
/**515
* Build the session context from entries using tree traversal.516
* If leafId is provided, walks from that entry to root.517
* Handles compaction and branch summaries along the path.518
*/519
function projectContextEntry(entry: SessionEntry, edit: ContextEditEntry | undefined): AgentMessage[] {520
const messages = sessionEntryToContextMessages(entry);521
if (!edit) return messages;522
const replacement = edit.replacement;523
if (replacement === null) return [];525
return messages.map((message) => {526
if (527
message.role !== "user" &&528
message.role !== "assistant" &&529
message.role !== "toolResult" &&530
message.role !== "custom"531
) {532
return message;533
}534
const content =535
(message.role === "assistant" || message.role === "toolResult") && typeof replacement.content === "string"536
? [{ type: "text" as const, text: replacement.content }]537
: replacement.content;538
return { ...message, content } as AgentMessage;539
});540
}542
/** Build provenance-preserving, compaction-aware model context. */543
export function buildSessionProjection(544
entries: SessionEntry[],545
leafId?: string | null,546
byId?: Map<string, SessionEntry>,547
): SessionProjection {548
const path = buildSessionPath(entries, leafId, byId);549
const { thinkingLevel, model } = getSessionContextSettings(path);550
const contextEntries = buildContextEntries(entries, leafId, byId);551
const edits = new Map<string, ContextEditEntry>();552
for (const entry of contextEntries) {553
if (entry.type === "context_edit") edits.set(entry.targetId, entry);554
}555
const projectedEntries = contextEntries.map(556
(sourceEntry, index): ProjectedSessionEntry => ({557
sourceEntry,558
// buildContextEntries() may retain an older compaction entry because its559
// raw ID lies inside the newest retained range. Only the newest compaction560
// at index zero contributes a checkpoint and summary.561
messages:562
sourceEntry.type === "compaction" && index > 0563
? []564
: projectContextEntry(sourceEntry, edits.get(sourceEntry.id)),565
}),566
);567
return {568
entries: projectedEntries,569
messages: projectedEntries.flatMap((entry) => entry.messages),570
thinkingLevel,571
model,572
};573
}575
/** Build the finalized model context from the canonical session projection. */576
export function buildSessionContext(577
entries: SessionEntry[],578
leafId?: string | null,579
byId?: Map<string, SessionEntry>,580
): SessionContext {581
const { messages, thinkingLevel, model } = buildSessionProjection(entries, leafId, byId);582
return { messages, thinkingLevel, model };583
}585
/**586
* Compute the default session directory for a cwd.587
* Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.588
*/589
function getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): string {590
const resolvedCwd = resolvePath(cwd);591
const resolvedAgentDir = resolvePath(agentDir);592
const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;593
return join(resolvedAgentDir, "sessions", safePath);594
}596
export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {597
const sessionDir = getDefaultSessionDirPath(cwd, agentDir);598
if (!existsSync(sessionDir)) {599
mkdirSync(sessionDir, { recursive: true });600
}601
return sessionDir;602
}604
const SESSION_READ_BUFFER_SIZE = 1024 * 1024;605
const SESSION_HEADER_READ_BUFFER_SIZE = 4096;606
/** Bound synchronous header discovery while allowing large cwd and custom metadata fields. */607
const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;609
class SessionHeaderScanLimitError extends Error {610
constructor(filePath: string) {611
super(`Session header exceeds ${MAX_SESSION_HEADER_SCAN_BYTES}-byte scan limit: ${filePath}`);612
this.name = "SessionHeaderScanLimitError";613
}614
}616
function parseSessionEntryLine(line: string): FileEntry | null {617
if (!line.trim()) return null;618
try {619
return JSON.parse(line) as FileEntry;620
} catch {621
// Skip malformed lines622
return null;623
}624
}626
/** Exported for testing */627
export function loadEntriesFromFile(filePath: string): FileEntry[] {628
const resolvedFilePath = normalizePath(filePath);629
if (!existsSync(resolvedFilePath)) return [];631
const entries: FileEntry[] = [];632
let pending = "";633
const fd = openSync(resolvedFilePath, "r");634
try {635
const decoder = new StringDecoder("utf8");636
const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);638
while (true) {639
const bytesRead = readSync(fd, buffer, 0, buffer.length, null);640
if (bytesRead === 0) break;642
pending += decoder.write(buffer.subarray(0, bytesRead));643
let lineStart = 0;644
let newlineIndex = pending.indexOf("\n", lineStart);645
while (newlineIndex !== -1) {646
const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));647
if (entry) entries.push(entry);648
lineStart = newlineIndex + 1;649
newlineIndex = pending.indexOf("\n", lineStart);650
}651
pending = pending.slice(lineStart);652
}654
pending += decoder.end();655
const finalEntry = parseSessionEntryLine(pending);656
if (finalEntry) entries.push(finalEntry);657
} finally {658
closeSync(fd);659
}661
// Validate session header before repairing the file.662
if (entries.length === 0) return entries;663
const header = entries[0];664
if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") {665
return [];666
}668
if (pending) appendFileSync(resolvedFilePath, "\n");669
return entries;670
}672
/**673
* Inspect a physical line while searching for the first parsed session entry.674
* Blank and malformed lines are skipped to match loadEntriesFromFile().675
* Returns undefined to keep scanning, null for a parsed non-header entry, or the header.676
*/677
function parseSessionHeaderCandidate(line: string): SessionHeader | null | undefined {678
if (!line.trim()) return undefined;679
const entry = parseSessionEntryLine(line);680
if (!entry) return undefined;681
if (entry.type !== "session" || typeof (entry as { id?: unknown }).id !== "string") return null;682
return entry;683
}685
function readSessionHeader(filePath: string): SessionHeader | null {686
const fd = openSync(filePath, "r");687
try {688
const decoder = new StringDecoder("utf8");689
const buffer = Buffer.allocUnsafe(SESSION_HEADER_READ_BUFFER_SIZE);690
const lineChunks: string[] = [];691
let scannedBytes = 0;693
while (scannedBytes < MAX_SESSION_HEADER_SCAN_BYTES) {694
const readLength = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scannedBytes);695
const bytesRead = readSync(fd, buffer, 0, readLength, null);696
if (bytesRead === 0) {697
lineChunks.push(decoder.end());698
return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;699
}700
scannedBytes += bytesRead;702
const chunk = decoder.write(buffer.subarray(0, bytesRead));703
let lineStart = 0;704
let newlineIndex = chunk.indexOf("\n", lineStart);705
while (newlineIndex !== -1) {706
lineChunks.push(chunk.slice(lineStart, newlineIndex));707
const header = parseSessionHeaderCandidate(lineChunks.join(""));708
if (header !== undefined) return header;709
lineChunks.length = 0;710
lineStart = newlineIndex + 1;711
newlineIndex = chunk.indexOf("\n", lineStart);712
}713
lineChunks.push(chunk.slice(lineStart));714
}716
// Probe for EOF so a final header without a newline is allowed when it ends717
// exactly at the scan limit. Any additional byte exceeds the bounded scan.718
const probe = Buffer.allocUnsafe(1);719
if (readSync(fd, probe, 0, probe.length, null) === 0) {720
lineChunks.push(decoder.end());721
return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;722
}723
throw new SessionHeaderScanLimitError(filePath);724
} finally {725
closeSync(fd);726
}727
}729
function readSessionHeaderForDiscovery(filePath: string): SessionHeader | null {730
try {731
return readSessionHeader(filePath);732
} catch {733
// Discovery is best-effort: unreadable or oversized files are not sessions,734
// and one corrupt file must not prevent other sessions from being found.735
return null;736
}737
}739
function getSessionHeaderCwd(header: SessionHeader): string | undefined {740
const cwd = (header as { cwd?: unknown }).cwd;741
return typeof cwd === "string" ? cwd : undefined;742
}744
function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean {745
return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd;746
}748
/** Exported for testing */749
export function findMostRecentSession(sessionDir: string, cwd?: string): string | null {750
const resolvedSessionDir = normalizePath(sessionDir);751
const resolvedCwd = cwd ? resolvePath(cwd) : undefined;752
try {753
const files = readdirSync(resolvedSessionDir)754
.filter((file) => file.endsWith(".jsonl"))755
.map((file) => join(resolvedSessionDir, file))756
.map((path) => ({ path, mtime: statSync(path).mtimeMs }))757
.sort((a, b) => b.mtime - a.mtime);759
for (const { path } of files) {760
const header = readSessionHeaderForDiscovery(path);761
if (header && (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(header), resolvedCwd))) return path;762
}763
return null;764
} catch {765
// Directory access and stat races make recent-session discovery unavailable.766
return null;767
}768
}770
function isMessageWithContent(message: AgentMessage): message is Message {771
return typeof (message as Message).role === "string" && "content" in message;772
}774
function extractTextContent(message: Message): string {775
const content = message.content;776
if (typeof content === "string") {777
return content;778
}779
return content780
.filter((block): block is TextContent => block.type === "text")781
.map((block) => block.text)782
.join(" ");783
}785
function getMessageActivityTime(entry: SessionMessageEntry): number | undefined {786
const message = entry.message;787
if (!isMessageWithContent(message)) return undefined;788
if (message.role !== "user" && message.role !== "assistant") return undefined;790
const msgTimestamp = (message as { timestamp?: number }).timestamp;791
if (typeof msgTimestamp === "number") {792
return msgTimestamp;793
}795
const t = new Date(entry.timestamp).getTime();796
return Number.isNaN(t) ? undefined : t;797
}799
async function buildSessionInfo(800
filePath: string,801
signal?: AbortSignal,802
fileStats?: Stats,803
): Promise<SessionInfo | null> {804
try {805
const stats = fileStats ?? (await stat(filePath));806
let header: SessionHeader | null = null;807
let messageCount = 0;808
let firstMessage = "";809
const allMessages: string[] = [];810
let name: string | undefined;811
let lastActivityTime: number | undefined;813
const rl = createInterface({814
input: createReadStream(filePath, { encoding: "utf8", signal }),815
crlfDelay: Infinity,816
});818
for await (const line of rl) {819
const entry = parseSessionEntryLine(line);820
if (!entry) continue;822
if (!header) {823
if (entry.type !== "session") return null;824
header = entry;825
continue;826
}828
// Extract session name (use latest, including explicit clears)829
if (entry.type === "session_info") {830
name = entry.name?.trim() || undefined;831
}833
if (entry.type !== "message") continue;834
messageCount++;836
const activityTime = getMessageActivityTime(entry);837
if (typeof activityTime === "number") {838
lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);839
}841
const message = entry.message;842
if (!isMessageWithContent(message)) continue;843
if (message.role !== "user" && message.role !== "assistant") continue;845
const textContent = extractTextContent(message);846
if (!textContent) continue;848
allMessages.push(textContent);849
if (!firstMessage && message.role === "user") {850
firstMessage = textContent;851
}852
}854
if (!header) return null;856
const cwd = typeof header.cwd === "string" ? header.cwd : "";857
const parentSessionPath = header.parentSession;858
const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;859
const modified =860
typeof lastActivityTime === "number" && lastActivityTime > 0861
? new Date(lastActivityTime)862
: !Number.isNaN(headerTime)863
? new Date(headerTime)864
: stats.mtime;866
return {867
path: filePath,868
id: header.id,869
cwd,870
name,871
parentSessionPath,872
created: new Date(header.timestamp),873
modified,874
messageCount,875
firstMessage: firstMessage || "(no messages)",876
allMessagesText: allMessages.join(" "),877
};878
} catch {879
signal?.throwIfAborted();880
return null;881
}882
}884
export type SessionListProgress = (885
loaded: number,886
total: number,887
/** Sessions loaded so far, sorted by activity. Present on periodic updates. */888
partialSessions?: readonly SessionInfo[],889
) => void;891
const MAX_CONCURRENT_SESSION_INFO_LOADS = 10;892
const MAX_CONCURRENT_SESSION_DISCOVERY_LOADS = 64;893
const CURRENT_SESSION_LIST_PUBLISH_INTERVAL = 10;894
const ALL_SESSION_LIST_PUBLISH_INTERVAL = 100;896
interface SessionFileCandidate {897
path: string;898
stats?: Stats;899
}901
async function mapWithConcurrency<T, R>(902
items: T[],903
limit: number,904
map: (item: T, index: number) => Promise<R>,905
signal?: AbortSignal,906
): Promise<R[]> {907
const results = new Array<R>(items.length);908
let nextIndex = 0;909
const worker = async (): Promise<void> => {910
while (nextIndex < items.length) {911
signal?.throwIfAborted();912
const index = nextIndex++;913
results[index] = await map(items[index]!, index);914
}915
};916
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));917
return results;918
}920
function sortSessionInfos(sessions: SessionInfo[]): SessionInfo[] {921
return sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());922
}924
function buildSessionInfosWithConcurrency(925
files: SessionFileCandidate[],926
onLoaded: (info: SessionInfo | null, index: number) => void,927
signal?: AbortSignal,928
): Promise<(SessionInfo | null)[]> {929
return mapWithConcurrency(930
files,931
MAX_CONCURRENT_SESSION_INFO_LOADS,932
async (file, index) => {933
const info = await buildSessionInfo(file.path, signal, file.stats);934
onLoaded(info, index);935
return info;936
},937
signal,938
);939
}941
async function listSessionsFromDir(942
dir: string,943
onProgress?: SessionListProgress,944
signal?: AbortSignal,945
): Promise<SessionInfo[]> {946
signal?.throwIfAborted();947
if (!existsSync(dir)) return [];949
try {950
const dirEntries = await readdir(dir);951
const files = dirEntries952
.filter((file) => file.endsWith(".jsonl"))953
.sort((a, b) => b.localeCompare(a))954
.map((file) => ({ path: join(dir, file) }));955
const total = files.length;956
const partialSessions: SessionInfo[] = [];957
let loaded = 0;958
const results = await buildSessionInfosWithConcurrency(959
files,960
(info) => {961
loaded++;962
if (info) partialSessions.push(info);963
const publishPartial =964
loaded === 1 || loaded % CURRENT_SESSION_LIST_PUBLISH_INTERVAL === 0 || loaded === files.length;965
onProgress?.(loaded, total, publishPartial ? sortSessionInfos([...partialSessions]) : undefined);966
},967
signal,968
);969
return results.filter((info): info is SessionInfo => info !== null);970
} catch {971
signal?.throwIfAborted();972
return [];973
}974
}976
/**977
* Manages conversation sessions as append-only trees stored in JSONL files.978
*979
* Each session entry has an id and parentId forming a tree structure. The "leaf"980
* pointer tracks the current position. Appending creates a child of the current leaf.981
* Branching moves the leaf to an earlier entry, allowing new branches without982
* modifying history.983
*984
* Use buildSessionContext() to get the resolved message list for the LLM, which985
* handles compaction summaries and follows the path from root to current leaf.986
*/987
export class SessionManager {988
private sessionId: string = "";989
private sessionFile: string | undefined;990
private sessionDir: string;991
private cwd: string;992
private persist: boolean;993
private flushed: boolean = false;994
private fileEntries: FileEntry[] = [];995
private byId: Map<string, SessionEntry> = new Map();996
private labelsById: Map<string, string> = new Map();997
private labelTimestampsById: Map<string, string> = new Map();998
private leafId: string | null = null;1000
private constructor(1001
cwd: string,1002
sessionDir: string,1003
sessionFile: string | undefined,1004
persist: boolean,1005
newSessionOptions?: NewSessionOptions,1006
preloadedFileEntries?: FileEntry[],1007
) {1008
this.cwd = resolvePath(cwd);1009
this.sessionDir = normalizePath(sessionDir);1010
this.persist = persist;1011
if (persist && this.sessionDir && !existsSync(this.sessionDir)) {1012
mkdirSync(this.sessionDir, { recursive: true });1013
}1015
if (sessionFile) {1016
this._setSessionFile(sessionFile, preloadedFileEntries);1017
} else if (preloadedFileEntries?.length) {1018
this._loadEntries(preloadedFileEntries, newSessionOptions);1019
} else {1020
this.newSession(newSessionOptions);1021
}1022
}1024
/** Switch to a different session file (used for resume and branching) */1025
setSessionFile(sessionFile: string): void {1026
this._setSessionFile(sessionFile);1027
}1029
private _setSessionFile(sessionFile: string, preloadedFileEntries?: FileEntry[]): void {1030
this.sessionFile = resolvePath(sessionFile);1031
if (existsSync(this.sessionFile)) {1032
const entries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile);1034
// If file was empty, initialize it with a valid session header. If it was1035
// non-empty but did not parse as a pi session, fail without modifying it.1036
if (entries.length === 0) {1037
const explicitPath = this.sessionFile;1038
if (statSync(explicitPath).size > 0) {1039
throw new Error(`Session file is not a valid ${APP_NAME} session: ${explicitPath}`);1040
}1041
this.newSession();1042
this.sessionFile = explicitPath;1043
this._rewriteFile();1044
this.flushed = true;1045
return;1046
}1048
this._loadEntries(entries);1049
this.flushed = true;1050
} else {1051
const explicitPath = this.sessionFile;1052
this.newSession();1053
this.sessionFile = explicitPath; // preserve explicit path from --session flag1054
}1055
}1057
newSession(options?: NewSessionOptions): string | undefined {1058
if (options?.id !== undefined) {1059
assertValidSessionId(options.id);1060
}1061
this.sessionId = options?.id ?? createSessionId();1062
const timestamp = new Date().toISOString();1063
const header: SessionHeader = {1064
type: "session",1065
version: CURRENT_SESSION_VERSION,1066
id: this.sessionId,1067
timestamp,1068
cwd: this.cwd,1069
parentSession: options?.parentSession,1070
};1071
this.fileEntries = [header];1072
this.byId.clear();1073
this.labelsById.clear();1074
this.labelTimestampsById.clear();1075
this.leafId = null;1076
this.flushed = false;1078
if (this.persist) {1079
const fileTimestamp = timestamp.replace(/[:.]/g, "-");1080
this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);1081
}1082
return this.sessionFile;1083
}1085
private _loadEntries(entries: FileEntry[], options?: NewSessionOptions): void {1086
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;1088
if (header) {1089
this.fileEntries = entries;1090
this.sessionId = header.id;1092
if (migrateToCurrentVersion(this.fileEntries)) {1093
this._rewriteFile();1094
}1095
} else {1096
this.newSession(options);1097
this.fileEntries = this.fileEntries.concat(entries);1098
}1100
this._buildIndex();1101
}1103
private _buildIndex(): void {1104
this.byId.clear();1105
this.labelsById.clear();1106
this.labelTimestampsById.clear();1107
this.leafId = null;1108
for (const entry of this.fileEntries) {1109
if (entry.type === "session") continue;1110
this.byId.set(entry.id, entry);1111
this.leafId = entry.id;1112
if (entry.type === "label") {1113
if (entry.label) {1114
this.labelsById.set(entry.targetId, entry.label);1115
this.labelTimestampsById.set(entry.targetId, entry.timestamp);1116
} else {1117
this.labelsById.delete(entry.targetId);1118
this.labelTimestampsById.delete(entry.targetId);1119
}1120
}1121
}1122
}1124
private _rewriteFile(): void {1125
if (!this.persist || !this.sessionFile) return;1126
const fd = openSync(this.sessionFile, "w");1127
try {1128
for (const entry of this.fileEntries) {1129
writeFileSync(fd, `${JSON.stringify(entry)}\n`);1130
}1131
} finally {1132
closeSync(fd);1133
}1134
}1136
isPersisted(): boolean {1137
return this.persist;1138
}1140
getCwd(): string {1141
return this.cwd;1142
}1144
getSessionDir(): string {1145
return this.sessionDir;1146
}1148
usesDefaultSessionDir(): boolean {1149
return this.sessionDir === getDefaultSessionDirPath(this.cwd);1150
}1152
getSessionId(): string {1153
return this.sessionId;1154
}1156
getSessionFile(): string | undefined {1157
return this.sessionFile;1158
}1160
/**1161
* A new session file is created only once the session contains a user or assistant message.1162
* Setup entries alone (model, thinking level, system prompt) stay in memory so opening and1163
* closing pi without chatting leaves no file behind. Starting at the user message (not the1164
* first assistant reply) keeps the prompt on disk if the first turn never completes (#10000).1165
*/1166
private _hasConversation(): boolean {1167
return this.fileEntries.some(1168
(e) => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"),1169
);1170
}1172
_persist(entry: SessionEntry): void {1173
if (!this.persist || !this.sessionFile) return;1175
if (!this.flushed) {1176
if (!this._hasConversation()) return;1177
const fd = openSync(this.sessionFile, "wx");1178
try {1179
for (const e of this.fileEntries) {1180
writeFileSync(fd, `${JSON.stringify(e)}\n`);1181
}1182
} finally {1183
closeSync(fd);1184
}1185
this.flushed = true;1186
} else {1187
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);1188
}1189
}1191
private _appendEntry(entry: SessionEntry): void {1192
this.fileEntries.push(entry);1193
this.byId.set(entry.id, entry);1194
this.leafId = entry.id;1195
this._persist(entry);1196
}1198
/** Append a message as child of current leaf, then advance leaf. Returns entry id.1199
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.1200
* Reason: we want these to be top-level entries in the session, not message session entries,1201
* so it is easier to find them.1202
* These need to be appended via appendCompaction() and appendBranchSummary() methods.1203
*/1204
appendMessage(message: Message | CustomMessage | BashExecutionMessage): string {1205
const entry: SessionMessageEntry = {1206
type: "message",1207
id: generateId(this.byId),1208
parentId: this.leafId,1209
timestamp: new Date().toISOString(),1210
message,1211
};1212
this._appendEntry(entry);1213
return entry.id;1214
}1216
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */1217
appendThinkingLevelChange(thinkingLevel: string): string {1218
const entry: ThinkingLevelChangeEntry = {1219
type: "thinking_level_change",1220
id: generateId(this.byId),1221
parentId: this.leafId,1222
timestamp: new Date().toISOString(),1223
thinkingLevel,1224
};1225
this._appendEntry(entry);1226
return entry.id;1227
}1229
/** Append a model change as child of current leaf, then advance leaf. Returns entry id. */1230
appendModelChange(provider: string, modelId: string): string {1231
const entry: ModelChangeEntry = {1232
type: "model_change",1233
id: generateId(this.byId),1234
parentId: this.leafId,1235
timestamp: new Date().toISOString(),1236
provider,1237
modelId,1238
};1239
this._appendEntry(entry);1240
return entry.id;1241
}1243
/** Append model-attributed usage that does not participate in LLM context. Returns the appended entry. */1244
appendUsage(kind: string, provider: string, model: string, usage: Usage, note?: string): UsageEntry {1245
const entry: UsageEntry = {1246
type: "usage",1247
id: generateId(this.byId),1248
parentId: this.leafId,1249
timestamp: new Date().toISOString(),1250
kind,1251
provider,1252
model,1253
usage,1254
...(note ? { note } : {}),1255
};1256
this._appendEntry(entry);1257
return entry;1258
}1260
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */1261
appendCompaction<T = unknown>(1262
summary: string,1263
firstKeptEntryId: string | null,1264
tokensBefore: number,1265
details?: T,1266
fromHook?: boolean,1267
usage?: Usage,1268
): string {1269
const timestamp = new Date().toISOString();1270
const systemMessage = getCurrentSystemMessage(this.buildSessionProjection().messages);1271
const id = generateId(this.byId);1272
const entry: CompactionEntry<T> = {1273
type: "compaction",1274
id,1275
parentId: this.leafId,1276
timestamp,1277
summary,1278
firstKeptEntryId: firstKeptEntryId ?? id,1279
tokensBefore,1280
details,1281
usage,1282
fromHook,1283
...(systemMessage ? { systemMessage: { ...systemMessage, timestamp: new Date(timestamp).getTime() } } : {}),1284
};1285
this._appendEntry(entry);1286
return entry.id;1287
}1289
/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */1290
appendCustomEntry(customType: string, data?: unknown): string {1291
const entry: CustomEntry = {1292
type: "custom",1293
customType,1294
data,1295
id: generateId(this.byId),1296
parentId: this.leafId,1297
timestamp: new Date().toISOString(),1298
};1299
this._appendEntry(entry);1300
return entry.id;1301
}1303
/** Append a session info entry (e.g., display name). Returns entry id. */1304
appendSessionInfo(name: string): string {1305
const sanitizedName = name.replace(/[\r\n]+/g, " ").trim();1306
const entry: SessionInfoEntry = {1307
type: "session_info",1308
id: generateId(this.byId),1309
parentId: this.leafId,1310
timestamp: new Date().toISOString(),1311
name: sanitizedName,1312
};1313
this._appendEntry(entry);1314
return entry.id;1315
}1317
/** Get the current session name from the latest session_info entry, if any. */1318
getSessionName(): string | undefined {1319
// Walk entries in reverse to find the latest session_info entry.1320
// Empty names explicitly clear the session title. Reads fileEntries directly: the footer1321
// calls this on every frame, and getEntries() copies the whole session.1322
for (let i = this.fileEntries.length - 1; i >= 0; i--) {1323
const entry = this.fileEntries[i];1324
if (entry.type === "session_info") {1325
return entry.name?.trim() || undefined;1326
}1327
}1328
return undefined;1329
}1331
/**1332
* Append a custom message entry (for extensions) that participates in LLM context.1333
* @param customType Extension identifier for filtering on reload1334
* @param content Message content (string or TextContent/ImageContent array)1335
* @param display Whether to show in TUI (true = styled display, false = hidden)1336
* @param details Optional extension-specific metadata (not sent to LLM)1337
* @returns Entry id1338
*/1339
appendCustomMessageEntry<T = unknown>(1340
customType: string,1341
content: string | (TextContent | ImageContent)[],1342
display: boolean,1343
details?: T,1344
): string {1345
const entry: CustomMessageEntry<T> = {1346
type: "custom_message",1347
customType,1348
content,1349
display,1350
details,1351
id: generateId(this.byId),1352
parentId: this.leafId,1353
timestamp: new Date().toISOString(),1354
};1355
this._appendEntry(entry);1356
return entry.id;1357
}1359
/** Append a branch-local edit to an earlier model-visible entry. */1360
appendContextEdit(targetId: string, replacement: ContextEditEntry["replacement"]): string {1361
if (1362
replacement !== null &&1363
(typeof replacement !== "object" ||1364
!("content" in replacement) ||1365
(typeof replacement.content !== "string" && !Array.isArray(replacement.content)))1366
) {1367
throw new Error("Context edit replacement must be null or contain string/array content");1368
}1369
const target = this.byId.get(targetId);1370
if (!target) throw new Error(`Entry ${targetId} not found`);1371
if (!this.getBranch().some((entry) => entry.id === targetId)) {1372
throw new Error(`Entry ${targetId} is not on the active branch`);1373
}1374
const editable =1375
target.type === "custom_message" ||1376
(target.type === "message" &&1377
(target.message.role === "user" ||1378
target.message.role === "assistant" ||1379
target.message.role === "toolResult"));1380
if (!editable) throw new Error(`Entry ${targetId} does not contribute editable model content`);1381
const targetRole = target.type === "message" ? target.message.role : "custom";1382
const normalizedReplacement =1383
replacement !== null &&1384
(targetRole === "assistant" || targetRole === "toolResult") &&1385
typeof replacement.content === "string"1386
? { content: [{ type: "text" as const, text: replacement.content }] }1387
: replacement;1388
const entry: ContextEditEntry = {1389
type: "context_edit",1390
id: generateId(this.byId),1391
parentId: this.leafId,1392
timestamp: new Date().toISOString(),1393
targetId,1394
replacement: normalizedReplacement,1395
};1396
this._appendEntry(entry);1397
return entry.id;1398
}1400
// =========================================================================1401
// Tree Traversal1402
// =========================================================================1404
getLeafId(): string | null {1405
return this.leafId;1406
}1408
getLeafEntry(): SessionEntry | undefined {1409
return this.leafId ? this.byId.get(this.leafId) : undefined;1410
}1412
getEntry(id: string): SessionEntry | undefined {1413
return this.byId.get(id);1414
}1416
/**1417
* Get all direct children of an entry.1418
*/1419
getChildren(parentId: string): SessionEntry[] {1420
const children: SessionEntry[] = [];1421
for (const entry of this.byId.values()) {1422
if (entry.parentId === parentId) {1423
children.push(entry);1424
}1425
}1426
return children;1427
}1429
/**1430
* Get the label for an entry, if any.1431
*/1432
getLabel(id: string): string | undefined {1433
return this.labelsById.get(id);1434
}1436
/**1437
* Set or clear a label on an entry.1438
* Labels are user-defined markers for bookmarking/navigation.1439
* Pass undefined or empty string to clear the label.1440
*/1441
appendLabelChange(targetId: string, label: string | undefined): string {1442
if (!this.byId.has(targetId)) {1443
throw new Error(`Entry ${targetId} not found`);1444
}1445
const entry: LabelEntry = {1446
type: "label",1447
id: generateId(this.byId),1448
parentId: this.leafId,1449
timestamp: new Date().toISOString(),1450
targetId,1451
label,1452
};1453
this._appendEntry(entry);1454
if (label) {1455
this.labelsById.set(targetId, label);1456
this.labelTimestampsById.set(targetId, entry.timestamp);1457
} else {1458
this.labelsById.delete(targetId);1459
this.labelTimestampsById.delete(targetId);1460
}1461
return entry.id;1462
}1464
/**1465
* Walk from entry to root, returning all entries in path order.1466
* Includes all entry types (messages, compaction, model changes, etc.).1467
* Use buildSessionContext() to get the resolved messages for the LLM.1468
*/1469
getBranch(fromId?: string): SessionEntry[] {1470
const path: SessionEntry[] = [];1471
const startId = fromId ?? this.leafId;1472
let current = startId ? this.byId.get(startId) : undefined;1473
while (current) {1474
path.push(current);1475
current = current.parentId ? this.byId.get(current.parentId) : undefined;1476
}1477
path.reverse();1478
return path;1479
}1481
/**1482
* Build the active, compaction-aware entry list for context/rendering.1483
* Uses tree traversal from current leaf.1484
*/1485
buildContextEntries(): SessionEntry[] {1486
return buildContextEntries(this.getEntries(), this.leafId, this.byId);1487
}1489
/**1490
* Build the session context (what gets sent to the LLM).1491
* Uses tree traversal from current leaf.1492
*/1493
buildSessionProjection(): SessionProjection {1494
return buildSessionProjection(this.getEntries(), this.leafId, this.byId);1495
}1497
buildSessionContext(): SessionContext {1498
const { messages, thinkingLevel, model } = this.buildSessionProjection();1499
return { messages, thinkingLevel, model };1500
}1502
/**1503
* Get session header.1504
*/1505
getHeader(): SessionHeader | null {1506
const h = this.fileEntries.find((e) => e.type === "session");1507
return h ? (h as SessionHeader) : null;1508
}1510
/** Number of session entries (excludes header), without copying them like `getEntries()`. */1511
getEntryCount(): number {1512
return this.byId.size;1513
}1515
/**1516
* Get all session entries (excludes header). Returns a shallow copy.1517
* The session is append-only: use appendXXX() to add entries, branch() to1518
* change the leaf pointer. Entries cannot be modified or deleted.1519
*/1520
getEntries(): SessionEntry[] {1521
return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session");1522
}1524
/**1525
* Get the session as a tree structure. Returns a shallow defensive copy of all entries.1526
* A well-formed session has exactly one root (first entry with parentId === null).1527
* Orphaned entries (broken parent chain) are also returned as roots.1528
*/1529
getTree(): SessionTreeNode[] {1530
const entries = this.getEntries();1531
const nodeMap = new Map<string, SessionTreeNode>();1532
const roots: SessionTreeNode[] = [];1534
// Create nodes with resolved labels1535
for (const entry of entries) {1536
const label = this.labelsById.get(entry.id);1537
const labelTimestamp = this.labelTimestampsById.get(entry.id);1538
nodeMap.set(entry.id, { entry, children: [], label, labelTimestamp });1539
}1541
// Build tree1542
for (const entry of entries) {1543
const node = nodeMap.get(entry.id)!;1544
if (entry.parentId === null || entry.parentId === entry.id) {1545
roots.push(node);1546
} else {1547
const parent = nodeMap.get(entry.parentId);1548
if (parent) {1549
parent.children.push(node);1550
} else {1551
// Orphan - treat as root1552
roots.push(node);1553
}1554
}1555
}1557
// Sort children by timestamp (oldest first, newest at bottom)1558
// Use iterative approach to avoid stack overflow on deep trees1559
const stack: SessionTreeNode[] = [...roots];1560
while (stack.length > 0) {1561
const node = stack.pop()!;1562
node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());1563
stack.push(...node.children);1564
}1566
return roots;1567
}1569
// =========================================================================1570
// Branching1571
// =========================================================================1573
/**1574
* Start a new branch from an earlier entry.1575
* Moves the leaf pointer to the specified entry. The next appendXXX() call1576
* will create a child of that entry, forming a new branch. Existing entries1577
* are not modified or deleted.1578
*/1579
branch(branchFromId: string): void {1580
if (!this.byId.has(branchFromId)) {1581
throw new Error(`Entry ${branchFromId} not found`);1582
}1583
this.leafId = branchFromId;1584
}1586
/**1587
* Reset the leaf pointer to null (before any entries).1588
* The next appendXXX() call will create a new root entry (parentId = null).1589
* Use this when navigating to re-edit the first user message.1590
*/1591
resetLeaf(): void {1592
this.leafId = null;1593
}1595
/**1596
* Start a new branch with a summary of the abandoned path.1597
* Same as branch(), but also appends a branch_summary entry that captures1598
* context from the abandoned conversation path.1599
*/1600
branchWithSummary(1601
branchFromId: string | null,1602
summary: string,1603
details?: unknown,1604
fromHook?: boolean,1605
usage?: Usage,1606
): string {1607
if (branchFromId !== null && !this.byId.has(branchFromId)) {1608
throw new Error(`Entry ${branchFromId} not found`);1609
}1610
const fromId = this.leafId ?? "root";1611
this.leafId = branchFromId;1612
const entry: BranchSummaryEntry = {1613
type: "branch_summary",1614
id: generateId(this.byId),1615
parentId: branchFromId,1616
timestamp: new Date().toISOString(),1617
fromId,1618
summary,1619
details,1620
usage,1621
fromHook,1622
};1623
this._appendEntry(entry);1624
return entry.id;1625
}1627
/**1628
* Create a new session file containing only the path from root to the specified leaf.1629
* Useful for extracting a single conversation path from a branched session.1630
* Returns the new session file path, or undefined if not persisting.1631
*/1632
createBranchedSession(leafId: string): string | undefined {1633
const previousSessionFile = this.sessionFile;1634
const path = this.getBranch(leafId);1635
if (path.length === 0) {1636
throw new Error(`Entry ${leafId} not found`);1637
}1639
// Filter out LabelEntry from path - we'll recreate them from the resolved map.1640
// Because labels are real tree entries, later entries can be children of labels;1641
// removing labels requires re-chaining the retained path to avoid orphaned subtrees.1642
const pathWithoutLabels: SessionEntry[] = [];1643
const replacementByLabelId = new Map<string, string>();1644
const pendingLabelIds: string[] = [];1645
let pathParentId: string | null = null;1646
for (const entry of path) {1647
if (entry.type === "label") {1648
pendingLabelIds.push(entry.id);1649
continue;1650
}1651
for (const labelId of pendingLabelIds) {1652
replacementByLabelId.set(labelId, entry.id);1653
}1654
pendingLabelIds.length = 0;1655
pathWithoutLabels.push(1656
entry.type === "compaction"1657
? {1658
...entry,1659
parentId: pathParentId,1660
firstKeptEntryId:1661
entry.firstKeptEntryId === entry.id1662
? entry.id1663
: (replacementByLabelId.get(entry.firstKeptEntryId) ?? entry.firstKeptEntryId),1664
}1665
: { ...entry, parentId: pathParentId },1666
);1667
pathParentId = entry.id;1668
}1670
const newSessionId = createSessionId();1671
const timestamp = new Date().toISOString();1672
const fileTimestamp = timestamp.replace(/[:.]/g, "-");1673
const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);1675
const header: SessionHeader = {1676
type: "session",1677
version: CURRENT_SESSION_VERSION,1678
id: newSessionId,1679
timestamp,1680
cwd: this.cwd,1681
parentSession: this.persist ? previousSessionFile : undefined,1682
};1684
// Collect labels for entries in the path1685
const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));1686
const labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = [];1687
for (const [targetId, label] of this.labelsById) {1688
if (pathEntryIds.has(targetId)) {1689
labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId)! });1690
}1691
}1693
if (this.persist) {1694
// Build label entries1695
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;1696
let parentId = lastEntryId;1697
const labelEntries: LabelEntry[] = [];1698
for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {1699
const labelEntry: LabelEntry = {1700
type: "label",1701
id: generateId(new Set(pathEntryIds)),1702
parentId,1703
timestamp: labelTimestamp,1704
targetId,1705
label,1706
};1707
pathEntryIds.add(labelEntry.id);1708
labelEntries.push(labelEntry);1709
parentId = labelEntry.id;1710
}1712
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];1713
this.sessionId = newSessionId;1714
this.sessionFile = newSessionFile;1715
this._buildIndex();1717
// Use the same rule as _persist(): write now if the branched path already1718
// has a conversation, otherwise let _persist() create the file later.1719
if (this._hasConversation()) {1720
this._rewriteFile();1721
this.flushed = true;1722
} else {1723
this.flushed = false;1724
}1726
return newSessionFile;1727
}1729
// In-memory mode: replace current session with the path + labels1730
const labelEntries: LabelEntry[] = [];1731
let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;1732
for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {1733
const labelEntry: LabelEntry = {1734
type: "label",1735
id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),1736
parentId,1737
timestamp: labelTimestamp,1738
targetId,1739
label,1740
};1741
labelEntries.push(labelEntry);1742
parentId = labelEntry.id;1743
}1744
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];1745
this.sessionId = newSessionId;1746
this._buildIndex();1747
return undefined;1748
}1750
/**1751
* Create a new session.1752
* @param cwd Working directory (stored in session header)1753
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).1754
*/1755
static create(cwd: string, sessionDir?: string, options?: NewSessionOptions): SessionManager {1756
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);1757
return new SessionManager(cwd, dir, undefined, true, options);1758
}1760
/**1761
* Open a specific session file.1762
* @param path Path to session file1763
* @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.1764
* @param cwdOverride Optional cwd override instead of the session header cwd.1765
*/1766
static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {1767
const resolvedPath = resolvePath(path);1768
let header: SessionHeader | null = null;1769
let preloadedFileEntries: FileEntry[] | undefined;1770
if (cwdOverride === undefined && existsSync(resolvedPath)) {1771
try {1772
header = readSessionHeader(resolvedPath);1773
} catch (error) {1774
if (!(error instanceof SessionHeaderScanLimitError)) throw error;1775
// The bounded scan is only a discovery optimization. A full load remains1776
// authoritative for legacy files with very large headers or prefixes.1777
preloadedFileEntries = loadEntriesFromFile(resolvedPath);1778
const firstEntry = preloadedFileEntries[0];1779
header = firstEntry?.type === "session" ? firstEntry : null;1780
}1781
}1782
const cwd = cwdOverride ?? (header ? getSessionHeaderCwd(header) : undefined) ?? process.cwd();1783
// If no sessionDir provided, derive from file's parent directory1784
const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, "..");1785
return new SessionManager(cwd, dir, resolvedPath, true, undefined, preloadedFileEntries);1786
}1788
/**1789
* Continue the most recent session, or create new if none.1790
* @param cwd Working directory1791
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).1792
*/1793
static continueRecent(cwd: string, sessionDir?: string): SessionManager {1794
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);1795
const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);1796
const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined);1797
if (mostRecent) {1798
return new SessionManager(cwd, dir, mostRecent, true);1799
}1800
return new SessionManager(cwd, dir, undefined, true);1801
}1803
/** Create an in-memory session (no file persistence), optionally from entries held outside the filesystem. */1804
static inMemory(cwd: string = process.cwd(), options?: NewSessionOptions, entries?: FileEntry[]): SessionManager {1805
return new SessionManager(cwd, "", undefined, false, options, entries);1806
}1808
/**1809
* Fork a session from another project directory into the current project.1810
* Creates a new session in the target cwd with the full history from the source session.1811
* @param sourcePath Path to the source session file1812
* @param targetCwd Target working directory (where the new session will be stored)1813
* @param sessionDir Optional session directory. If omitted, uses default for targetCwd.1814
*/1815
static forkFrom(1816
sourcePath: string,1817
targetCwd: string,1818
sessionDir?: string,1819
options?: NewSessionOptions,1820
): SessionManager {1821
const resolvedSourcePath = resolvePath(sourcePath);1822
const resolvedTargetCwd = resolvePath(targetCwd);1823
const sourceEntries = loadEntriesFromFile(resolvedSourcePath);1824
if (sourceEntries.length === 0) {1825
throw new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`);1826
}1828
const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined;1829
if (!sourceHeader) {1830
throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);1831
}1833
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);1834
if (!existsSync(dir)) {1835
mkdirSync(dir, { recursive: true });1836
}1838
// Create new session file with new ID but forked content1839
if (options?.id !== undefined) {1840
assertValidSessionId(options.id);1841
}1842
const newSessionId = options?.id ?? createSessionId();1843
const timestamp = new Date().toISOString();1844
const fileTimestamp = timestamp.replace(/[:.]/g, "-");1845
const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);1847
// Write new header pointing to source as parent, with updated cwd1848
const newHeader: SessionHeader = {1849
type: "session",1850
version: CURRENT_SESSION_VERSION,1851
id: newSessionId,1852
timestamp,1853
cwd: resolvedTargetCwd,1854
parentSession: resolvedSourcePath,1855
};1856
writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });1858
// Copy all non-header entries from source1859
for (const entry of sourceEntries) {1860
if (entry.type !== "session") {1861
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);1862
}1863
}1865
return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);1866
}1868
/**1869
* Find an exact session ID without loading transcript bodies.1870
* @param cwd Working directory (used to compute default session directory)1871
* @param id Exact session ID1872
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).1873
*/1874
static findById(cwd: string, id: string, sessionDir?: string): string | undefined {1875
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);1876
const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);1877
const resolvedCwd = resolvePath(cwd);1879
try {1880
for (const file of readdirSync(dir)) {1881
if (!file.endsWith(".jsonl")) continue;1882
const path = join(dir, file);1883
const header = readSessionHeaderForDiscovery(path);1884
if (header?.id !== id) continue;1885
if (filterCwd && !sessionCwdMatches(getSessionHeaderCwd(header), resolvedCwd)) continue;1886
return path;1887
}1888
} catch {1889
// Exact session discovery is best-effort, matching list().1890
}1891
return undefined;1892
}1894
/**1895
* List all sessions for a directory.1896
* @param cwd Working directory (used to compute default session directory)1897
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).1898
* @param onProgress Optional callback for progress updates (loaded, total)1899
*/1900
static async list(1901
cwd: string,1902
sessionDir?: string,1903
onProgress?: SessionListProgress,1904
signal?: AbortSignal,1905
): Promise<SessionInfo[]> {1906
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);1907
const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);1908
const resolvedCwd = resolvePath(cwd);1909
const includeSession = (session: SessionInfo) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd);1910
const progress: SessionListProgress | undefined = onProgress1911
? (loaded, total, partialSessions) => onProgress(loaded, total, partialSessions?.filter(includeSession))1912
: undefined;1913
const sessions = (await listSessionsFromDir(dir, progress, signal)).filter(includeSession);1914
return sortSessionInfos(sessions);1915
}1917
/**1918
* List all sessions across all project directories.1919
* @param onProgress Optional callback for progress updates (loaded, total)1920
*/1921
static async listAll(onProgress?: SessionListProgress, signal?: AbortSignal): Promise<SessionInfo[]>;1922
static async listAll(1923
sessionDir?: string,1924
onProgress?: SessionListProgress,1925
signal?: AbortSignal,1926
): Promise<SessionInfo[]>;1927
static async listAll(1928
sessionDirOrOnProgress?: string | SessionListProgress,1929
onProgressOrSignal?: SessionListProgress | AbortSignal,1930
signal?: AbortSignal,1931
): Promise<SessionInfo[]> {1932
const customSessionDir =1933
typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined;1934
const progress =1935
typeof sessionDirOrOnProgress === "function"1936
? sessionDirOrOnProgress1937
: typeof onProgressOrSignal === "function"1938
? onProgressOrSignal1939
: undefined;1940
const abortSignal =1941
typeof sessionDirOrOnProgress === "string" || typeof onProgressOrSignal === "function"1942
? signal1943
: (onProgressOrSignal ?? signal);1944
abortSignal?.throwIfAborted();1945
if (customSessionDir) {1946
return sortSessionInfos(await listSessionsFromDir(customSessionDir, progress, abortSignal));1947
}1949
const sessionsDir = getSessionsDir();1951
try {1952
if (!existsSync(sessionsDir)) return [];1953
const entries = await readdir(sessionsDir, { withFileTypes: true });1954
const dirs = entries1955
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())1956
.map((entry) => join(sessionsDir, entry.name));1958
const dirFiles = await mapWithConcurrency(1959
dirs,1960
MAX_CONCURRENT_SESSION_DISCOVERY_LOADS,1961
async (dir) => {1962
try {1963
return (await readdir(dir)).filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file));1964
} catch {1965
return [];1966
}1967
},1968
abortSignal,1969
);1970
const allFiles = dirFiles.flat();1971
const candidates = await mapWithConcurrency(1972
allFiles,1973
MAX_CONCURRENT_SESSION_DISCOVERY_LOADS,1974
async (path): Promise<SessionFileCandidate> => {1975
try {1976
return { path, stats: await stat(path) };1977
} catch {1978
return { path };1979
}1980
},1981
abortSignal,1982
);1983
candidates.sort(1984
(a, b) =>1985
(b.stats?.mtimeMs ?? Number.NEGATIVE_INFINITY) - (a.stats?.mtimeMs ?? Number.NEGATIVE_INFINITY) ||1986
basename(b.path).localeCompare(basename(a.path)),1987
);1989
const totalFiles = candidates.length;1990
let loaded = 0;1991
let firstCandidateLoaded = false;1992
const partialSessions: SessionInfo[] = [];1993
const results = await buildSessionInfosWithConcurrency(1994
candidates,1995
(info, index) => {1996
loaded++;1997
if (index === 0) firstCandidateLoaded = true;1998
if (info) partialSessions.push(info);1999
const publishPartial =2000
firstCandidateLoaded &&2001
(index === 0 || loaded % ALL_SESSION_LIST_PUBLISH_INTERVAL === 0 || loaded === totalFiles);2002
progress?.(loaded, totalFiles, publishPartial ? sortSessionInfos([...partialSessions]) : undefined);2003
},2004
abortSignal,2005
);2007
return sortSessionInfos(results.filter((info): info is SessionInfo => info !== null));2008
} catch {2009
abortSignal?.throwIfAborted();2010
return [];2011
}2012
}2013
}