Query pi agent session logs using Nushell's structured data pipeline.
Quick Start
# Load and view recent sessions
ls ~/.pi/agent/sessions/ | get name | first 5
# Query a specific session log
open ~/.pi/agent/sessions/<project-path>/<session-file>.jsonl --raw | lines | each { from json } | first 10
# Find all bash commands run
open-jsonl session.jsonl | where type == "message" | where message.role == "assistant"
Log Structure
Pi agent logs are stored as JSON Lines (.jsonl) files in ~/.pi/agent/sessions/<project-path>/. Each line is a JSON object.
Entry Types
session
Session metadata (first entry in each file).
{
"type": "session",
"version": 3,
"id": "5741eacc-682e-472b-b42a-6d5ebf0105cb",
"timestamp": "2026-04-10T19:12:00.750Z",
"cwd": "/home/knoopx/Projects/knoopx/pi"
}
Fields: version, id, timestamp, cwd
message
User, assistant, or tool messages.
User message:
{
"type": "message",
"id": "7bb32c72",
"parentId": "380e238c",
"timestamp": "2026-04-10T19:12:38.654Z",
"message": {
"role": "user",
"content": [{ "type": "text", "text": "verify skill..." }],
"timestamp": 1775848358650
}
}
Assistant message with tool call:
{
"type": "message",
"id": "cba9a752",
"parentId": "7bb32c72",
"timestamp": "2026-04-10T19:12:43.718Z",
"message": {
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "...",
"thinkingSignature": "reasoning_content"
},
{
"type": "toolCall",
"id": "LkmAH1oV...",
"name": "read",
"arguments": { "path": "..." }
}
],
"api": "openai-completions",
"provider": "local",
"model": "unsloth/Qwen3.5-27B-GGUF",
"usage": {
"input": 12002,
"output": 64,
"cacheRead": 0,
"cacheWrite": 0,
"totalTokens": 12066
},
"stopReason": "toolUse",
"timestamp": 1775848358650,
"responseId": "chatcmpl-..."
}
}
Fields:
- Top-level:
id,parentId,timestamp message.role: "user" or "assistant"message.content[]: Array of content items withtypefield:{ type: "text", text: "..." }{ type: "thinking", thinking: "...", thinkingSignature: "..." }{ type: "toolCall", id: "...", name: "...", arguments: {...} }
message.api: "openai-completions"message.provider: "local", "anthropic", etc.message.model: Model name/IDmessage.usage: Token usage statsmessage.stopReason: "stop", "toolUse", "aborted", etc.
model_change
Model or provider switch.
{
"type": "model_change",
"id": "80e34ca7",
"parentId": null,
"timestamp": "2026-04-10T19:12:00.855Z",
"provider": "local",
"modelId": "unsloth/Qwen3.5-27B-GGUF"
}
Fields: id, parentId, timestamp, provider, modelId
thinking_level_change
Thinking level adjustment (low/medium/high).
{
"type": "thinking_level_change",
"id": "380e238c",
"parentId": "80e34ca7",
"timestamp": "2026-04-10T19:12:00.855Z",
"thinkingLevel": "medium"
}
Fields: id, parentId, timestamp, thinkingLevel
custom_message
Hook output, errors, and VCS notifications.
Hook error:
{
"type": "custom_message",
"customType": "hook-error",
"content": "Hook error:\nESLint: 10.2.0\n...",
"display": true,
"id": "7a51fa2e",
"parentId": "faf0296b",
"timestamp": "2026-04-10T18:39:04.378Z"
}
VCS notification:
{
"type": "custom_message",
"customType": "Squashed change xoqlnppl into change yxqqokrx",
"content": [{ "type": "text", "text": "Working copy (@) now at: ..." }],
"display": true,
"id": "fbdc57fb",
"parentId": "e7ca4d4d",
"timestamp": "2026-04-10T18:47:15.462Z"
}
Fields: customType, content, display, id, parentId, timestamp
customType values:
"hook"- Hook output (eslint, typecheck success)"hook-error"- Hook errors"Squashed change X into change Y"- jj/VCS notifications
compaction
Context compaction summary when session is truncated.
{
"type": "compaction",
"id": "26b7c5ac",
"parentId": "ca53199c",
"timestamp": "2026-04-10T17:09:03.200Z",
"summary": "## Goal\n- ...\n\n## Progress\n...",
"firstKeptEntryId": "5cd3d2eb",
"tokensBefore": 266602,
"details": {
"readFiles": ["..."],
"modifiedFiles": ["..."]
},
"fromHook": false
}
Fields: id, parentId, timestamp, summary, firstKeptEntryId, tokensBefore, details, fromHook
Helper Function
Define this helper once for cleaner queries:
# Add to your env.nu or define inline
def open-jsonl [file: path] {
open $file --raw | lines | each { from json }
}
Core Commands
Loading Session Logs
# Load a single session file
open-jsonl ~/.pi/agent/sessions/<project-path>/<session-file>.jsonl
# Or without helper:
open ~/.pi/agent/sessions/<project-path>/<session-file>.jsonl --raw | lines | each { from json }
# Load all sessions from a project
ls ~/.pi/agent/sessions/<project-path>/*.jsonl
| each { |f| open-jsonl $f.name } | flatten
# Load most recent session
ls ~/.pi/agent/sessions/<project-path>/*.jsonl
| sort-by modified --reverse | first 1 | get name | open-jsonl
Filtering by Type
# Get all messages
open-jsonl session.jsonl | where type == "message"
# Get user messages
open-jsonl session.jsonl | where type == "message" | where message.role == "user"
# Get assistant messages
open-jsonl session.jsonl | where type == "message" | where message.role == "assistant"
# Get tool calls from assistant messages
open-jsonl session.jsonl
| where type == "message"
| where message.role == "assistant"
| where ($in.message.content | any { |c| $c.type == "toolCall" })
# Get hook output (eslint, typecheck, etc.)
open-jsonl session.jsonl | where type == "custom_message"
| select customType content display
# Get hook errors only
open-jsonl session.jsonl | where type == "custom_message"
| where customType == "hook-error"
| select customType content timestamp
# Get VCS notifications (jj squashes, etc.)
open-jsonl session.jsonl | where type == "custom_message"
| where customType | str starts-with "Squashed"
| select customType content timestamp
# Get compaction summaries
open-jsonl session.jsonl | where type == "compaction"
| select timestamp firstKeptEntryId tokensBefore details
Finding Tool Calls
# Extract all tool calls with their names and arguments
open-jsonl session.jsonl
| where type == "message"
| where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall"
| select id name arguments
# Get bash commands specifically
open-jsonl session.jsonl
| where type == "message"
| where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name == "bash"
| get arguments.command
# Get all read operations
open-jsonl session.jsonl
| where type == "message"
| where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name == "read"
| get arguments.path
# Get thinking content
open-jsonl session.jsonl
| where type == "message"
| where message.role == "assistant"
| get message.content
| flatten
| where type == "thinking"
| get thinking
Common Queries
What Commands Were Run?
# All bash commands in a session
open-jsonl session.jsonl
| where type == "message"
| where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name == "bash"
| get arguments.command
# All bash commands with their IDs (for tracking)
open-jsonl session.jsonl
| where type == "message"
| where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name == "bash"
| select id arguments.command
What Hook Errors Occurred?
# All hook errors (eslint, typecheck, etc.)
open-jsonl session.jsonl
| where type == "custom_message" | where customType == "hook-error"
| select timestamp content
# Hook errors with timestamps
open-jsonl session.jsonl
| where type == "custom_message" | where customType == "hook-error"
| select customType timestamp content
Tool Call Analysis
# Count tool calls by type
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall"
| get name | uniq --count
# Get unique tools used
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall"
| get name | uniq
What Files Were Accessed?
# All files read
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name == "read"
| get arguments.path
# All files written (edit/write operations)
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name in ["edit", "write"]
| get arguments.path
# Unique files accessed
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name in ["read", "edit", "write"]
| get arguments.path | uniq
Session Statistics
# Count different entry types
open-jsonl session.jsonl | get type | uniq --count
# Count message roles
open-jsonl session.jsonl
| where type == "message"
| get message.role | uniq --count
# Count tool calls by type
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall"
| get name | uniq --count
# Count custom_message types (hook, hook-error, etc.)
open-jsonl session.jsonl
| where type == "custom_message"
| get customType | uniq --count
# Token usage summary
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.usage
| flatten
| math sum
Find Specific Patterns
# Find sessions where a specific command was run
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name == "bash"
| where ($in.arguments.command | str contains "jj diff")
# Find all grep operations
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name == "bash"
| where ($in.arguments.command | str contains "grep")
| get arguments.command
# Find file modifications
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name in ["edit", "write"]
| select id name arguments
User Queries Analysis
# Get all user messages
open-jsonl session.jsonl
| where type == "message" | where message.role == "user"
| get message.content
| flatten
| where type == "text"
| get text
# Count user messages
open-jsonl session.jsonl
| where type == "message" | where message.role == "user"
| length
# Get user messages with timestamps
open-jsonl session.jsonl
| where type == "message" | where message.role == "user"
| select timestamp message
Model and Provider Info
# Get model changes
open-jsonl session.jsonl | where type == "model_change"
| select provider modelId timestamp
# Get thinking level changes
open-jsonl session.jsonl | where type == "thinking_level_change"
| select thinkingLevel timestamp
# Get token usage from assistant messages
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| select message.usage message.model
Custom Commands
# Quick session stats
def "session-stats" [file: path] {
let data = (open-jsonl $file)
{
total_events: ($data | length)
messages: ($data | where type == "message" | length)
user_messages: ($data | where type == "message" | where message.role == "user" | length)
assistant_messages: ($data | where type == "message" | where message.role == "assistant" | length)
tool_calls: ($data | where type == "message" | where message.role == "assistant" | get message.content | flatten | where type == "toolCall" | length)
custom_messages: ($data | where type == "custom_message" | length)
hook_errors: ($data | where type == "custom_message" | where customType == "hook-error" | length)
compactions: ($data | where type == "compaction" | length)
session_id: ($data | where type == "session" | get id | first)
}
}
# Get model info
def "model-stats" [file: path] {
open-jsonl $file
| where type == "message" | where message.role == "assistant"
| select message.model message.provider message.usage
| first
}
# Get hook errors
def "hook-errors" [file: path] {
open-jsonl $file
| where type == "custom_message" | where customType == "hook-error"
| select timestamp content
}
# Get bash commands
def "bash-history" [file: path] {
open-jsonl $file
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name == "bash"
| get arguments.command | uniq
}
# Get all custom messages by type
def "custom-messages" [file: path] {
open-jsonl $file
| where type == "custom_message"
| get customType | uniq --count
}
# Find files modified
def "files-modified" [file: path] {
open-jsonl $file
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name in ["edit", "write"]
| get arguments.path | uniq
}
# Get compaction summaries
def "compactions" [file: path] {
open-jsonl $file
| where type == "compaction"
| select timestamp firstKeptEntryId tokensBefore
| each { |c|
{
timestamp: $c.timestamp
tokensBefore: $c.tokensBefore
readFiles: ($c.details.readFiles | length)
modifiedFiles: ($c.details.modifiedFiles | length)
}
}
}
Multi-Session Queries
# Compare tool usage across sessions
ls ~/.pi/agent/sessions/<project-path>/*.jsonl | each { |f|
let data = (open-jsonl $f.name)
let tool_calls = ($data | where type == "message" | where message.role == "assistant" | get message.content | flatten | where type == "toolCall" | get name | uniq --count)
{
file: $f.name
tools: $tool_calls
}
}
# Aggregate statistics across all sessions
ls ~/.pi/agent/sessions/<project-path>/*.jsonl | each { |f|
open-jsonl $f.name
} | flatten | where type == "message" | where message.role == "assistant" | get message.content | flatten | where type == "toolCall" | get name | uniq --count
Practical Examples
Debug a Failed Session
# Load session and find what went wrong
let session = (open-jsonl "failed_session.jsonl")
# Check for aborted messages
$session | where type == "message" | where message.role == "assistant" | where message.stopReason == "aborted"
# Check for error messages
$session | where type == "message" | where message.role == "assistant" | where message.errorMessage != null
# Check for hook errors (eslint, typecheck failures)
$session | where type == "custom_message" | where customType == "hook-error"
| select timestamp content
Find How to Run a Command
# Search for a specific command pattern
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name == "bash"
| where ($in.arguments.command | str contains "bun run")
| get arguments.command
# Find all build commands
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name == "bash"
| where ($in.arguments.command | str contains --ignore-case "build" or $in.arguments.command | str contains "bun run")
| get arguments.command | uniq
Track File Changes Over Time
# Get all file operations in order
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name in ["read", "edit", "write"]
| select timestamp name arguments.path
# Group by file
open-jsonl session.jsonl
| where type == "message" | where message.role == "assistant"
| get message.content
| flatten
| where type == "toolCall" | where name in ["read", "edit", "write"]
| select timestamp name path: arguments.path
| group-by path
Constraints
- Session paths contain encoded directory paths with
--prefix/suffix - JSON Lines format: each line is a separate JSON object
- Tool calls are embedded in assistant messages under
message.content[]withtype == "toolCall" - Timestamps are ISO 8601 format (string) or Unix milliseconds (number)
- Use
flattento extract nested arrays frommessage.content - Some fields may be
null- filter withwhere $it != null custom_message.customType: "hook" for output, "hook-error" for errors, "Squashed change..." for VCScompactionentries contain summarized context when sessions are truncated- Entry IDs are unique within a session;
parentIdlinks related entries
Tips
- Use
jqfor complex JSON transformations if nushell struggles - Save filtered results:
open session.jsonl | where ... | save filtered.jsonl - Load multiple sessions:
ls *.jsonl | each { |f| open-jsonl $f.name } | flatten - Use
str contains --ignore-casefor case-insensitive command searches - Pipe to
tableorto mdfor better formatting in reports - Tool calls don't have separate result entries; results are shown in subsequent assistant messages
- Use
message.usagefields to track token consumption per assistant response