Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/internal/session-format-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -2743,6 +2743,18 @@ schemas keep their existing ordering behavior.
shapes. Model IDs are present. Agentsview catalog-prices the tokens. Data
version 81 reparses existing Pi-family archives after adding the flat
`cacheWrite` spelling.
- **Reasoning effort:** Assistant messages store the Pi thinking level as
`message.thinkingLevel`, written since Pi v0.99.0 (2026-09-29, upstream
commit `540e174c72`, "Virtual models"). Older logs carry only
`thinking_level_change` entries (`thinkingLevel`), which Pi has written since
its first releases and appends on every effective change, including model
switches. Pi types the stored value as a plain string, so Agentsview keeps
whatever string Pi wrote (`off` through `max` today) in `reasoning_effort`.
The inline value wins. Otherwise the latest `thinking_level_change` value
applies, which backfills older logs; logs with neither leave it empty. Only
assistant messages carry it. OMP writes the same entry type but may add a
`configured` selector and a null `thinkingLevel`; null is ignored. Data
version 151 reparses Pi-family archives to pick this up.
- **Directory configuration:** Reverified 2026-09-09 against
[config.ts](https://github.com/earendil-works/pi/blob/acaa253cc8e3f159e6100b6f3874861b1f0bfc99/packages/coding-agent/src/config.ts)
and
Expand Down
1 change: 1 addition & 0 deletions internal/db/data_version_scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ var dataVersionScopes = map[int][]parser.AgentType{
154: {parser.AgentVSCodeCopilot, parser.AgentPositron},
155: {parser.AgentCodex, parser.AgentTraeX, parser.AgentAugureCode},
156: {parser.AgentPi, parser.AgentOMO, parser.AgentPrimeAgent, parser.AgentStepCode},
157: {parser.AgentPi, parser.AgentOMP, parser.AgentOMO, parser.AgentPrimeAgent, parser.AgentStepCode},
}

// firstScopedDataVersion is the oldest version that may be declared. Released
Expand Down
4 changes: 3 additions & 1 deletion internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,9 @@ CREATE INDEX IF NOT EXISTS idx_provider_freshness_updated_at
// subagents instead of forks, and stop a nested run from resolving to itself.
// Re-parse Pi-format sources in place; source and session identities are
// unchanged.)
const dataVersion = 156
// (157: capture Pi thinking level as reasoning effort. Re-parse Pi-format
// sources in place; source and session identities are unchanged.)
const dataVersion = 157

const archiveOnlyVersionBase = 1 << 20

Expand Down
34 changes: 30 additions & 4 deletions internal/parser/pi.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ func parsePiLikeSession(
ordinal int
userCount int
currentModel string
currentEffort string
assistantByID map[string]int
)
assistantByID = make(map[string]int)
Expand Down Expand Up @@ -209,8 +210,8 @@ func parsePiLikeSession(
case "assistant":
sourceParentUUID := resolveVisibleAncestor(parentID)
msg := parsePiAssistantMessage(
line, ordinal, currentModel, entryID,
sourceParentUUID, cwd,
line, ordinal, currentModel, currentEffort,
entryID, sourceParentUUID, cwd,
)
if msg == nil {
continue
Expand Down Expand Up @@ -258,6 +259,16 @@ func parsePiLikeSession(
currentModel = id
}

case "thinking_level_change":
if entryID != "" {
visibleAncestorByID[entryID] = resolveVisibleAncestor(
parentID,
)
}
if level := gjson.Get(line, "thinkingLevel").Str; level != "" {
currentEffort = level
}

case "compaction":
sourceParentUUID := resolveVisibleAncestor(parentID)
msg := parsePiCompactionMessage(
Expand Down Expand Up @@ -305,7 +316,7 @@ func parsePiLikeSession(
parentID,
)
}
// skip silently (e.g., thinking_level_change)
// skip silently (entry types with no visible content)
}
}

Expand Down Expand Up @@ -459,8 +470,10 @@ func parsePiUserMessage(
// Returns nil if the entry is malformed. fallbackModel is the most
// recent model id seen (from a prior assistant message or
// model_change entry), used when this message has no inline model.
// fallbackEffort is the most recent thinking_level_change value, used
// when this message has no inline thinkingLevel.
func parsePiAssistantMessage(
line string, ordinal int, fallbackModel, sourceUUID,
line string, ordinal int, fallbackModel, fallbackEffort, sourceUUID,
sourceParentUUID, sessionCwd string,
) *ParsedMessage {
var (
Expand Down Expand Up @@ -540,6 +553,7 @@ func parsePiAssistantMessage(
ToolCalls: toolCalls,
}
applyPiTokenUsage(pm, line, fallbackModel)
applyPiReasoningEffort(pm, line, fallbackEffort)
return pm
}

Expand Down Expand Up @@ -624,6 +638,18 @@ func applyPiTokenUsage(
applyPiUsage(pm, usage)
}

// applyPiReasoningEffort records the thinking level Pi requested for an
// assistant response. Newer Pi versions write it on the message as
// thinkingLevel; older ones only append thinking_level_change entries, so
// the most recent entry value fills in. The value is stored as Pi wrote it.
func applyPiReasoningEffort(pm *ParsedMessage, line, fallback string) {
if level := gjson.Get(line, "message.thinkingLevel").Str; level != "" {
pm.ReasoningEffort = level
} else {
pm.ReasoningEffort = fallback
}
}

func applyPiUsage(pm *ParsedMessage, usage gjson.Result) {
if !usage.Exists() {
return
Expand Down
68 changes: 68 additions & 0 deletions internal/parser/pi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1166,3 +1166,71 @@ func TestPiProviderParsesNoUsageNoTokenUsage(t *testing.T) {
assert.Empty(t, msgs[0].TokenUsage,
"token usage left empty when message.usage absent")
}

func TestPiParserReasoningEffort(t *testing.T) {
header := `{"type":"session","id":"re-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/tmp"}` + "\n"
user := func(id string) string {
return `{"type":"message","id":"` + id + `","timestamp":"2025-01-01T10:00:01Z","message":{"role":"user","content":"hi"}}` + "\n"
}
asst := func(id, extra string) string {
return `{"type":"message","id":"` + id + `","timestamp":"2025-01-01T10:00:02Z","message":{"role":"assistant","content":"ok"` + extra + `}}` + "\n"
}
level := func(id, v string) string {
return `{"type":"thinking_level_change","id":"` + id + `","timestamp":"2025-01-01T10:00:00.5Z","thinkingLevel":"` + v + `"}` + "\n"
}

tests := []struct {
name string
body string
want []string // effort of each assistant message, in order
}{
{
name: "inline thinkingLevel",
body: user("u1") + asst("a1", `,"thinkingLevel":"high"`),
want: []string{"high"},
},
{
name: "values stored verbatim",
body: user("u1") + asst("a1", `,"thinkingLevel":"off"`) +
asst("a2", `,"thinkingLevel":"some-future-level"`),
want: []string{"off", "some-future-level"},
},
{
name: "change entry backfills messages without inline level",
body: level("t1", "low") + user("u1") + asst("a1", ""),
want: []string{"low"},
},
{
name: "inline level wins over change entry",
body: level("t1", "low") + user("u1") +
asst("a1", `,"thinkingLevel":"max"`),
want: []string{"max"},
},
{
name: "mid-session change applies to later messages only",
body: level("t1", "low") + user("u1") + asst("a1", "") +
level("t2", "xhigh") + user("u2") + asst("a2", ""),
want: []string{"low", "xhigh"},
},
{
name: "no level information stays empty",
body: user("u1") + asst("a1", ""),
want: []string{""},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, msgs := runPiParserTest(t, header+tc.body)
var got []string
for _, m := range msgs {
if m.Role == RoleAssistant {
got = append(got, m.ReasoningEffort)
} else {
assert.Empty(t, m.ReasoningEffort,
"only assistant messages carry effort")
}
}
assert.Equal(t, tc.want, got)
})
}
}
Loading