f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
621 lines
17 KiB
Go
621 lines
17 KiB
Go
package parser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func (p *piProvider) parseSession(
|
|
path, project, machine string,
|
|
) (*ParsedSession, []ParsedMessage, error) {
|
|
return parsePiLikeSession(
|
|
path, project, machine, p.Def.Type, p.Def.IDPrefix,
|
|
)
|
|
}
|
|
|
|
func parsePiLikeSession(
|
|
path, project, machine string,
|
|
agent AgentType,
|
|
idPrefix string,
|
|
) (*ParsedSession, []ParsedMessage, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("open %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
lr := newLineReader(f, maxLineSize)
|
|
defer releaseLineReader(lr)
|
|
|
|
// --- Parse session header (first non-whitespace line) ---
|
|
// Skip whitespace-only lines to stay consistent with
|
|
// IsPiSessionFile in discovery.go which uses TrimSpace. OMP (Oh My Pi)
|
|
// v16.3+ prefixes the header with a fixed-width rewritable
|
|
// {"type":"title",...} slot line holding the current session title;
|
|
// skip it too (matching IsPiSessionFile) and keep its title.
|
|
var headerLine, slotTitle string
|
|
for {
|
|
line, ok := lr.next()
|
|
if !ok {
|
|
return nil, nil, fmt.Errorf(
|
|
"not a pi session: missing session header in %s", path,
|
|
)
|
|
}
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
if gjson.Get(line, "type").Str == "title" {
|
|
slotTitle = gjson.Get(line, "title").Str
|
|
continue
|
|
}
|
|
headerLine = line
|
|
break
|
|
}
|
|
|
|
if !gjson.Valid(headerLine) {
|
|
return nil, nil, fmt.Errorf(
|
|
"not a pi session: invalid JSON header in %s", path,
|
|
)
|
|
}
|
|
|
|
if gjson.Get(headerLine, "type").Str != "session" {
|
|
return nil, nil, fmt.Errorf(
|
|
"not a pi session: missing session header in %s", path,
|
|
)
|
|
}
|
|
|
|
sessionID := gjson.Get(headerLine, "id").Str
|
|
cwd := gjson.Get(headerLine, "cwd").Str
|
|
headerTitle := gjson.Get(headerLine, "title").Str
|
|
headerTimestamp := parseTimestamp(gjson.Get(headerLine, "timestamp").Str)
|
|
|
|
// If project was not passed in, derive from cwd.
|
|
if project == "" && cwd != "" {
|
|
project = ExtractProjectFromCwd(cwd)
|
|
}
|
|
|
|
// Branch lineage. Upstream pi records the parent as branchedFrom, a
|
|
// file path whose basename without extension is the parent's session
|
|
// ID. OMP (Oh My Pi) v3 headers instead record parentSession, the
|
|
// parent's session ID directly. branchedFrom wins when present so
|
|
// upstream pi is unchanged; parentSession is the OMP-only fallback.
|
|
// Both paths reuse this session's own idPrefix, so the mapped value
|
|
// matches the parent's stored ID (idPrefix + its session id) and
|
|
// lineage resolves.
|
|
var parentSessionID string
|
|
if branchedFrom := gjson.Get(headerLine, "branchedFrom").Str; branchedFrom != "" {
|
|
base := filepath.Base(branchedFrom)
|
|
parentSessionID = idPrefix + strings.TrimSuffix(base, filepath.Ext(base))
|
|
} else if agent == AgentOMP {
|
|
if parentSession := gjson.Get(headerLine, "parentSession").Str; parentSession != "" {
|
|
parentSessionID = idPrefix + parentSession
|
|
}
|
|
}
|
|
|
|
// V1 detection: if header has no id, we may need to derive from filename.
|
|
isV1 := sessionID == ""
|
|
|
|
// --- Main message loop ---
|
|
var (
|
|
messages []ParsedMessage
|
|
firstMessage string
|
|
sessionName string
|
|
ordinal int
|
|
userCount int
|
|
currentModel string
|
|
)
|
|
// Pi emits metadata rows that stay in the tree, so bridge them to the
|
|
// nearest visible ancestor before assigning SourceParentUUID.
|
|
visibleAncestorByID := map[string]string{}
|
|
resolveVisibleAncestor := func(id string) string {
|
|
if id == "" {
|
|
return ""
|
|
}
|
|
return visibleAncestorByID[id]
|
|
}
|
|
|
|
for {
|
|
line, ok := lr.next()
|
|
if !ok {
|
|
break
|
|
}
|
|
|
|
if !gjson.Valid(line) {
|
|
continue
|
|
}
|
|
|
|
entryType := gjson.Get(line, "type").Str
|
|
if entryType == "" {
|
|
continue
|
|
}
|
|
entryID := gjson.Get(line, "id").Str
|
|
parentID := gjson.Get(line, "parentId").Str
|
|
|
|
// If any message entry has an id field, this is a V2 session.
|
|
if isV1 && entryID != "" {
|
|
isV1 = false
|
|
}
|
|
|
|
switch entryType {
|
|
case "message":
|
|
role := gjson.Get(line, "message.role").Str
|
|
switch role {
|
|
case "user":
|
|
sourceParentUUID := resolveVisibleAncestor(parentID)
|
|
msg := parsePiUserMessage(
|
|
line, ordinal, entryID, sourceParentUUID,
|
|
)
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
if firstMessage == "" && msg.Content != "" {
|
|
firstMessage = truncate(
|
|
strings.ReplaceAll(msg.Content, "\n", " "),
|
|
300,
|
|
)
|
|
}
|
|
messages = append(messages, *msg)
|
|
if entryID != "" {
|
|
visibleAncestorByID[entryID] = entryID
|
|
}
|
|
ordinal++
|
|
userCount++
|
|
|
|
case "assistant":
|
|
sourceParentUUID := resolveVisibleAncestor(parentID)
|
|
msg := parsePiAssistantMessage(
|
|
line, ordinal, currentModel, entryID,
|
|
sourceParentUUID,
|
|
)
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
if msg.Model != "" {
|
|
currentModel = msg.Model
|
|
}
|
|
messages = append(messages, *msg)
|
|
if entryID != "" {
|
|
visibleAncestorByID[entryID] = entryID
|
|
}
|
|
ordinal++
|
|
|
|
case "toolResult":
|
|
sourceParentUUID := resolveVisibleAncestor(parentID)
|
|
msg := parsePiToolResultMessage(
|
|
line, ordinal, entryID, sourceParentUUID,
|
|
)
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
messages = append(messages, *msg)
|
|
if entryID != "" {
|
|
visibleAncestorByID[entryID] = sourceParentUUID
|
|
}
|
|
ordinal++
|
|
|
|
default:
|
|
if entryID != "" {
|
|
visibleAncestorByID[entryID] = resolveVisibleAncestor(
|
|
parentID,
|
|
)
|
|
}
|
|
// skip silently
|
|
}
|
|
|
|
case "model_change":
|
|
if entryID != "" {
|
|
visibleAncestorByID[entryID] = resolveVisibleAncestor(
|
|
parentID,
|
|
)
|
|
}
|
|
if id := gjson.Get(line, "modelId").Str; id != "" {
|
|
currentModel = id
|
|
}
|
|
|
|
case "compaction":
|
|
sourceParentUUID := resolveVisibleAncestor(parentID)
|
|
msg := parsePiCompactionMessage(
|
|
line, ordinal, entryID, sourceParentUUID,
|
|
)
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
messages = append(messages, *msg)
|
|
if entryID != "" {
|
|
visibleAncestorByID[entryID] = entryID
|
|
}
|
|
ordinal++
|
|
|
|
case "session_info":
|
|
if entryID != "" {
|
|
visibleAncestorByID[entryID] = resolveVisibleAncestor(
|
|
parentID,
|
|
)
|
|
}
|
|
if name := gjson.Get(line, "name"); name.Exists() {
|
|
sessionName = name.Str
|
|
}
|
|
|
|
default:
|
|
if entryID != "" {
|
|
visibleAncestorByID[entryID] = resolveVisibleAncestor(
|
|
parentID,
|
|
)
|
|
}
|
|
// skip silently (e.g., thinking_level_change)
|
|
}
|
|
}
|
|
|
|
if err := lr.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf("reading pi %s: %w", path, err)
|
|
}
|
|
|
|
// Session name precedence: the OMP title slot is rewritten in place
|
|
// and always holds the current title, so it outranks session_info
|
|
// renames; the header title is the initial auto-generated fallback.
|
|
if slotTitle != "" {
|
|
sessionName = slotTitle
|
|
} else if sessionName == "" {
|
|
sessionName = headerTitle
|
|
}
|
|
|
|
// V1 fallback: derive session ID from filename.
|
|
if isV1 || sessionID == "" {
|
|
sessionID = strings.TrimSuffix(filepath.Base(path), ".jsonl")
|
|
}
|
|
|
|
// Compute StartedAt and EndedAt from message timestamps.
|
|
startedAt := headerTimestamp
|
|
var endedAt time.Time
|
|
for _, m := range messages {
|
|
if m.Timestamp.IsZero() {
|
|
continue
|
|
}
|
|
if startedAt.IsZero() || m.Timestamp.Before(startedAt) {
|
|
startedAt = m.Timestamp
|
|
}
|
|
if endedAt.IsZero() || m.Timestamp.After(endedAt) {
|
|
endedAt = m.Timestamp
|
|
}
|
|
}
|
|
|
|
sess := &ParsedSession{
|
|
ID: idPrefix + sessionID,
|
|
Project: project,
|
|
Machine: machine,
|
|
Agent: agent,
|
|
ParentSessionID: parentSessionID,
|
|
Cwd: cwd,
|
|
FirstMessage: firstMessage,
|
|
SessionName: sessionName,
|
|
StartedAt: startedAt,
|
|
EndedAt: endedAt,
|
|
MessageCount: len(messages),
|
|
UserMessageCount: userCount,
|
|
File: FileInfo{
|
|
Path: path,
|
|
Size: info.Size(),
|
|
Mtime: info.ModTime().UnixNano(),
|
|
},
|
|
}
|
|
|
|
accumulateMessageTokenUsage(sess, messages)
|
|
|
|
return sess, messages, nil
|
|
}
|
|
|
|
// parsePiUserMessage parses a message entry with role="user".
|
|
// Returns nil if the entry is malformed.
|
|
func parsePiUserMessage(
|
|
line string, ordinal int, sourceUUID, sourceParentUUID string,
|
|
) *ParsedMessage {
|
|
content := gjson.Get(line, "message.content")
|
|
|
|
var text string
|
|
if content.Type == gjson.String {
|
|
text = content.Str
|
|
} else if content.IsArray() {
|
|
var parts []string
|
|
content.ForEach(func(_, block gjson.Result) bool {
|
|
if block.Get("type").Str == "text" {
|
|
if t := block.Get("text").Str; t != "" {
|
|
parts = append(parts, t)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
text = strings.Join(parts, "\n")
|
|
}
|
|
|
|
ts := piTimestamp(line)
|
|
|
|
return &ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleUser,
|
|
SourceType: "user",
|
|
SourceUUID: sourceUUID,
|
|
SourceParentUUID: sourceParentUUID,
|
|
Content: text,
|
|
Timestamp: ts,
|
|
ContentLength: len(text),
|
|
}
|
|
}
|
|
|
|
// parsePiAssistantMessage parses a message entry with role="assistant".
|
|
// 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.
|
|
func parsePiAssistantMessage(
|
|
line string, ordinal int, fallbackModel, sourceUUID,
|
|
sourceParentUUID string,
|
|
) *ParsedMessage {
|
|
var (
|
|
parts []string
|
|
toolCalls []ParsedToolCall
|
|
hasThinking bool
|
|
hasToolUse bool
|
|
)
|
|
|
|
msgContent := gjson.Get(line, "message.content")
|
|
if msgContent.Type == gjson.String {
|
|
// Plain string content (back-compat format variation).
|
|
parts = append(parts, msgContent.Str)
|
|
} else {
|
|
msgContent.ForEach(func(_, block gjson.Result) bool {
|
|
switch block.Get("type").Str {
|
|
case "text":
|
|
if t := block.Get("text").Str; t != "" {
|
|
parts = append(parts, t)
|
|
}
|
|
case "thinking":
|
|
// Set hasThinking regardless of whether the thinking
|
|
// field is empty -- redacted thinking blocks have an
|
|
// empty field but the block type presence is sufficient
|
|
// to mark the message.
|
|
hasThinking = true
|
|
if thinking := block.Get("thinking").Str; thinking != "" {
|
|
parts = append(parts,
|
|
"[Thinking]\n"+thinking+"\n[/Thinking]")
|
|
}
|
|
case "toolCall":
|
|
hasToolUse = true
|
|
id := block.Get("id").Str
|
|
name := block.Get("name").Str
|
|
argsRaw := block.Get("arguments").Raw
|
|
// Normalize Pi's agent__intent / _i field to
|
|
// "description" so the frontend can use a single
|
|
// params.description check across all agents.
|
|
argsRaw = normalizePiIntent(argsRaw)
|
|
toolCalls = append(toolCalls, ParsedToolCall{
|
|
ToolUseID: id,
|
|
ToolName: name,
|
|
Category: NormalizeToolCategory(name),
|
|
InputJSON: argsRaw,
|
|
})
|
|
parts = append(parts, formatPiToolUse(
|
|
name, argsRaw,
|
|
))
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
content := strings.Join(parts, "\n")
|
|
ts := piTimestamp(line)
|
|
|
|
pm := &ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleAssistant,
|
|
SourceType: "assistant",
|
|
SourceUUID: sourceUUID,
|
|
SourceParentUUID: sourceParentUUID,
|
|
Content: content,
|
|
Timestamp: ts,
|
|
HasThinking: hasThinking,
|
|
HasToolUse: hasToolUse,
|
|
ContentLength: len(content),
|
|
ToolCalls: toolCalls,
|
|
}
|
|
applyPiTokenUsage(pm, line, fallbackModel)
|
|
return pm
|
|
}
|
|
|
|
// applyPiTokenUsage extracts the assistant message's model and
|
|
// per-message token counts from a Pi JSONL line. Pi records
|
|
// usage as a flat object under message.usage with provider-
|
|
// agnostic input/output keys plus optional cache breakdowns.
|
|
// Cache fields are read from both the nested cache.{read,write}
|
|
// shape (OpenCode-style) and the flat cacheRead/cacheCreation
|
|
// shape (Anthropic-style) so both transports work.
|
|
//
|
|
// Coverage semantics match the claude parser contract: a field
|
|
// present at zero is preserved as "known zero" and sets its
|
|
// coverage flag, while a usage object with no recognized
|
|
// fields (empty `{}` or a foreign schema) leaves TokenUsage
|
|
// empty so the usage query filter skips the row.
|
|
func applyPiTokenUsage(
|
|
pm *ParsedMessage, line, fallbackModel string,
|
|
) {
|
|
if model := gjson.Get(line, "message.model").Str; model != "" {
|
|
pm.Model = model
|
|
} else if fallbackModel != "" {
|
|
pm.Model = fallbackModel
|
|
}
|
|
|
|
usage := gjson.Get(line, "message.usage")
|
|
if !usage.Exists() {
|
|
return
|
|
}
|
|
|
|
inputField := usage.Get("input")
|
|
outputField := usage.Get("output")
|
|
cacheReadField := usage.Get("cache.read")
|
|
if !cacheReadField.Exists() {
|
|
cacheReadField = usage.Get("cacheRead")
|
|
}
|
|
cacheWriteField := usage.Get("cache.write")
|
|
if !cacheWriteField.Exists() {
|
|
cacheWriteField = usage.Get("cacheCreation")
|
|
}
|
|
|
|
if !inputField.Exists() && !outputField.Exists() &&
|
|
!cacheReadField.Exists() && !cacheWriteField.Exists() {
|
|
return
|
|
}
|
|
|
|
input := int(inputField.Int())
|
|
output := int(outputField.Int())
|
|
cacheRead := int(cacheReadField.Int())
|
|
cacheCreate := int(cacheWriteField.Int())
|
|
|
|
normalized := map[string]int{
|
|
"input_tokens": input,
|
|
"output_tokens": output,
|
|
"cache_read_input_tokens": cacheRead,
|
|
"cache_creation_input_tokens": cacheCreate,
|
|
}
|
|
j, err := json.Marshal(normalized)
|
|
if err != nil {
|
|
return
|
|
}
|
|
pm.TokenUsage = j
|
|
pm.OutputTokens = output
|
|
pm.HasOutputTokens = outputField.Exists()
|
|
pm.ContextTokens = input + cacheRead + cacheCreate
|
|
pm.HasContextTokens = inputField.Exists() ||
|
|
cacheReadField.Exists() || cacheWriteField.Exists()
|
|
}
|
|
|
|
// parsePiToolResultMessage parses a message entry with role="toolResult".
|
|
// Returns nil if the entry is malformed.
|
|
func parsePiToolResultMessage(
|
|
line string, ordinal int, sourceUUID, sourceParentUUID string,
|
|
) *ParsedMessage {
|
|
toolUseID := gjson.Get(line, "message.toolCallId").Str
|
|
content := gjson.Get(line, "message.content")
|
|
contentLen := toolResultContentLength(content)
|
|
|
|
ts := piTimestamp(line)
|
|
|
|
return &ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleUser,
|
|
SourceType: "toolResult",
|
|
SourceUUID: sourceUUID,
|
|
SourceParentUUID: sourceParentUUID,
|
|
Timestamp: ts,
|
|
ToolResults: []ParsedToolResult{
|
|
{
|
|
ToolUseID: toolUseID,
|
|
ContentLength: contentLen,
|
|
ContentRaw: content.Raw,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func parsePiCompactionMessage(
|
|
line string, ordinal int, sourceUUID, sourceParentUUID string,
|
|
) *ParsedMessage {
|
|
summary := gjson.Get(line, "summary").Str
|
|
ts := parseTimestamp(gjson.Get(line, "timestamp").Str)
|
|
return &ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleAssistant,
|
|
Content: summary,
|
|
Timestamp: ts,
|
|
IsSystem: true,
|
|
ContentLength: len(summary),
|
|
SourceType: "system",
|
|
SourceSubtype: "compact_boundary",
|
|
SourceUUID: sourceUUID,
|
|
SourceParentUUID: sourceParentUUID,
|
|
IsCompactBoundary: true,
|
|
}
|
|
}
|
|
|
|
// formatPiToolUse constructs a synthetic block with "input" mapped from
|
|
// Pi's "arguments" field and delegates to formatToolUse. This avoids
|
|
// duplicating the tool-name switch logic.
|
|
func formatPiToolUse(name, argsRaw string) string {
|
|
// Build {"name":"<name>","input":<args>} so formatToolUse can
|
|
// read input.* paths as usual.
|
|
var sb strings.Builder
|
|
sb.WriteString(`{"name":`)
|
|
nameJSON, _ := json.Marshal(name)
|
|
sb.Write(nameJSON)
|
|
sb.WriteString(`,"input":`)
|
|
if argsRaw == "" {
|
|
sb.WriteString("{}")
|
|
} else {
|
|
sb.WriteString(argsRaw)
|
|
}
|
|
sb.WriteByte('}')
|
|
return formatToolUse(gjson.Parse(sb.String()))
|
|
}
|
|
|
|
// normalizePiIntent rewrites Pi's agent__intent or _i argument field to
|
|
// "description" so the frontend can use a uniform params.description check
|
|
// across all agents. Returns the original JSON unchanged if neither field
|
|
// is present or if "description" already exists.
|
|
func normalizePiIntent(argsRaw string) string {
|
|
if argsRaw == "" {
|
|
return argsRaw
|
|
}
|
|
// Don't overwrite an existing description field.
|
|
if gjson.Get(argsRaw, "description").Exists() {
|
|
return argsRaw
|
|
}
|
|
intent := gjson.Get(argsRaw, "agent__intent")
|
|
if !intent.Exists() {
|
|
intent = gjson.Get(argsRaw, "_i")
|
|
}
|
|
if !intent.Exists() {
|
|
return argsRaw
|
|
}
|
|
// Unmarshal into a map, rename the intent key to "description",
|
|
// and re-marshal to produce valid JSON with proper escaping.
|
|
var m map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(argsRaw), &m); err != nil {
|
|
return argsRaw
|
|
}
|
|
if v, ok := m["agent__intent"]; ok {
|
|
m["description"] = v
|
|
} else if v, ok := m["_i"]; ok {
|
|
m["description"] = v
|
|
} else {
|
|
return argsRaw
|
|
}
|
|
delete(m, "agent__intent")
|
|
delete(m, "_i")
|
|
out, err := json.Marshal(m)
|
|
if err != nil {
|
|
return argsRaw
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// piTimestamp extracts the timestamp for a pi JSONL entry.
|
|
// Tries the top-level "timestamp" field first (ISO 8601), then
|
|
// falls back to "message.timestamp" as Unix milliseconds.
|
|
func piTimestamp(line string) time.Time {
|
|
if ts := parseTimestamp(gjson.Get(line, "timestamp").Str); !ts.IsZero() {
|
|
return ts
|
|
}
|
|
if ms := gjson.Get(line, "message.timestamp").Int(); ms != 0 {
|
|
return time.UnixMilli(ms).UTC()
|
|
}
|
|
return time.Time{}
|
|
}
|