Chương 8Phần 3: Coding agent
Session: cây JSONL, nhánh và context
Tệp JSONL dạng cây, migration, dựng lại context từ nhánh đang hoạt động, /tree và fork.
Trong chương này18 mục
- Vai trò
- Mô hình tư duy
- Tệp session nằm ở đâu
- Header và các loại entry
- Ví dụ minh hoạ một tệp JSONL
- Ghi thêm và nạp lại
- Phiên bản và migration
- Dựng lại context từ nhánh đang hoạt động
- Ví dụ: từ cây entry đến danh sách message
- Rẽ nhánh trong cùng tệp: /tree
- Fork và clone sang tệp mới
- Nhãn và tên phiên
- Liệt kê và mở lại phiên
- Xuất HTML và chia sẻ
- Quyết định thiết kế
- Cạm bẫy và trường hợp biên
- Đọc code ở đâu
- Tóm tắt
Vai trò#
Một session là bản ghi đầy đủ của một cuộc trò chuyện: mọi message, lời gọi tool và kết quả tool, mọi lần đổi mô hình (model), compaction (nén ngữ cảnh), nhãn, trạng thái extension. Pi lưu nó thành một tệp JSONL (mỗi dòng một đối tượng JSON) và tài liệu hoá định dạng đó trong packages/coding-agent/docs/session-format.md. Đó là một lựa chọn có chủ ý từ ngày đầu: Mario Zechner viết pi một phần vì muốn nhìn thấy và kiểm soát toàn bộ ngữ cảnh, với một định dạng session được tài liệu hoá để công cụ khác đọc được ("What I learned building an opinionated and minimal coding agent").
SessionManager trong packages/coding-agent/src/core/session-manager.ts sở hữu tệp đó. Chú thích của lớp tóm tắt toàn bộ mô hình:
/** * 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. */buildSessionProjection(); buildSessionContext() mà chú thích nhắc tới chỉ còn là lớp bọc mỏng bên trên nó.Ai gọi nó: AgentSession ghi entry qua các hàm append*() và dựng ngữ cảnh mỗi yêu cầu (chương AgentSession); main.ts chọn cách mở tệp theo cờ dòng lệnh; AgentSessionRuntime tạo tệp mới khi /new, /fork, /clone; bộ chọn cây của TUI đọc getTree() (chương pi-tui); bộ xuất HTML đọc mọi entry. Extension chỉ được cầm một bản đọc (ReadonlySessionManager, session-manager.ts:245); muốn ghi, chúng đi qua API của extension (chương Extensions).
Mô hình tư duy#
Ba ý cần giữ trong đầu:
- Chỉ ghi thêmEntry không bao giờ bị sửa hay xoá. Muốn "sửa" ngữ cảnh thì ghi thêm một entry mới (compaction, context edit, nhãn) mô tả thay đổi.
- Cây qua parentIdMỗi entry trỏ tới cha bằng
parentId. Quay lại một điểm cũ rồi gõ tiếp tạo một nhánh mới trong cùng tệp. - Con trỏ lá
leafIdlà vị trí hiện tại. Entry mới luôn là con của lá, và lá dời sang nó. Ngữ cảnh gửi cho mô hình là đường đi từ gốc tới lá.
header {type:"session", version:3, id, cwd} (dòng đầu, không thuộc cây)
[model_change] ─ [thinking_level_change] ─ [system] ─ [user U1] ─ [assistant A1]
│
┌───────────────────────────────────────────────┴──────┐
│ │
[user U2] ─ [assistant A2] [branch_summary] ─ [user U3] ─ [assistant A3]
nhánh cũ, vẫn nằm trong tệp ▲
lá (leafId)
nhánh đang hoạt động = model_change → … → A1 → branch_summary → U3 → A3
/tree, kèm tóm tắt nhánh vừa rời. Nhánh U2, A2 vẫn còn trong tệp nhưng không còn trong ngữ cảnh.Từ vựng của chương:
- entry
- Một dòng JSON sau header, có
type,id,parentId,timestamp. - gốc (root)
- Entry có
parentId: null. Thường chỉ có một, nhưngresetLeaf()cho phép tạo gốc thứ hai. - nhánh đang hoạt động (active branch)
- Đường đi từ gốc tới lá, trả về bởi
getBranch(). - context entries
- Nhánh đang hoạt động sau khi áp dụng compaction mới nhất (
buildContextEntries()). - projection
- Context entries đã đổi thành message mô hình sẽ thấy, sau khi áp dụng context edit, mỗi message giữ entry nguồn (
buildSessionProjection()).
Tệp session nằm ở đâu#
Mỗi thư mục làm việc (cwd) có một thư mục session riêng dưới ~/.pi/agent/sessions/. Tên thư mục mã hoá đường dẫn cwd:
/** * Compute the default session directory for a cwd. * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. */function getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): string { const resolvedCwd = resolvePath(cwd); const resolvedAgentDir = resolvePath(agentDir); const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; return join(resolvedAgentDir, "sessions", safePath);}export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { const sessionDir = getDefaultSessionDirPath(cwd, agentDir); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } return sessionDir;}/, \, : bằng -, bọc trong --. Cwd /home/phuc/projects/my-research thành ~/.pi/agent/sessions/--home-phuc-projects-my-research--/.Tên tệp ghép thời điểm tạo và id phiên: 2026-10-22T05-04-11-123Z_<id>.jsonl, với : và . của timestamp ISO đổi thành - (session-manager.ts:1078). Id mặc định là một UUID phiên bản 7, loại UUID có phần đầu là dấu thời gian (session-manager.ts:264); tài liệu chỉ nói "UUID". Vì tên tệp bắt đầu bằng thời điểm tạo, sắp xếp tên tệp giảm dần cho ra phiên mới nhất trước, đúng thứ tự mà listSessionsFromDir() dùng để nạp dần danh sách (session-manager.ts:950). --session-id cho đặt id tuỳ ý, miễn chỉ gồm chữ, số, ., _, - và bắt đầu, kết thúc bằng chữ hoặc số (session-manager.ts:268).
Thư mục gốc có thể đổi, theo thứ tự ưu tiên: --session-dir, biến PI_CODING_AGENT_SESSION_DIR, setting sessionDir, rồi mặc định (main.ts:691). Khi thư mục là tuỳ biến, nhiều cwd có thể dùng chung một thư mục, nên các hàm tìm kiếm lọc thêm theo cwd trong header (session-manager.ts:1793).
--no-session (và --help, --list-models) dùng SessionManager.inMemory() (main.ts:364): cùng cấu trúc cây, cùng mọi hàm, chỉ là persist = false nên không gì chạm đĩa. Phiên kiểu này không thể resume sau khi pi thoát.
Header và các loại entry#
Dòng đầu tiên của tệp là header. Nó không có id cây hay parentId; mọi dòng sau đều kế thừa SessionEntryBase:
export const CURRENT_SESSION_VERSION = 3;export interface SessionHeader { type: "session"; version?: number; // v1 sessions don't have this id: string; timestamp: string; cwd: string; parentSession?: string;}export interface NewSessionOptions { id?: string; parentSession?: string;}export interface SessionEntryBase { type: string; id: string; parentId: string | null; timestamp: string;}cwd trong header quyết định phiên thuộc dự án nào khi resume. parentSession trỏ tới tệp gốc khi phiên được tạo bằng fork hoặc clone. Timestamp của entry là chuỗi ISO 8601, còn timestamp bên trong một message là số mili giây Unix (docs/session-format.md:47).Id của entry là 8 ký tự hex lấy từ randomUUID(), được kiểm tra trùng với các id đã có, và chỉ rơi về UUID đầy đủ nếu 100 lần thử đều trùng (session-manager.ts:276). Id ngắn giữ tệp gọn và đọc được bằng mắt; chúng chỉ cần duy nhất trong một tệp. Mười một loại entry theo sau.
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 UsageEntry extends SessionEntryBase { type: "usage"; /** Arbitrary usage category, such as "cache_warm". */ kind: string; provider: string; model: string; usage: Usage; /** Optional human-readable qualifier for usage notices. */ note?: string;}message bọc một AgentMessage bất kỳ: system, user, assistant, toolResult, hoặc bashExecution của lệnh ! (chương AgentSession). usage ghi chi phí của những lời gọi mô hình không sinh message, như làm ấm prompt cache (kind: "cache_warm").export interface CompactionEntry<T = unknown> extends SessionEntryBase { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; /** Usage from the LLM call(s) that generated this summary, if available */ usage?: Usage; /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */ fromHook?: boolean; /** Complete prompt and tool state at this compaction boundary. */ systemMessage?: SystemMessage;}export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase { type: "branch_summary"; fromId: string; summary: string; /** Extension-specific data (not sent to LLM) */ details?: T; /** Usage from the LLM call that generated this summary, if available */ usage?: Usage; /** True if generated by an extension, false if pi-generated */ fromHook?: boolean;}firstKeptEntryId đánh dấu entry đầu tiên được giữ nguyên; mọi thứ trước nó trên nhánh được thay bằng summary. systemMessage là ảnh chụp toàn bộ system prompt và bộ tool tại điểm nén. branch_summary mang tóm tắt của nhánh vừa rời, với fromId là lá cũ. Trường fromHook là tên cũ, nghĩa là "do extension tạo"./** * Custom entry for extensions to store extension-specific data in the session. * Use customType to identify your extension's entries. * * Purpose: Persist extension state across session reloads. On reload, extensions 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;}/** Session metadata entry (e.g., user-defined display name). */export interface SessionInfoEntry extends SessionEntryBase { type: "session_info"; name?: string;}custom là chỗ extension lưu trạng thái của nó và không bao giờ vào ngữ cảnh. Pi cũng dùng nó cho trạng thái router của virtual model (customType: "pi.virtual-model-state"). label với label: undefined xoá nhãn./** * Custom message entry for extensions to inject messages into LLM context. * Use customType to identify your extension's entries. * * Unlike CustomEntry, this DOES participate in LLM context. * The content is converted to a user message in buildSessionContext(). * Use details for extension-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;}/** Content that an append-only context edit may replace without changing message metadata. */export type ContextEditableContent = | UserMessage["content"] | AssistantMessage["content"] | ToolResultMessage["content"] | CustomMessage["content"];/** Append-only change to one earlier entry's contribution to model context. */export interface ContextEditEntry extends SessionEntryBase { type: "context_edit"; targetId: string; /** Null omits the target from model context. A value replaces only its content. */ replacement: { content: ContextEditableContent } | null;}custom, custom_message có vào ngữ cảnh. Chú thích nói nội dung "được đổi thành message user trong buildSessionContext()"; thực tế ở v1.4.0 buildSessionProjection() sinh một message role custom, và việc hạ về user xảy ra trong convertToLlm() lúc gửi. context_edit thay nội dung của một entry trước đó, hoặc bỏ nó (replacement: null), chỉ trong ngữ cảnh./** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */export type SessionEntry = | SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry | UsageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry | CustomMessageEntry | ContextEditEntry | LabelEntry | SessionInfoEntry;/** Raw file entry (includes header) */export type FileEntry = SessionHeader | SessionEntry;Bảng dưới gom lại: loại nào vào ngữ cảnh, và ai ghi nó.
type | Vào ngữ cảnh? | Ghi bởi |
|---|---|---|
message | Có, chính message đó (bashExecution với !! bị bỏ khi gửi) | appendMessage() |
thinking_level_change | Không; chỉ đặt mức suy nghĩ khi resume | appendThinkingLevelChange() |
model_change | Không; chỉ chọn mô hình khi resume | appendModelChange() |
usage | Không; cộng vào thống kê | appendUsage() |
compaction | Có: message system ảnh chụp, rồi compactionSummary | appendCompaction() |
branch_summary | Có: branchSummary (nếu tóm tắt khác rỗng) | branchWithSummary() |
custom | Không | appendCustomEntry() |
custom_message | Có: message custom | appendCustomMessageEntry() |
context_edit | Không tự sinh message; sửa hoặc bỏ entry đích | appendContextEdit() |
label | Không | appendLabelChange() |
session_info | Không | appendSessionInfo() |
Ví dụ minh hoạ một tệp JSONL#
Ví dụ minh hoạ: tệp của kịch bản ở chương AgentSession (hỏi version trong package.json, mô hình gọi read một lần), sau đó người dùng đặt tên phiên bằng /name và gắn nhãn cho câu trả lời. Id, thời điểm và nội dung là bịa; tên trường đúng với các type ở trên. Dấu … đánh dấu chỗ rút gọn (nội dung system prompt, schema tool, đối tượng usage), không phải cú pháp JSON.
{"type":"session","version":3,"id":"019a0b2c-7d3e-7f41-9c2a-5b6e8d1f0a37","timestamp":"2026-10-22T05:04:11.123Z","cwd":"/home/phuc/projects/demo"}
{"type":"model_change","id":"a1b2c3d4","parentId":null,"timestamp":"2026-10-22T05:04:11.130Z","provider":"anthropic","modelId":"claude-sonnet-4-5"}
{"type":"thinking_level_change","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2026-10-22T05:04:11.131Z","thinkingLevel":"medium"}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2026-10-22T05:04:20.005Z","message":{"role":"system","content":"","sections":{"preamble":"You are an expert coding assistant operating inside pi, …","tools":"<tools>\n- read: …","rules":"<rules>…","docs":"<docs>…","cwd":"<cwd>\n/home/phuc/projects/demo\n</cwd>"},"toolsAdded":[{"name":"read","description":"…","parameters":{…}},{"name":"bash",…},{"name":"edit",…},{"name":"write",…}],"timestamp":1792645460003}}
{"type":"message","id":"d4e5f607","parentId":"c3d4e5f6","timestamp":"2026-10-22T05:04:20.006Z","message":{"role":"user","content":[{"type":"text","text":"package.json khai báo version nào?"}],"timestamp":1792645460001}}
{"type":"message","id":"e5f60718","parentId":"d4e5f607","timestamp":"2026-10-22T05:04:23.410Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01","name":"read","arguments":{"path":"package.json"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{…},"stopReason":"toolUse","thinkingLevel":"medium","timestamp":1792645460010}}
{"type":"message","id":"f6071829","parentId":"e5f60718","timestamp":"2026-10-22T05:04:23.452Z","message":{"role":"toolResult","toolCallId":"toolu_01","toolName":"read","content":[{"type":"text","text":"{\n \"name\": \"demo\",\n \"version\": \"1.2.3\"\n}"}],"isError":false,"timestamp":1792645463451}}
{"type":"message","id":"0718293a","parentId":"f6071829","timestamp":"2026-10-22T05:04:25.977Z","message":{"role":"assistant","content":[{"type":"text","text":"Version là 1.2.3."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{…},"stopReason":"stop","thinkingLevel":"medium","timestamp":1792645463460}}
{"type":"session_info","id":"18293a4b","parentId":"0718293a","timestamp":"2026-10-22T05:05:02.000Z","name":"Kiểm tra version"}
{"type":"label","id":"293a4b5c","parentId":"18293a4b","timestamp":"2026-10-22T05:05:10.000Z","targetId":"0718293a","label":"đã trả lời"}
parentId nối mọi dòng thành một chuỗi, kể cả session_info và label: chúng cũng là nút của cây.Vài điều đọc được từ ví dụ. Message system có timestamp lớn hơn message user dù đứng trước: _prepareRun() tạo message user trước, rồi mới tạo bản vá system và chèn nó lên đầu mảng (chương AgentSession, bước 2). Các phần prompt khác preamble được bọc trong thẻ cùng tên, như <cwd>…</cwd> (system-prompt.ts:188). Mỗi message assistant mang thinkingLevel được yêu cầu cho chính phản hồi đó, do vòng lặp agent gắn vào (agent-loop.ts:419).
Ghi thêm và nạp lại#
Mọi hàm append*() có cùng hình dạng: tạo entry với parentId là lá hiện tại, rồi giao cho _appendEntry():
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 | CustomMessage | 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;}appendMessage() cố ý loại hai role tóm tắt: chúng phải là entry cấp cao (compaction, branch_summary) để dễ tìm, và message tương ứng được sinh lại mỗi lần dựng ngữ cảnh.Ghi xuống đĩa là một lệnh appendFileSync một dòng. Ngoại lệ là lần đầu:
/** * A new session file is created only once the session contains a user or assistant message. * Setup entries alone (model, thinking level, system prompt) stay in memory so opening and * closing pi without chatting leaves no file behind. Starting at the user message (not the * first assistant reply) keeps the prompt on disk if the first turn never completes (#10000). */private _hasConversation(): boolean { return this.fileEntries.some( (e) => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"), );}_persist(entry: SessionEntry): void { if (!this.persist || !this.sessionFile) return; if (!this.flushed) { if (!this._hasConversation()) return; const fd = openSync(this.sessionFile, "wx"); try { for (const e of this.fileEntries) { writeFileSync(fd, `${JSON.stringify(e)}\n`); } } finally { closeSync(fd); } this.flushed = true; } else { appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); }}user đầu tiên đến, tệp được mở với cờ "wx" (thất bại nếu tệp đã tồn tại) và mọi entry đang có được viết một lượt. Chọn mốc là message user chứ không phải phản hồi đầu tiên, để prompt không bị mất nếu lượt đầu không bao giờ xong: đó là lỗi #10000, sửa ở 0.99.0 (CHANGELOG.md:482).Nạp lại một tệp đi theo chiều ngược. loadEntriesFromFile() đọc theo khối 1 MB và tách dòng dần, thay vì đọc cả tệp thành một chuỗi lớn rồi mới tách:
while (true) { const bytesRead = readSync(fd, buffer, 0, buffer.length, null); if (bytesRead === 0) break; pending += decoder.write(buffer.subarray(0, bytesRead)); let lineStart = 0; let newlineIndex = pending.indexOf("\n", lineStart); while (newlineIndex !== -1) { const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex)); if (entry) entries.push(entry); lineStart = newlineIndex + 1; newlineIndex = pending.indexOf("\n", lineStart); } pending = pending.slice(lineStart); } pending += decoder.end(); const finalEntry = parseSessionEntryLine(pending); if (finalEntry) entries.push(finalEntry);} finally { closeSync(fd);}// Validate session header before repairing the file.if (entries.length === 0) return entries;const header = entries[0];if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") { return [];}if (pending) appendFileSync(resolvedFilePath, "\n");return entries;StringDecoder giữ các byte UTF-8 bị cắt ngang giữa hai khối. Dòng không parse được bị bỏ qua lặng lẽ. Nếu dòng đầu không phải header hợp lệ, hàm trả mảng rỗng. Nếu tệp kết thúc giữa chừng một dòng (pi bị giết khi đang ghi), nó thêm một ký tự xuống dòng để lần ghi sau bắt đầu ở dòng mới.Hàm gọi nó, _setSessionFile(), phân biệt ba trường hợp: tệp không tồn tại thì tạo phiên mới nhưng giữ đường dẫn được chỉ định (cho --session đường/dẫn.jsonl); tệp rỗng thì khởi tạo header; tệp có nội dung mà không phải session thì ném lỗi thay vì ghi đè (session-manager.ts:1029). Sau đó chỉ mục trong bộ nhớ được dựng lại:
private _buildIndex(): void { this.byId.clear(); this.labelsById.clear(); this.labelTimestampsById.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); this.labelTimestampsById.set(entry.targetId, entry.timestamp); } else { this.labelsById.delete(entry.targetId); this.labelTimestampsById.delete(entry.targetId); } } }}label theo thứ tự; nhãn rỗng xoá nhãn trước.Phiên bản và migration#
Định dạng đã đổi hai lần (docs/session-format.md:22). Phiên bản 1 là một chuỗi tuyến tính không có id. Phiên bản 2 (pi 0.31.0, tháng 1 năm 2026) thêm id/parentId để rẽ nhánh tại chỗ (CHANGELOG.md:5150). Phiên bản 3 đổi role hookMessage thành custom khi hook được hợp nhất thành extension. Header ghi phiên bản; thiếu trường này nghĩa là v1.
/** 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; } } }}firstKeptEntryIndex (chỉ số dòng); migration đổi nó thành firstKeptEntryId trỏ vào entry ở vị trí đó./** * 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;}Migration chạy trong _loadEntries() mỗi khi một tệp được nạp, sửa mảng trong bộ nhớ, rồi viết lại toàn bộ tệp (session-manager.ts:1085). Ngoài việc khởi tạo một tệp rỗng và viết một tệp mới khi fork, đây là chỗ duy nhất một tệp session đã có nội dung bị viết lại thay vì ghi thêm, và nó chỉ xảy ra một lần cho mỗi tệp cũ.
src/migrations.ts là chuyện khác: các migration một lần lúc khởi động cho cấu hình và vị trí tệp, chạy trước khi chọn session (main.ts:668). Phần liên quan tới session là migrateSessionsFromAgentRoot(): bản 0.30.0 lỡ lưu session thẳng vào ~/.pi/agent/, nên hàm này đọc cwd từ header của từng tệp và chuyển nó về đúng thư mục sessions/--cwd--/ (migrations.ts:75). Các migration khác chuyển credential sang auth.json, commands/ sang prompts/, tools/ sang bin/ (migrations.ts:300).
Dựng lại context từ nhánh đang hoạt động#
Mỗi yêu cầu tới mô hình bắt đầu bằng một câu hỏi: từ cây entry này, mô hình phải thấy danh sách message nào? buildSessionProjection() trả lời bằng bốn bước. Hàm được gọi trước mỗi yêu cầu (chương AgentSession), và chi phí của nó chỉ là vài lần duyệt tuyến tính: sao chép danh sách entry, đi ngược từ lá về gốc, rồi duyệt đường đi đó.
Bước 1: đi từ lá về gốc#
function buildSessionPath( entries: SessionEntry[], leafId?: string | null, byId?: Map<string, SessionEntry>,): SessionEntry[] { const index = buildEntryIndex(entries, byId); let leaf: SessionEntry | undefined; if (leafId === null) { return []; } if (leafId) { leaf = index.get(leafId); } leaf ??= entries[entries.length - 1]; if (!leaf) { return []; } const path: SessionEntry[] = []; let current: SessionEntry | undefined = leaf; while (current) { path.push(current); current = current.parentId ? index.get(current.parentId) : undefined; } path.reverse(); return path;}leafId === null (sau resetLeaf()) cho đường rỗng; không truyền leafId thì lấy entry cuối mảng. Nhánh khác của cây không bao giờ được chạm tới.Trên đường đi đó, getSessionContextSettings() đọc mức suy nghĩ từ thinking_level_change cuối cùng và mô hình từ model_change hay message assistant cuối cùng (session-manager.ts:418). Hai giá trị này chỉ dùng khi resume; chúng không thành message.
Bước 2: áp dụng compaction mới nhất#
export function buildContextEntries( entries: SessionEntry[], leafId?: string | null, byId?: Map<string, SessionEntry>,): SessionEntry[] { const path = buildSessionPath(entries, leafId, byId); let compaction: CompactionEntry | null = null; for (const entry of path) { if (entry.type === "compaction") { compaction = entry; } } if (!compaction) { return path; } const compactionIdx = path.findIndex((entry) => entry.id === compaction.id); if (compactionIdx < 0) { return path; } const contextEntries: SessionEntry[] = [compaction]; let foundFirstKept = false; for (let i = 0; i < compactionIdx; i++) { const entry = path[i]; if (entry.id === compaction.firstKeptEntryId) { foundFirstKept = true; } if (foundFirstKept && !(entry.type === "message" && entry.message.role === "system")) { contextEntries.push(entry); } } contextEntries.push(...path.slice(compactionIdx + 1)); return contextEntries;}firstKeptEntryId tới trước compaction (bỏ message system), rồi mọi entry sau compaction. Message system trong phần giữ lại bị bỏ vì ảnh chụp systemMessage của compaction đã chứa trạng thái prompt đầy đủ tại điểm đó. Entry không phải message (nhãn, model_change...) vẫn được giữ để interactive mode vẽ được.Bước 3: đổi entry thành message#
/** * Project one selected session entry into LLM/runtime messages. * Plain custom entries are display/state entries and do not participate in context. */export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] { if (entry.type === "message") { const message = entry.message; // Session files are parsed without validation; old versions, forks, or // hand-edited files can contain messages with null/missing content. if (message.role === "system" && message.content == null) return [{ ...message, content: "" }]; if ( (message.role === "user" || message.role === "assistant" || message.role === "toolResult") && message.content == null ) { return [{ ...message, content: [] }]; } return [message]; } if (entry.type === "custom_message") { return [ createCustomMessage(entry.customType, entry.content ?? [], entry.display, entry.details, entry.timestamp), ]; } if (entry.type === "branch_summary" && entry.summary) { return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)]; } if (entry.type === "compaction") { const summary = createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); return entry.systemMessage ? [entry.systemMessage, summary] : [summary]; } return [];}null (từ bản cũ, từ fork, từ tệp sửa tay) được chuẩn hoá thành rỗng thay vì làm vỡ yêu cầu. Compaction cho ra hai message, ảnh chụp system rồi tóm tắt; entry compaction cũ không có ảnh chụp thì chỉ cho ra tóm tắt.Bước 4: áp dụng context edit, giữ nguồn gốc#
/** Build provenance-preserving, compaction-aware model context. */export function buildSessionProjection( entries: SessionEntry[], leafId?: string | null, byId?: Map<string, SessionEntry>,): SessionProjection { const path = buildSessionPath(entries, leafId, byId); const { thinkingLevel, model } = getSessionContextSettings(path); const contextEntries = buildContextEntries(entries, leafId, byId); const edits = new Map<string, ContextEditEntry>(); for (const entry of contextEntries) { if (entry.type === "context_edit") edits.set(entry.targetId, entry); } const projectedEntries = contextEntries.map( (sourceEntry, index): ProjectedSessionEntry => ({ sourceEntry, // buildContextEntries() may retain an older compaction entry because its // raw ID lies inside the newest retained range. Only the newest compaction // at index zero contributes a checkpoint and summary. messages: sourceEntry.type === "compaction" && index > 0 ? [] : projectContextEntry(sourceEntry, edits.get(sourceEntry.id)), }), ); return { entries: projectedEntries, messages: projectedEntries.flatMap((entry) => entry.messages), thinkingLevel, model, };}edits là một Map ghi đè theo thứ tự nhánh, nên với cùng một đích, edit sau cùng thắng. Edit chỉ có hiệu lực khi nó nằm trên nhánh đang hoạt động: điều hướng về trước nó thì đích hiện lại nguyên bản (docs/session-format.md:147). Một compaction cũ hơn có thể lọt vào phần được giữ; nó không đóng góp message nào.Việc thay nội dung nằm trong projectContextEntry(): replacement: null cho mảng rỗng; nội dung mới thay content nhưng giữ role và metadata, và chuỗi được bọc thành một khối text cho assistant, toolResult vì hai role này đòi mảng (session-manager.ts:519). Mỗi ProjectedSessionEntry giữ sourceEntry: AgentSession dùng nó để biết message nào ứng với entry nào khi cần ghi một context edit hay báo id entry cho extension. buildSessionContext() chỉ bỏ phần nguồn gốc đó đi (session-manager.ts:575).
Ví dụ: từ cây entry đến danh sách message#
Ví dụ minh hoạ, đủ mọi loại entry có ảnh hưởng tới ngữ cảnh. Người dùng nhờ viết hàm parseDate, rồi nhờ thêm test bằng vitest; nhánh đó thất bại vì vitest chưa được cài. Họ dùng /tree quay về sau câu trả lời A2, chọn tóm tắt nhánh vừa rời, và yêu cầu viết test bằng node:test. Một extension todo gửi một custom message cuối lượt. Ngữ cảnh vượt ngưỡng nên compaction tự động chạy, giữ lại từ U3. Ở prompt sau, provider trả "overloaded" một lần, retry thành công.
M0 model_change anthropic / claude-sonnet-4-5
└─ T0 thinking_level_change medium
└─ S0 message (system) mọi phần prompt, toolsAdded: read, bash, edit, write
└─ U1 message (user) "Viết hàm parseDate trong src/date.ts"
└─ A1 message (assistant) toolCall write
└─ R1 message (toolResult)
└─ A2 message (assistant) "Đã viết xong parseDate."
├─ U2 message (user) "Thêm test bằng vitest" ┐
│ └─ A3 ─ R2 ─ A4 (gọi bash, vitest chưa cài, thất bại) ┘ nhánh bị bỏ
└─ BS branch_summary fromId = A4
└─ U3 message (user) "Viết test bằng node:test"
└─ A5 message (assistant) "Đã thêm test/date.test.ts"
└─ CM custom_message customType "todo", display true
└─ C compaction firstKeptEntryId = U3, có systemMessage
└─ U4 message (user) "Chạy lại toàn bộ test"
└─ AE message (assistant) stopReason "error", "overloaded"
└─ CE context_edit targetId = AE, replacement null
└─ A6 message (assistant) "12 test đều qua." ◄ lá
bash, kết quả tool, assistant báo lỗi.Lần theo bốn bước với lá là A6:
- Đường điA6 → CE → AE → U4 → C → CM → A5 → U3 → BS → A2 → R1 → A1 → U1 → S0 → T0 → M0, đảo lại. U2, A3, R2, A4 không nằm trên đường nên không bao giờ được xét.
- Thiết lậpMức suy nghĩ
mediumtừ T0; mô hình từ message assistant cuối trên đường, A6. - CompactionC là compaction duy nhất. Context entries = [C, U3, A5, CM] + [U4, AE, CE, A6]. M0 tới BS đứng trước
firstKeptEntryIdnên bị thay bằng tóm tắt của C, kể cả bản tóm tắt nhánh BS. - Context editCE nhắm AE với
replacement: null, nên AE không cho ra message nào; bản thân CE cũng không.
| # | Entry nguồn | Message trong projection | Gửi provider (sau convertToLlm) |
|---|---|---|---|
| 1 | C | system: ảnh chụp prompt và tool tại lúc nén | system |
| 2 | C | compactionSummary | user: "The conversation history before this point was compacted into the following summary:" + <summary>…</summary> |
| 3 | U3 | user | user |
| 4 | A5 | assistant | assistant |
| 5 | CM | custom (customType: "todo") | user với nội dung của CM; details không được gửi |
| 6 | U4 | user | user |
| 7 | A6 | assistant | assistant |
Prompt kế tiếp của người dùng sẽ thành entry con của A6 và message thứ 8. Bây giờ đổi lá: nếu người dùng dùng /tree chọn A4, đường đi là M0 … A2 → U2 → A3 → R2 → A4, không có compaction và context edit nào trên đó, nên projection là S0, U1, A1, R1, A2, U2, A3, R2, A4 nguyên vẹn. Cùng một tệp, hai lá, hai ngữ cảnh hoàn toàn độc lập; compaction C và edit CE chỉ thuộc về nhánh của chúng.
Rẽ nhánh trong cùng tệp: /tree#
Pi có ba cách quay lại một điểm cũ (docs/sessions.md:20): /tree đi trong cùng tệp, /fork và /clone tạo tệp mới. Armin Ronacher mô tả cây session như chỗ cho các "side quest": rẽ sang một việc phụ rồi quay lại nhánh chính mà không làm bẩn ngữ cảnh của nó ("Pi: The Minimal Agent Within OpenClaw"). Ở tầng tệp, rẽ nhánh chỉ là dời con trỏ:
/** * 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;}resetLeaf() dùng khi message được chọn để sửa lại chính là gốc của cây (chẳng hạn message user đầu tiên của một phiên cũ không có entry thiết lập): entry kế tiếp sẽ là một gốc mới.branchWithSummary( branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean, usage?: Usage,): string { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } const fromId = this.leafId ?? "root"; this.leafId = branchFromId; const entry: BranchSummaryEntry = { type: "branch_summary", id: generateId(this.byId), parentId: branchFromId, timestamp: new Date().toISOString(), fromId, summary, details, usage, fromHook, }; this._appendEntry(entry); return entry.id;}fromId lấy lá cũ trước khi dời (một lỗi trước đây ghi nhầm điểm đến, CHANGELOG.md:869). Entry tóm tắt trở thành lá mới, nên prompt kế tiếp là con của nó và mô hình đọc tóm tắt ngay trước prompt đó.AgentSession.navigateTree() là phần điều phối mà lệnh /tree gọi. Nó từ chối khi agent đang chạy hoặc đang nén (agent-session.ts:4028), gom các entry của nhánh cũ từ lá cũ tới tổ tiên chung để tóm tắt (branch-summarization.ts:108), cho extension huỷ hoặc tự cung cấp tóm tắt qua session_before_tree, rồi gọi bộ tóm tắt mặc định nếu người dùng chọn tóm tắt. Cách viết tóm tắt thuộc chương Compaction. Phần quyết định lá mới:
// Determine the new leaf position based on target typelet newLeafId: string | null;let editorText: string | undefined;if (targetEntry.type === "message" && targetEntry.message.role === "user") { // User message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; editorText = contentText(targetEntry.message.content, "");} else if (targetEntry.type === "custom_message") { // Custom message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; editorText = contentText(targetEntry.content, "");} else { // Non-user message: leaf = selected node newLeafId = targetId;}user (hay custom_message) nghĩa là "tôi muốn sửa câu này": lá dời về cha của nó và văn bản được đặt lại vào trình soạn thảo. Chọn một entry khác (một câu trả lời assistant chẳng hạn) nghĩa là "tiếp tục sau entry này": lá là chính nó.// Switch leaf (with or without summary)// Summary is attached at the navigation target position (newLeafId), not the old branchlet summaryEntry: BranchSummaryEntry | undefined;if (summaryText) { // Create summary at target position (can be null for root) const summaryId = this.sessionManager.branchWithSummary( newLeafId, summaryText, summaryDetails, fromExtension, summaryUsage, ); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; // Attach label to the summary entry if (label) { this.sessionManager.appendLabelChange(summaryId, label); }} else if (newLeafId === null) { // No summary, navigating to root - reset leaf this.sessionManager.resetLeaf();} else { // No summary, navigating to non-root this.sessionManager.branch(newLeafId);}// Attach label to target entry when not summarizing (no summary entry to label)if (label && !summaryText) { this.sessionManager.appendLabelChange(targetId, label);}session_tree cho extension (agent-session.ts:4196).Bộ chọn của /tree vẽ từ getTree(): một bản sao nông của toàn bộ cây, con của mỗi nút sắp theo thời gian (cũ trên, mới dưới), entry mồ côi có cha không tồn tại được coi là gốc, và mỗi nút mang nhãn hiện hành (session-manager.ts:1524). Cách vẽ, lọc và phím tắt thuộc chương pi-tui.
Fork và clone sang tệp mới#
Khi một hướng thử đáng thành một công việc riêng, cây trong một tệp trở nên chật. /fork (tách nhánh) tạo một phiên mới kết thúc ngay trước một message user đã chọn, đặt văn bản của message đó vào trình soạn thảo; /clone sao chép nhánh đang hoạt động tới lá hiện tại. Cả hai đi qua cùng một hàm, khác nhau ở position:
async fork( entryId: string, options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },): Promise<{ cancelled: boolean; selectedText?: string }> { const position = options?.position ?? "before"; const beforeResult = await this.emitBeforeFork(entryId, { position }); if (beforeResult.cancelled) { return { cancelled: true }; } let targetLeafId: string | null; let selectedText: string | undefined; const selectedEntry = this.session.sessionManager.getEntry(entryId); if (!selectedEntry) { throw new Error("Invalid entry ID for forking"); } if (position === "at") { targetLeafId = selectedEntry.id; } else { if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") { throw new Error("Invalid entry ID for forking"); } targetLeafId = selectedEntry.parentId; selectedText = extractUserMessageText(selectedEntry.message.content); }fork(entryId) từ bộ chọn message user cho /fork, và fork(leafId, { position: "at" }) cho /clone (interactive-mode.ts:5511). Bộ chọn của /fork liệt kê message user của mọi nhánh, không chỉ nhánh đang hoạt động (agent-session.ts:4221).Với phiên được lưu, phần còn lại (agent-session-runtime.ts:289) có hai nhánh. Nếu message user được chọn là gốc của cây (parentId: null, chẳng hạn trong một phiên cũ), không có gì để chép: một phiên rỗng được tạo với parentSession trỏ về tệp cũ. Ở phiên tạo bằng v1.4.0, message user đầu tiên luôn có cha (các entry thiết lập và message system), nên fork tại đó cho ra một phiên chỉ chứa các entry thiết lập. Còn lại, một SessionManager mới mở lại tệp hiện tại và gọi createBranchedSession(). Phiên chưa từng được lưu (chưa có message nào) thì không fork được.
createBranchedSession(leafId: string): string | undefined { const previousSessionFile = this.sessionFile; 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. // Because labels are real tree entries, later entries can be children of labels; // removing labels requires re-chaining the retained path to avoid orphaned subtrees. const pathWithoutLabels: SessionEntry[] = []; const replacementByLabelId = new Map<string, string>(); const pendingLabelIds: string[] = []; let pathParentId: string | null = null; for (const entry of path) { if (entry.type === "label") { pendingLabelIds.push(entry.id); continue; } for (const labelId of pendingLabelIds) { replacementByLabelId.set(labelId, entry.id); } pendingLabelIds.length = 0; pathWithoutLabels.push( entry.type === "compaction" ? { ...entry, parentId: pathParentId, firstKeptEntryId: entry.firstKeptEntryId === entry.id ? entry.id : (replacementByLabelId.get(entry.firstKeptEntryId) ?? entry.firstKeptEntryId), } : { ...entry, parentId: pathParentId }, ); pathParentId = entry.id; }label là nút thật của cây, nên bỏ chúng đi sẽ để lại entry mồ côi; vòng lặp nối lại parentId của mỗi entry giữ lại vào entry giữ lại liền trước, và sửa firstKeptEntryId của compaction nếu nó từng trỏ vào một nhãn.Phần sau của hàm (session-manager.ts:1670) tạo header mới với id mới và parentSession là tệp cũ, tạo lại các nhãn còn hiệu lực dưới dạng một chuỗi entry label ở cuối, giữ nguyên timestamp của nhãn, và áp dụng cùng quy tắc tạo tệp trễ như _persist(). Các entry được giữ nguyên id, nên một id trong phiên con trỏ tới đúng nội dung như trong phiên mẹ.
--fork: chép cả tệp sang dự án hiện tại#
Cờ --fork <đường dẫn|id> dùng một hàm khác, SessionManager.forkFrom(), và chép mọi entry, cả các nhánh không hoạt động:
// Create new session file with new ID but forked contentif (options?.id !== undefined) { assertValidSessionId(options.id);}const newSessionId = options?.id ?? createSessionId();const timestamp = new Date().toISOString();const fileTimestamp = timestamp.replace(/[:.]/g, "-");const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);// Write new header pointing to source as parent, with updated cwdconst newHeader: SessionHeader = { type: "session", version: CURRENT_SESSION_VERSION, id: newSessionId, timestamp, cwd: resolvedTargetCwd, parentSession: resolvedSourcePath,};writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });// Copy all non-header entries from sourcefor (const entry of sourceEntries) { if (entry.type !== "session") { appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); }}return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);cwd trong header là thư mục đích, nên phiên mới thuộc về dự án hiện tại. --session trỏ tới một phiên của dự án khác cũng đi đường này, sau khi hỏi người dùng có muốn fork vào thư mục hiện tại không (main.ts:399).Trong cả ba trường hợp, header của tệp con có parentSession (docs/session-format.md:72); bộ chọn session dùng nó để vẽ các phiên thành cây theo quan hệ cha con (session-selector.ts:206).
Nhãn và tên phiên#
Nhãn là dấu trang trên một entry ("checkpoint-1", "trước refactor"), để tìm lại điểm đó trong /tree. Vì entry không được sửa, đặt nhãn không ghi vào entry đích mà ghi thêm một entry label trỏ tới nó:
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); this.labelTimestampsById.set(targetId, entry.timestamp); } else { this.labelsById.delete(targetId); this.labelTimestampsById.delete(targetId); } return entry.id;}label là con của lá như mọi entry khác, nên nó dời lá. Bảng labelsById giữ nhãn hiện hành của mỗi đích; nạp lại tệp thì _buildIndex() dựng lại bảng bằng cách phát lại mọi entry label.Nhãn có thể được đặt từ /tree, từ extension (pi.setLabel()), hoặc khi điều hướng cây kèm tuỳ chọn label: có tóm tắt thì nhãn gắn vào entry tóm tắt, không thì gắn vào entry đích (mục /tree). Nhãn không bao giờ vào ngữ cảnh.
Tên phiên đi theo cùng nguyên tắc ghi thêm, với entry session_info:
/** Append a session info entry (e.g., display name). Returns entry id. */appendSessionInfo(name: string): string { const sanitizedName = name.replace(/[\r\n]+/g, " ").trim(); const entry: SessionInfoEntry = { type: "session_info", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), name: sanitizedName, }; this._appendEntry(entry); return entry.id;}/** Get the current session name from the latest session_info entry, if any. */getSessionName(): string | undefined { // Walk entries in reverse to find the latest session_info entry. // Empty names explicitly clear the session title. Reads fileEntries directly: the footer // calls this on every frame, and getEntries() copies the whole session. for (let i = this.fileEntries.length - 1; i >= 0; i--) { const entry = this.fileEntries[i]; if (entry.type === "session_info") { return entry.name?.trim() || undefined; } } return undefined;}getSessionName() duyệt ngược mảng thay vì gọi getEntries(), vì footer gọi nó ở mỗi khung hình và getEntries() sao chép cả phiên.Tên được đặt bằng /name, cờ --name/-n lúc khởi động, hoặc pi.setSessionName() của extension (docs/session-format.md:199); AgentSession.setSessionName() phát thêm session_info_changed (agent-session.ts:4002). Bộ chọn /resume hiện tên thay cho message đầu tiên, và khi liệt kê, nó cũng lấy session_info cuối cùng, kể cả lần xoá tên (session-manager.ts:828). Cả tên lẫn nhãn đều tính trên toàn tệp chứ không theo nhánh, dù entry ghi chúng nằm trên một nhánh cụ thể: getSessionName() và _buildIndex() duyệt fileEntries, không duyệt getBranch(). Đổi tên ở một nhánh rồi /tree sang nhánh khác không làm tên cũ quay lại.
Liệt kê và mở lại phiên#
Lúc khởi động, createSessionManager() trong main.ts biến các cờ dòng lệnh thành đúng một SessionManager. Nó xét theo thứ tự cố định, cờ đầu tiên khớp thắng (main.ts:357):
| Cờ | Hành vi | Factory |
|---|---|---|
--no-session | Phiên trong bộ nhớ, không ghi tệp | SessionManager.inMemory() |
--fork <đường dẫn|id> | Chép phiên nguồn thành một phiên mới cho cwd hiện tại; có thể kèm --session-id cho id mới | SessionManager.forkFrom() |
--session <đường dẫn|id> | Mở phiên đó; nếu nó thuộc dự án khác thì hỏi có fork vào thư mục hiện tại không | open() hoặc forkFrom() |
--resume, -r | Mở bộ chọn, phiên của dự án hiện tại trước, có thể xem mọi dự án | list(), listAll(), rồi open() |
--continue, -c | Mở phiên mới nhất của cwd, hoặc tạo phiên mới nếu chưa có | SessionManager.continueRecent() |
--session-id <id> | Mở phiên có đúng id đó trong dự án, hoặc tạo phiên mới mang id đó kèm cảnh báo | findById(), rồi open() hoặc create() |
| (không cờ) | Phiên mới | SessionManager.create() |
Các tổ hợp mâu thuẫn bị chặn trước đó: --fork không đi cùng --session, --continue, --resume, --no-session; --session-id không đi cùng --session, --continue, --resume (main.ts:299, docs/cli.md:86). Đối số của --session và --fork có thể là đường dẫn hoặc id, kể cả tiền tố id:
async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise<ResolvedSession> { // If it looks like a file path, resolve it before handing it to the session manager. if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) { return { type: "path", path: resolvePath(sessionArg, cwd) }; } // Exact IDs only require reading session headers. Fall back to the full // metadata listing for prefix matches. const exactLocalMatch = findLocalSessionByExactId(sessionArg, cwd, sessionDir); if (exactLocalMatch) { return exactLocalMatch; } const localSessions = await SessionManager.list(cwd, sessionDir); const localMatch = localSessions.find((s) => s.id.startsWith(sessionArg)); if (localMatch) { return { type: "local", path: localMatch.path }; } // Try global search across all projects const allSessions = await SessionManager.listAll(sessionDir); const globalMatch = allSessions.find((s) => s.id === sessionArg) ?? allSessions.find((s) => s.id.startsWith(sessionArg)); if (globalMatch) { return { type: "global", path: globalMatch.path, cwd: globalMatch.cwd }; } // Not found anywhere return { type: "not_found", arg: sessionArg };}/, \ hay đuôi .jsonl thì coi là đường dẫn. Id chính xác chỉ cần đọc header của từng tệp; tiền tố id mới cần liệt kê đầy đủ. Không tìm thấy trong dự án hiện tại thì tìm trong mọi dự án, và kết quả global dẫn tới câu hỏi fork ở trên.--continue chọn theo thời điểm sửa tệp (mtime), không theo tên tệp:
export function findMostRecentSession(sessionDir: string, cwd?: string): string | null { const resolvedSessionDir = normalizePath(sessionDir); const resolvedCwd = cwd ? resolvePath(cwd) : undefined; try { const files = readdirSync(resolvedSessionDir) .filter((file) => file.endsWith(".jsonl")) .map((file) => join(resolvedSessionDir, file)) .map((path) => ({ path, mtime: statSync(path).mtimeMs })) .sort((a, b) => b.mtime - a.mtime); for (const { path } of files) { const header = readSessionHeaderForDiscovery(path); if (header && (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(header), resolvedCwd))) return path; } return null; } catch { // Directory access and stat races make recent-session discovery unavailable. return null; }}readSessionHeaderForDiscovery() nuốt lỗi), nên một tệp hỏng không chặn việc tìm các phiên khác.Bộ chọn của --resume và /resume cần nhiều thông tin hơn: buildSessionInfo() đọc cả tệp theo dòng để lấy tên, số message, message đầu tiên và toàn văn để tìm kiếm (session-manager.ts:799). Thời điểm "sửa đổi" dùng để sắp xếp là timestamp của message user hay assistant mới nhất, không phải mtime của tệp, nên đổi tên hay gắn nhãn một phiên cũ không đưa nó lên đầu danh sách (session-manager.ts:856). list() chạy tối đa 10 lần đọc song song và báo tiến độ theo từng đợt để bộ chọn hiện dần; listAll() duyệt mọi thư mục con của ~/.pi/agent/sessions/ (session-manager.ts:1894). Xoá một phiên đơn giản là xoá tệp; bộ chọn có Ctrl+D và dùng lệnh trash khi có (docs/session-format.md:16).
Mở một tệp bằng SessionManager.open() chỉ đọc header trước, trong giới hạn 1 MB, để lấy cwd của phiên (session-manager.ts:1760). Cwd đó, chứ không phải thư mục bạn đang đứng, quyết định settings dự án, extension và trust của runtime. Nếu cwd ghi trong header không còn tồn tại (dự án đã bị xoá hay đổi tên), interactive mode hỏi có tiếp tục ở cwd hiện tại không; các chế độ khác thoát với lỗi (main.ts:697).
Xuất HTML và chia sẻ#
/export ghi phiên thành HTML hoặc JSONL; pi --export tệp.jsonl [đích.html] làm điều tương tự từ dòng lệnh mà không mở phiên (main.ts:640). Hai định dạng khác nhau ở một điểm quan trọng: HTML mang cả cây, JSONL chỉ mang nhánh đang hoạt động.
export async function exportSessionToHtml( sm: SessionManager, state?: AgentState, options?: ExportOptions | string,): Promise<string> { const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; const sessionFile = sm.getSessionFile(); if (!sessionFile) { throw new Error("Cannot export in-memory session to HTML"); } if (!existsSync(sessionFile)) { throw new Error("Nothing to export yet - start a conversation first"); } const entries = sm.getEntries(); const sessionData: SessionData = { header: sm.getHeader(), entries, leafId: sm.getLeafId(), systemPrompt: state?.systemPrompt, tools: state?.tools?.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })), renderedTools, }; const html = generateHtml(sessionData, opts.themeName);Trang xuất ra có thanh bên vẽ cây và đọc tham số leafId, targetId trên URL để mở đúng một nhánh và cuộn tới một entry (template.js:21). Bản xuất JSONL thì dựng một header mới (không có parentSession) và nối lại parentId dọc theo nhánh (session-export.ts:8), nên nó là một phiên tuyến tính mở được bằng /import hay --session.
/share tải phiên lên để lấy một đường link:
export async function shareSession(context: SessionShareContext): Promise<void> { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-share-")); const jsonlFile = path.join(tempDir, "session.jsonl"); const htmlFile = path.join(tempDir, "session.html"); try { try { exportSessionForShare(jsonlFile, context.session); } catch (error: unknown) { context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); return; } const destination = context.session.settingsManager.getSessionShareDestination(); if (destination !== "gist") { if (await tryShareViaRadius(jsonlFile, context)) return; if (destination === "radius") { context.showError("Radius authentication is not configured. Run '/login radius' first."); return; } } try { await context.session.exportToHtml(htmlFile, { themeName: theme.name }); } catch (error: unknown) { context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); return; } await shareViaGist(htmlFile, context);custom cuối với customType: "pi.share" chứa system prompt và schema tool cho trang xem (session-share.ts:24). Đường gist tạo một gist riêng tư từ bản xuất HTML bằng gh gist create --public=false, và link xem có dạng https://pi.dev/session/#<gistId> (config.ts:594).Quyết định thiết kế#
Một tệp, chỉ ghi thêm, một dòng mỗi entry#
Phương án khác là một cơ sở dữ liệu, hay một tệp cho mỗi nhánh. JSONL chỉ ghi thêm thắng ở ba điểm thực tế. An toàn khi sập: mỗi lần ghi là một dòng; pi bị giết giữa chừng thì tệp mất tối đa dòng cuối, và lần nạp sau sửa ký tự xuống dòng còn thiếu. Không mất lịch sử: rẽ nhánh, nén, sửa ngữ cảnh đều là dòng mới, nên mọi thứ từng xảy ra vẫn đọc được, thống kê chi phí cộng được trên toàn bộ. Dễ dùng bằng công cụ thường: grep, jq, diff đều chạy được trên tệp, và định dạng được tài liệu hoá để chương trình khác đọc. Cái giá: tệp chỉ lớn lên, mở một phiên phải đọc cả tệp (nên mới đọc theo khối), và mỗi yêu cầu phải đi lại đường từ lá về gốc.
Cây trong cùng tệp#
Rẽ nhánh tại chỗ có từ 0.31.0 (CHANGELOG.md:5152). Giữ mọi nhánh trong một tệp nghĩa là quay lại một hướng đã bỏ chỉ là dời lá, không cần tìm tệp; và bản tóm tắt nhánh nối hai hướng thử bằng chính một entry của cây. Khi một nhánh đáng tách riêng, /fork và /clone đưa nó sang tệp mới, giữ nguyên id entry và ghi parentSession để vẫn lần ra được nguồn gốc.
Mọi thay đổi trạng thái đều là entry#
Mô hình, mức suy nghĩ, tên, nhãn không phải trường của header mà là entry. Lý do: chúng đổi giữa phiên và có thể khác nhau giữa các nhánh. Resume chỉ cần đọc giá trị mới nhất trên đường đi. Cũng vì vậy mà compaction mang theo ảnh chụp systemMessage: phần ngữ cảnh giữ lại không cần các message system cũ, vì trạng thái prompt tại điểm nén đã được gộp vào ảnh chụp (docs/session-format.md:248).
Id ngắn, duy nhất trong một tệp#
Tám ký tự hex đủ để không trùng trong một phiên (có kiểm tra), ngắn hơn nhiều so với UUID khi mỗi dòng mang hai id (id, parentId) và nhiều entry còn trỏ thêm (targetId, firstKeptEntryId, fromId). Id chỉ cần duy nhất trong tệp; id toàn cục là id phiên trong header.
Cạm bẫy và trường hợp biên#
- Nhãn dời lá. Entry
labellà con của lá hiện tại như mọi entry khác, nên nó nằm trên nhánh đang hoạt động và entry kế tiếp sẽ là con của nó. Nó không sinh message, nhưng công cụ nào đi theoparentIdphải tính tới nó, nhưcreateBranchedSession()phải nối lại cây khi bỏ nhãn. - Dòng hỏng cắt đứt lịch sử. Dòng không parse được bị bỏ lặng lẽ. Nếu đó là một entry giữa nhánh, entry con của nó có
parentIdtrỏ vào hư không:buildSessionPath()dừng ở đó, nên mọi thứ trước điểm hỏng biến khỏi ngữ cảnh mà không báo lỗi, còngetTree()coi entry con là một gốc mồ côi (session-manager.ts:1544). Sửa tệp bằng tay cần cẩn thận. - Tiền tố id chọn phiên gần nhất.
--session abkhông báo mơ hồ khi nhiều phiên có id bắt đầu bằngab; nó lấy phiên đầu tiên trong danh sách đã sắp theo hoạt động gần nhất (main.ts:266). - Compaction không giữ gì.
firstKeptEntryIdbằng chính id của compaction nghĩa là không giữ entry nào phía trước (session-manager.ts:1278, docs/session-format.md:131). customkhông vào ngữ cảnh. Extension muốn mô hình đọc thứ gì phải dùngcustom_message;customchỉ để lưu trạng thái.- Hai loại timestamp. Entry dùng chuỗi ISO, message bên trong dùng số mili giây. Thứ tự hai loại có thể ngược nhau, như message
systemvàuserở ví dụ JSONL. getEntries()sao chép. Nó lọc toàn bộfileEntriesmỗi lần gọi; mã gọi thường xuyên nên tránh, nhưgetSessionName()đã làm (session-manager.ts:1320).- Phiên trong bộ nhớ không xuất HTML được (index.ts:247), và phiên mới chưa có message nào thì chưa có tệp để xuất hay fork.
- Có thể có nhiều gốc.
resetLeaf()vàbranchWithSummary(null, ...)làm entry kế tiếp thành một gốc mới (docs/session-format.md:211); công cụ đọc tệp không nên giả định chỉ có mộtparentId: null.
Đọc code ở đâu#
| # | Tệp và vùng | Đọc để hiểu |
|---|---|---|
| 1 | packages/coding-agent/docs/session-format.md, packages/coding-agent/docs/sessions.md | Định dạng và cách dùng, trước khi đọc code. |
| 2 | session-manager.ts:41 | Header, mười một loại entry, projection, SessionInfo. |
| 3 | session-manager.ts:976 | Constructor, mở tệp, phiên mới, chỉ mục, tạo tệp trễ, appendMessage(). |
| 4 | session-manager.ts:1216 | Các hàm append*() còn lại, kể cả appendCompaction() và appendContextEdit(). |
| 5 | session-manager.ts:390 | Dựng ngữ cảnh: đường đi, compaction, entry thành message, context edit. |
| 6 | session-manager.ts:1400 | Lá, con, nhãn, getBranch(), getTree(). |
| 7 | session-manager.ts:1569 | branch(), branchWithSummary(), createBranchedSession(). |
| 8 | session-manager.ts:1750 | Factory: create, open, continueRecent, inMemory, forkFrom, findById, list, listAll. |
| 9 | session-manager.ts:585 | Thư mục theo cwd, đọc tệp theo khối, đọc header có giới hạn, thông tin cho bộ chọn. |
| 10 | session-manager.ts:286, packages/coding-agent/src/migrations.ts | Migration định dạng và migration lúc khởi động. |
| 11 | agent-session.ts:4009 | navigateTree() cho /tree; danh sách message để fork. |
| 12 | agent-session-runtime.ts:262 | /fork và /clone. |
| 13 | main.ts:232 | Cờ dòng lệnh thành SessionManager. |
| 14 | packages/coding-agent/src/core/session-export.ts, packages/coding-agent/src/core/export-html/index.ts, packages/coding-agent/src/modes/interactive/session-share.ts | Xuất JSONL, xuất HTML, /share. |
Tóm tắt#
- Một session là một tệp JSONL dưới
~/.pi/agent/sessions/--cwd--/: một dòng header, rồi các entry chỉ ghi thêm, nối thành cây bằngid/parentId. - Có mười một loại entry; bốn loại ảnh hưởng tới ngữ cảnh (
message,compaction,branch_summary,custom_message), một loại sửa ngữ cảnh (context_edit), sáu loại còn lại chỉ mang trạng thái hay metadata. - Lá là vị trí hiện tại; entry mới luôn là con của lá. Ngữ cảnh là đường đi từ gốc tới lá, áp dụng compaction mới nhất và context edit mới nhất cho mỗi đích, được tính lại trước mỗi yêu cầu.
- Tệp chỉ được tạo khi có message
userđầu tiên; migration v1 → v2 → v3 là lần duy nhất tệp bị viết lại. /treedời lá trong cùng tệp, có thể kèm tóm tắt nhánh vừa rời;/forkvà/clonechép một đường đi sang tệp mới;--forkchép cả tệp sang dự án hiện tại.- Nhãn và tên phiên cũng là entry, tính trên toàn tệp. Xuất HTML mang cả cây; xuất JSONL và chia sẻ qua Radius chỉ mang nhánh đang hoạt động.