Update CHANGELOG.md and docs for session tree release

CHANGELOG.md:
- Add /tree command, context event, before_agent_start event
- Add ui.custom(), branch summarization, selectedBg theme color
- Add snake game example hook
- Add external contributions: CRLF fix, bash on Unix, clickable OAuth, error messages
- Update theme requirements (50 total colors)

session.md:
- Complete rewrite for v2 tree structure
- Document all entry types with examples
- Add SessionManager API reference

hooks.md:
- Replace pi.send() with pi.sendMessage()
- Add pi.appendEntry(), pi.registerCommand(), pi.registerMessageRenderer()
- Move exec() from ctx to pi.exec()
- Add ui.custom() for custom TUI components
- Add context and before_agent_start events
- Update before_compact event fields
- Add ctx.sessionManager and ctx.modelRegistry
This commit is contained in:
Mario Zechner 2025-12-31 00:39:20 +01:00
parent a4b36e3d63
commit a9479458ee
4 changed files with 504 additions and 57 deletions

View file

@ -42,11 +42,18 @@
- Handler types renamed: `SendHandler``SendMessageHandler`, new `AppendEntryHandler`
- **SessionManager**:
- `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions)
- **Themes**: Custom themes must add `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens
- **Themes**: Custom themes must add `selectedBg`, `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens (50 total)
### Added
- **`/tree` command**: Navigate the session tree in-place. Shows full tree structure with labels, supports search (type to filter), page navigation (←/→), and filter modes (Ctrl+O cycles: default → no-tools → user-only → labeled-only → all, Shift+Ctrl+O cycles backwards). Selecting a branch generates a summary and switches context. Press `l` to label entries.
- **`context` hook event**: Fires before each LLM call, allowing hooks to non-destructively modify messages. Returns `{ messages }` to override. Useful for dynamic context pruning without modifying session history.
- **`before_agent_start` hook event**: Fires once when user submits a prompt, before `agent_start`. Hooks can return `{ message }` to inject a `CustomMessageEntry` that gets persisted and sent to the LLM.
- **`ui.custom()` for hooks**: Show arbitrary TUI components with keyboard focus. Call `done()` when finished: `ctx.ui.custom(component, done)`.
- **Branch summarization**: When switching branches via `/tree`, generates a summary of the abandoned branch including file operations (read/modified files). Summaries are stored as `BranchSummaryEntry` with cumulative file tracking in `details`.
- **`selectedBg` theme color**: Background color for selected/active lines in tree selector and other components.
- **`enabledModels` setting**: Configure whitelisted models in `settings.json` (same format as `--models` CLI flag). CLI `--models` takes precedence over the setting.
- **Snake game example hook**: Added `examples/hooks/snake.ts` demonstrating `ui.custom()`, `registerCommand()`, and session persistence.
### Changed
@ -61,7 +68,10 @@
### Fixed
- **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355))
- **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355) by [@Pratham-Dubey](https://github.com/Pratham-Dubey))
- **Use bash instead of sh on Unix**: Fixed shell commands using `/bin/sh` instead of `/bin/bash` on Unix systems. ([#328](https://github.com/badlogic/pi-mono/pull/328) by [@dnouri](https://github.com/dnouri))
- **OAuth login URL clickable**: Made OAuth login URLs clickable in terminal. ([#349](https://github.com/badlogic/pi-mono/pull/349) by [@Cursivez](https://github.com/Cursivez))
- **Improved error messages**: Better error messages when `apiKey` or `model` are missing. ([#346](https://github.com/badlogic/pi-mono/pull/346) by [@ronyrus](https://github.com/ronyrus))
- **Session file validation**: `findMostRecentSession()` now validates session headers before returning, preventing non-session JSONL files from being loaded
- **Compaction error handling**: `generateSummary()` and `generateTurnPrefixSummary()` now throw on LLM errors instead of returning empty strings

View file

@ -0,0 +1,64 @@
### Breaking Changes
- **Session tree structure (v2)**: Sessions now store entries as a tree with `id`/`parentId` fields, enabling in-place branching without creating new files. Existing v1 sessions are auto-migrated on load.
- **SessionManager API**:
- `saveXXX()` renamed to `appendXXX()` (e.g., `appendMessage`, `appendCompaction`)
- `branchInPlace()` renamed to `branch()`
- `reset()` renamed to `newSession()`
- `createBranchedSessionFromEntries(entries, index)` replaced with `createBranchedSession(leafId)`
- `saveCompaction(entry)` replaced with `appendCompaction(summary, firstKeptEntryId, tokensBefore)`
- `getEntries()` now excludes the session header (use `getHeader()` separately)
- New methods: `getTree()`, `getPath()`, `getLeafUuid()`, `getLeafEntry()`, `getEntry()`, `branchWithSummary()`
- New `appendCustomEntry(customType, data)` for hooks to store custom data (not in LLM context)
- New `appendCustomMessageEntry(customType, content, display, details?)` for hooks to inject messages into LLM context
- **Compaction API**:
- `CompactionEntry<T>` and `CompactionResult<T>` are now generic with optional `details?: T` for hook-specific data
- `compact()` now returns `CompactionResult` (`{ summary, firstKeptEntryId, tokensBefore, details? }`) instead of `CompactionEntry`
- `appendCompaction()` now accepts optional `details` parameter
- `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId`
- `prepareCompaction()` now returns `firstKeptEntryId` in its result
- **Hook types**:
- `SessionEventBase` no longer has `sessionManager`/`modelRegistry` - access them via `HookEventContext` instead
- `HookEventContext` now has `sessionManager` and `modelRegistry` (moved from events)
- `HookEventContext` no longer has `exec()` - use `pi.exec()` instead
- `HookCommandContext` no longer has `exec()` - use `pi.exec()` instead
- `before_compact` event passes `preparation: CompactionPreparation` and `previousCompactions: CompactionEntry[]` (newest first)
- `before_switch` event now has `targetSessionFile`, `switch` event has `previousSessionFile`
- Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`)
- Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction)
- **Hook API**:
- `pi.send(text, attachments?)` replaced with `pi.sendMessage(message, triggerTurn?)` which creates `CustomMessageEntry` instead of user messages
- New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context)
- New `pi.registerCommand(name, options)` to register custom slash commands
- New `pi.registerMessageRenderer(customType, renderer)` to register custom renderers for hook messages
- New `pi.exec(command, args, options?)` to execute shell commands (moved from `HookEventContext`/`HookCommandContext`)
- `HookMessageRenderer` type: `(message: HookMessage, options, theme) => Component | null`
- Renderers return inner content; the TUI wraps it in a styled Box
- New types: `HookMessage<T>`, `RegisteredCommand`, `HookCommandContext`
- Handler types renamed: `SendHandler``SendMessageHandler`, new `AppendEntryHandler`
- **SessionManager**:
- `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions)
- **Themes**: Custom themes must add `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens
### Added
- **`enabledModels` setting**: Configure whitelisted models in `settings.json` (same format as `--models` CLI flag). CLI `--models` takes precedence over the setting.
### Changed
- **Entry IDs**: Session entries now use short 8-character hex IDs instead of full UUIDs
- **API key priority**: `ANTHROPIC_OAUTH_TOKEN` now takes precedence over `ANTHROPIC_API_KEY`
- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry<T>` for hook state persistence, `CustomMessageEntry<T>` for hook-injected context messages, `LabelEntry` for user-defined bookmarks
- **Entry labels**: New `getLabel(id)` and `appendLabelChange(targetId, label)` methods for labeling entries. Labels are included in `SessionTreeNode` for UI/export.
- **TUI**: `CustomMessageEntry` renders with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors). Entries with `display: false` are hidden.
- **AgentSession**: New `sendHookMessage(message, triggerTurn?)` method for hooks to inject messages. Handles queuing during streaming, direct append when idle, and optional turn triggering.
- **HookMessage**: New message type with `role: "hookMessage"` for hook-injected messages in agent events. Use `isHookMessage(msg)` type guard to identify them. These are converted to user messages for LLM context via `messageTransformer`.
- **Agent.prompt()**: Now accepts `AppMessage` directly (in addition to `string, attachments?`) for custom message types like `HookMessage`.
### Fixed
- **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355))
- **Session file validation**: `findMostRecentSession()` now validates session headers before returning, preventing non-session JSONL files from being loaded
- **Compaction error handling**: `generateSummary()` and `generateTurnPrefixSummary()` now throw on LLM errors instead of returning empty strings

View file

@ -260,19 +260,31 @@ See [src/core/compaction.ts](../src/core/compaction.ts) for the full implementat
| Field | Description |
|-------|-------------|
| `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |
| `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
| `previousSummary` | Summary from the last compaction, if any. Include this in your summary to preserve accumulated context. |
| `messagesToSummarize` | Messages that will be summarized and discarded (from after last compaction to cut point). |
| `messagesToKeep` | Messages that will be kept verbatim after the summary (from cut point to end). |
| `tokensBefore` | Current context token count (why compaction triggered). |
| `preparation` | Compaction preparation with `firstKeptEntryId`, `messagesToSummarize`, `messagesToKeep`, `tokensBefore`, `isSplitTurn`. |
| `previousCompactions` | Array of previous `CompactionEntry` objects (newest first). Access summaries for accumulated context. |
| `model` | Model to use for summarization. |
| `resolveApiKey` | Function to resolve API key for any model: `await resolveApiKey(model)` |
| `customInstructions` | Optional focus for summary (from `/compact <instructions>`). |
| `signal` | AbortSignal for cancellation. Pass to LLM calls and check periodically. |
Access session entries via `ctx.sessionManager.getEntries()` and API keys via `ctx.modelRegistry.getApiKey(model)`.
Custom compaction hooks should honor the abort signal by passing it to `complete()` calls. This allows users to cancel compaction (e.g., via Ctrl+C during `/compact`).
**Returning custom compaction:**
```typescript
return {
compaction: {
summary: "Your summary...",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { /* optional hook-specific data */ },
}
};
```
The `details` field persists hook-specific metadata (e.g., artifact index, version markers) in the compaction entry.
See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example.
**After compaction (`compact` event):**
@ -437,6 +449,61 @@ export default function (pi: HookAPI) {
**Note:** If you modify `content`, you should also update `details` accordingly. The TUI uses `details` (e.g., truncation info) for rendering, so inconsistent values will cause display issues.
### context
Fired before each LLM call, allowing non-destructive message modification. The original session is not modified.
```typescript
pi.on("context", async (event, ctx) => {
// event.messages: AgentMessage[] (deep copy, safe to modify)
// Return modified messages, or undefined to keep original
return { messages: modifiedMessages };
});
```
Use case: Dynamic context pruning without modifying session history.
```typescript
export default function (pi: HookAPI) {
pi.on("context", async (event, ctx) => {
// Find all pruning decisions stored as custom entries
const entries = ctx.sessionManager.getEntries();
const pruningRules = entries
.filter(e => e.type === "custom" && e.customType === "prune-rules")
.flatMap(e => e.data as PruneRule[]);
// Apply pruning to messages (e.g., truncate old tool results)
const prunedMessages = applyPruning(event.messages, pruningRules);
return { messages: prunedMessages };
});
}
```
### before_agent_start
Fired once when user submits a prompt, before `agent_start`. Allows injecting a message that gets persisted.
```typescript
pi.on("before_agent_start", async (event, ctx) => {
// event.userMessage: the user's message
// Return a message to inject, or undefined to skip
return {
message: {
customType: "context-injection",
content: "Additional context...",
display: true, // Show in TUI
}
};
});
```
The injected message is:
- Persisted to session as a `CustomMessageEntry`
- Sent to the LLM as a user message
- Visible in TUI (if `display: true`)
## Context API
Every event handler receives a context object with these methods:
@ -480,23 +547,42 @@ ctx.ui.notify("Operation complete", "info");
ctx.ui.notify("Something went wrong", "error");
```
### ctx.exec(command, args, options?)
### ctx.ui.custom(component, done)
Execute a command and get the result. Supports cancellation via `AbortSignal` and timeout.
Show a custom TUI component with keyboard focus. Call `done()` when finished.
```typescript
const result = await ctx.exec("git", ["status"]);
// result.stdout: string
// result.stderr: string
// result.code: number
// result.killed?: boolean // True if killed by signal/timeout
import { Container, Text } from "@mariozechner/pi-tui";
// With timeout (5 seconds)
const result = await ctx.exec("slow-command", [], { timeout: 5000 });
const myComponent = new Container(0, 0, [
new Text("Custom UI - press ESC to close", 0, 0),
]);
// With abort signal
const controller = new AbortController();
const result = await ctx.exec("long-command", [], { signal: controller.signal });
ctx.ui.custom(myComponent, () => {
// Cleanup when component is dismissed
});
```
See `examples/hooks/snake.ts` for a complete example with keyboard handling.
### ctx.sessionManager
Access to the session manager for reading session state.
```typescript
const entries = ctx.sessionManager.getEntries();
const path = ctx.sessionManager.getPath();
const tree = ctx.sessionManager.getTree();
const label = ctx.sessionManager.getLabel(entryId);
```
### ctx.modelRegistry
Access to model registry for model discovery and API keys.
```typescript
const apiKey = ctx.modelRegistry.getApiKey(model);
const models = ctx.modelRegistry.getAvailableModels();
```
### ctx.cwd
@ -529,19 +615,152 @@ if (ctx.hasUI) {
}
```
## Sending Messages
## Hook API Methods
Hooks can inject messages into the agent session using `pi.send()`. This is useful for:
The `pi` object provides methods for interacting with the agent:
- Waking up the agent when an external event occurs (file change, CI result, etc.)
- Async debugging (inject debug output from other processes)
- Triggering agent actions from external systems
### pi.sendMessage(message, triggerTurn?)
Inject a message into the session. Creates a `CustomMessageEntry` (not a user message).
```typescript
pi.send(text: string, attachments?: Attachment[]): void
pi.sendMessage(message: HookMessage, triggerTurn?: boolean): void
// HookMessage structure:
interface HookMessage {
customType: string; // Your hook's identifier
content: string | (TextContent | ImageContent)[];
display: boolean; // true = show in TUI, false = hidden
details?: unknown; // Hook-specific metadata (not sent to LLM)
}
```
If the agent is currently streaming, the message is queued. Otherwise, a new agent loop starts immediately.
- If `triggerTurn` is true (default), starts an agent turn after injecting
- If streaming, message is queued until current turn ends
- Messages are persisted to session and sent to LLM as user messages
```typescript
pi.sendMessage({
customType: "my-hook",
content: "External trigger: build failed",
display: true,
}, true); // Trigger agent response
```
### pi.appendEntry(customType, data?)
Persist hook state to session. Does NOT participate in LLM context.
```typescript
pi.appendEntry(customType: string, data?: unknown): void
```
Use for storing state that survives session reload. Scan entries on reload to reconstruct state:
```typescript
pi.on("session", async (event, ctx) => {
if (event.reason === "start" || event.reason === "switch") {
const entries = ctx.sessionManager.getEntries();
for (const entry of entries) {
if (entry.type === "custom" && entry.customType === "my-hook") {
// Reconstruct state from entry.data
}
}
}
});
// Later, save state
pi.appendEntry("my-hook", { count: 42 });
```
### pi.registerCommand(name, options)
Register a custom slash command.
```typescript
pi.registerCommand(name: string, options: {
description?: string;
handler: (ctx: HookCommandContext) => Promise<void>;
}): void
```
The handler receives:
- `ctx.args`: Everything after `/commandname`
- `ctx.ui`: UI methods (select, confirm, input, notify, custom)
- `ctx.hasUI`: Whether interactive UI is available
- `ctx.cwd`: Current working directory
- `ctx.sessionManager`: Session access
- `ctx.modelRegistry`: Model access
```typescript
pi.registerCommand("stats", {
description: "Show session statistics",
handler: async (ctx) => {
const entries = ctx.sessionManager.getEntries();
const messages = entries.filter(e => e.type === "message").length;
ctx.ui.notify(`${messages} messages in session`, "info");
}
});
```
To prompt the LLM after a command, use `pi.sendMessage()` with `triggerTurn: true`.
### pi.registerMessageRenderer(customType, renderer)
Register a custom TUI renderer for `CustomMessageEntry` messages.
```typescript
pi.registerMessageRenderer(customType: string, renderer: HookMessageRenderer): void
type HookMessageRenderer = (
message: HookMessage,
options: { expanded: boolean; width: number },
theme: Theme
) => Component | null;
```
Return a TUI Component for the inner content. Pi wraps it in a styled box.
```typescript
import { Text } from "@mariozechner/pi-tui";
pi.registerMessageRenderer("my-hook", (message, options, theme) => {
return new Text(theme.fg("accent", `[MY-HOOK] ${message.content}`), 0, 0);
});
```
### pi.exec(command, args, options?)
Execute a shell command.
```typescript
const result = await pi.exec(command: string, args: string[], options?: {
signal?: AbortSignal;
timeout?: number;
}): Promise<ExecResult>;
interface ExecResult {
stdout: string;
stderr: string;
code: number;
killed?: boolean; // True if killed by signal/timeout
}
```
```typescript
const result = await pi.exec("git", ["status"]);
if (result.code === 0) {
console.log(result.stdout);
}
// With timeout
const result = await pi.exec("slow-command", [], { timeout: 5000 });
if (result.killed) {
console.log("Command timed out");
}
```
## Sending Messages (Examples)
### Example: File Watcher
@ -553,15 +772,18 @@ export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
if (event.reason !== "start") return;
// Watch a trigger file
const triggerFile = "/tmp/agent-trigger.txt";
fs.watch(triggerFile, () => {
try {
const content = fs.readFileSync(triggerFile, "utf-8").trim();
if (content) {
pi.send(`External trigger: ${content}`);
fs.writeFileSync(triggerFile, ""); // Clear after reading
pi.sendMessage({
customType: "file-trigger",
content: `External trigger: ${content}`,
display: true,
}, true);
fs.writeFileSync(triggerFile, "");
}
} catch {
// File might not exist yet
@ -573,8 +795,6 @@ export default function (pi: HookAPI) {
}
```
To trigger: `echo "Run the tests" > /tmp/agent-trigger.txt`
### Example: HTTP Webhook
```typescript
@ -589,7 +809,11 @@ export default function (pi: HookAPI) {
let body = "";
req.on("data", chunk => body += chunk);
req.on("end", () => {
pi.send(body || "Webhook triggered");
pi.sendMessage({
customType: "webhook",
content: body || "Webhook triggered",
display: true,
}, true);
res.writeHead(200);
res.end("OK");
});
@ -602,9 +826,7 @@ export default function (pi: HookAPI) {
}
```
To trigger: `curl -X POST http://localhost:3333 -d "CI build failed"`
**Note:** `pi.send()` is not supported in print mode (single-shot execution).
**Note:** `pi.sendMessage()` is not supported in print mode (single-shot execution).
## Examples

View file

@ -1,6 +1,6 @@
# Session File Format
Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field.
Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Session entries form a tree structure via `id`/`parentId` fields, enabling in-place branching without creating new files.
## File Location
@ -10,47 +10,66 @@ Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with
Where `<path>` is the working directory with `/` replaced by `-`.
## Session Version
Sessions have a version field in the header:
- **Version 1**: Linear entry sequence (legacy, auto-migrated on load)
- **Version 2**: Tree structure with `id`/`parentId` linking
Existing v1 sessions are automatically migrated to v2 when loaded.
## Type Definitions
- [`src/session-manager.ts`](../src/session-manager.ts) - Session entry types (`SessionHeader`, `SessionMessageEntry`, etc.)
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AppMessage`, `Attachment`, `ThinkingLevel`
- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Session entry types
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `Attachment`, `ThinkingLevel`
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `UserMessage`, `AssistantMessage`, `ToolResultMessage`, `Usage`, `ToolCall`
## Entry Base
All entries (except `SessionHeader`) extend `SessionEntryBase`:
```typescript
interface SessionEntryBase {
type: string;
id: string; // 8-char hex ID
parentId: string | null; // Parent entry ID (null for first entry)
timestamp: string; // ISO timestamp
}
```
## Entry Types
### SessionHeader
First line of the file. Defines session metadata.
First line of the file. Metadata only, not part of the tree (no `id`/`parentId`).
```json
{"type":"session","id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","provider":"anthropic","modelId":"claude-sonnet-4-5","thinkingLevel":"off"}
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
```
For branched sessions, includes the source session path:
For branched sessions (created via `/branch` command):
```json
{"type":"session","id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","provider":"anthropic","modelId":"claude-sonnet-4-5","thinkingLevel":"off","branchedFrom":"/path/to/original/session.jsonl"}
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","branchedFrom":"/path/to/original/session.jsonl"}
```
### SessionMessageEntry
A message in the conversation. The `message` field contains an `AppMessage` (see [rpc.md](./rpc.md#message-types)).
A message in the conversation. The `message` field contains an `AgentMessage`.
```json
{"type":"message","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello","timestamp":1733234567890}}
{"type":"message","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop","timestamp":1733234567891}}
{"type":"message","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false,"timestamp":1733234567900}}
{"type":"message","timestamp":"2024-12-03T14:00:04.000Z","message":{"role":"bashExecution","command":"ls -la","output":"total 48\n...","exitCode":0,"cancelled":false,"truncated":false,"timestamp":1733234567950}}
{"type":"message","id":"a1b2c3d4","parentId":"prev1234","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello"}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false}}
```
The `bashExecution` role is a custom message type for user-executed bash commands (via `!` in TUI or `bash` RPC command). See [rpc.md](./rpc.md#bashexecutionmessage) for the full schema.
### ModelChangeEntry
Emitted when the user switches models mid-session.
```json
{"type":"model_change","timestamp":"2024-12-03T14:05:00.000Z","provider":"openai","modelId":"gpt-4o"}
{"type":"model_change","id":"d4e5f6g7","parentId":"c3d4e5f6","timestamp":"2024-12-03T14:05:00.000Z","provider":"openai","modelId":"gpt-4o"}
```
### ThinkingLevelChangeEntry
@ -58,9 +77,92 @@ Emitted when the user switches models mid-session.
Emitted when the user changes the thinking/reasoning level.
```json
{"type":"thinking_level_change","timestamp":"2024-12-03T14:06:00.000Z","thinkingLevel":"high"}
{"type":"thinking_level_change","id":"e5f6g7h8","parentId":"d4e5f6g7","timestamp":"2024-12-03T14:06:00.000Z","thinkingLevel":"high"}
```
### CompactionEntry
Created when context is compacted. Stores a summary of earlier messages.
```json
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000}
```
Optional fields:
- `details`: Hook-specific data (e.g., ArtifactIndex for structured compaction)
- `fromHook`: `true` if generated by a hook, `false`/`undefined` if pi-generated
### BranchSummaryEntry
Created when switching branches via `/tree`. Captures context from the abandoned path.
```json
{"type":"branch_summary","id":"g7h8i9j0","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:15:00.000Z","fromId":"f6g7h8i9","summary":"Branch explored approach A..."}
```
Optional fields:
- `details`: File tracking data (`{ readFiles: string[], modifiedFiles: string[] }`)
- `fromHook`: `true` if generated by a hook
### CustomEntry
Hook state persistence. Does NOT participate in LLM context.
```json
{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-hook","data":{"count":42}}
```
Use `customType` to identify your hook's entries on reload.
### CustomMessageEntry
Hook-injected messages that DO participate in LLM context.
```json
{"type":"custom_message","id":"i9j0k1l2","parentId":"h8i9j0k1","timestamp":"2024-12-03T14:25:00.000Z","customType":"my-hook","content":"Injected context...","display":true}
```
Fields:
- `content`: String or `(TextContent | ImageContent)[]` (same as UserMessage)
- `display`: `true` = show in TUI with purple styling, `false` = hidden
- `details`: Optional hook-specific metadata (not sent to LLM)
### LabelEntry
User-defined bookmark/marker on an entry.
```json
{"type":"label","id":"j0k1l2m3","parentId":"i9j0k1l2","timestamp":"2024-12-03T14:30:00.000Z","targetId":"a1b2c3d4","label":"checkpoint-1"}
```
Set `label` to `undefined` to clear a label.
## Tree Structure
Entries form a tree:
- First entry has `parentId: null`
- Each subsequent entry points to its parent via `parentId`
- Branching creates new children from an earlier entry
- The "leaf" is the current position in the tree
```
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← current leaf
└─ [branch_summary] ─── [user msg] ← alternate branch
```
## Context Building
`buildSessionContext()` walks from the current leaf to the root, producing the message list for the LLM:
1. Collects all entries on the path
2. Extracts current model and thinking level settings
3. If a `CompactionEntry` is on the path:
- Emits the summary first
- Then messages from `firstKeptEntryId` to compaction
- Then messages after compaction
4. Converts `BranchSummaryEntry` and `CustomMessageEntry` to appropriate message formats
## Parsing Example
```typescript
@ -73,17 +175,66 @@ for (const line of lines) {
switch (entry.type) {
case "session":
console.log(`Session: ${entry.id}, Model: ${entry.provider}/${entry.modelId}`);
console.log(`Session v${entry.version ?? 1}: ${entry.id}`);
break;
case "message":
console.log(`${entry.message.role}: ${JSON.stringify(entry.message.content)}`);
console.log(`[${entry.id}] ${entry.message.role}: ${JSON.stringify(entry.message.content)}`);
break;
case "compaction":
console.log(`[${entry.id}] Compaction: ${entry.tokensBefore} tokens summarized`);
break;
case "branch_summary":
console.log(`[${entry.id}] Branch from ${entry.fromId}`);
break;
case "custom":
console.log(`[${entry.id}] Custom (${entry.customType}): ${JSON.stringify(entry.data)}`);
break;
case "custom_message":
console.log(`[${entry.id}] Hook message (${entry.customType}): ${entry.content}`);
break;
case "label":
console.log(`[${entry.id}] Label "${entry.label}" on ${entry.targetId}`);
break;
case "model_change":
console.log(`Switched to: ${entry.provider}/${entry.modelId}`);
console.log(`[${entry.id}] Model: ${entry.provider}/${entry.modelId}`);
break;
case "thinking_level_change":
console.log(`Thinking: ${entry.thinkingLevel}`);
console.log(`[${entry.id}] Thinking: ${entry.thinkingLevel}`);
break;
}
}
```
## SessionManager API
Key methods for working with sessions programmatically:
### Creation
- `SessionManager.create(cwd, sessionDir?)` - New session
- `SessionManager.open(path, sessionDir?)` - Open existing
- `SessionManager.continueRecent(cwd, sessionDir?)` - Continue most recent or create new
- `SessionManager.inMemory(cwd?)` - No file persistence
### Appending (all return entry ID)
- `appendMessage(message)` - Add message
- `appendThinkingLevelChange(level)` - Record thinking change
- `appendModelChange(provider, modelId)` - Record model change
- `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)` - Add compaction
- `appendCustomEntry(customType, data?)` - Hook state (not in context)
- `appendCustomMessageEntry(customType, content, display, details?)` - Hook message (in context)
- `appendLabelChange(targetId, label)` - Set/clear label
### Tree Navigation
- `getLeafId()` - Current position
- `getEntry(id)` - Get entry by ID
- `getPath(fromId?)` - Walk from entry to root
- `getTree()` - Get full tree structure
- `getChildren(parentId)` - Get direct children
- `getLabel(id)` - Get label for entry
- `branch(entryId)` - Move leaf to earlier entry
- `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary
### Context
- `buildSessionContext()` - Get messages for LLM
- `getEntries()` - All entries (excluding header)
- `getHeader()` - Session metadata