diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b2c064c8..ebcfbff6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/UNRELEASED_OLD.md b/packages/coding-agent/UNRELEASED_OLD.md new file mode 100644 index 00000000..3ac1728a --- /dev/null +++ b/packages/coding-agent/UNRELEASED_OLD.md @@ -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` and `CompactionResult` 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`, `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` for hook state persistence, `CustomMessageEntry` 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 + diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 2a07999c..b91d8965 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -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 `). | | `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 +``` + +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; + +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 diff --git a/packages/coding-agent/docs/session.md b/packages/coding-agent/docs/session.md index 7778eaef..2060d188 100644 --- a/packages/coding-agent/docs/session.md +++ b/packages/coding-agent/docs/session.md @@ -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 `` 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