Files
boshu2__agentops/cli/internal/parser/parser.go
boshu 568bbcef2d fix(ci): green 5 pre-existing main-wide gate failures
The same gate set failed on every open PR and drove all 16 nightly-failure
issues. Each root-caused via isolated-worktree reproduction of the live CI
jobs, then fixed minimally:

- correctness: check + propagate the discarded json.Marshal error in
  plan-pawl --json and yield tokens --json (planning-rule f-2026-04-29-002).
- lint: 3 markdownlint violations in pre-existing audit docs (MD004 leading
  "+" continuations, MD037 skills/* glob emphasis).
- security: dual #nosec + nosemgrep on 4 gosec/semgrep false positives in
  local-CLI code (G404 e-exploration RNG, G101 message-type sentinel, G122
  corpus-walk I/O) -- not a dependency CVE; per .claude/rules/go.md.
- skills-integrity: scope the broken-symlink scan to the shipped dirs
  (skills hooks lib scripts), matching the pre-push gate, so the intentional
  root CLAUDE.md -> AGENTS.md symlink is not falsely flagged.
- go-gate-shadow: add the 4 post-parity job-scoped blocking scripts to the
  deferredWorkflowScripts allowlist (--require-workflow-parity missing_blocking
  4 -> 0); strengthen the plan-pawl JSON test (command/test pair).

contracts-sync + skill-gates were already green on main (no change).
2026-06-25 08:59:50 -04:00

796 lines
22 KiB
Go

// Package parser provides streaming JSONL parsing for Claude and Codex transcripts.
package parser
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/boshu2/agentops/cli/internal/types"
)
// DefaultMaxContentLength is the default truncation limit for content fields.
const DefaultMaxContentLength = 500
// Message type constants for transcript entries.
const (
msgTypeUser = "user"
msgTypeAssistant = "assistant"
msgTypeToolUse = "tool_use"
msgTypeToolResult = "tool_result"
// msgTypeCodexTokenCount is an internal sentinel: a Codex token_count event
// folded into ParseResult.FinalUsage rather than the message list.
msgTypeCodexTokenCount = "codex_token_count" // #nosec G101 nosemgrep -- message-type sentinel string, not a credential.
)
// Error classification constants for parse errors.
const (
errClassJSON = "json"
errClassSchema = "schema"
errClassEncoding = "encoding"
)
// Parser handles streaming JSONL parsing with configurable options.
type Parser struct {
// MaxContentLength is the maximum characters before truncation.
MaxContentLength int
// SkipMalformed skips malformed lines instead of erroring.
SkipMalformed bool
// OnProgress is called with progress updates for large files.
OnProgress func(linesProcessed, totalLines int)
}
// NewParser creates a parser with default settings.
func NewParser() *Parser {
return &Parser{
MaxContentLength: DefaultMaxContentLength,
SkipMalformed: true,
}
}
// rawMessage represents the raw JSON structure from supported transcript surfaces.
type rawMessage struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
Timestamp string `json:"timestamp"`
UUID string `json:"uuid"`
ParentUUID string `json:"parentUuid,omitempty"`
RequestID string `json:"requestId,omitempty"`
Role string `json:"role,omitempty"`
Content any `json:"content,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolInput map[string]any `json:"tool_input,omitempty"`
ToolOutput any `json:"tool_output,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
Message *struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Role string `json:"role"`
Content any `json:"content"` // Can be string or array
Tools []struct {
Name string `json:"name"`
Input map[string]any `json:"input"`
Output any `json:"output"`
} `json:"tools,omitempty"`
Usage *types.TokenUsage `json:"usage,omitempty"`
} `json:"message,omitempty"`
// ToolUseResult contains structured tool output (e.g., for TodoWrite)
ToolUseResult any `json:"toolUseResult,omitempty"`
}
type codexSessionMeta struct {
ID string `json:"id"`
Timestamp string `json:"timestamp"`
}
type codexEventPayload struct {
Type string `json:"type"`
Message string `json:"message"`
// Info carries the cumulative token accounting on a "token_count" event.
Info *struct {
TotalTokenUsage *codexTokenUsage `json:"total_token_usage"`
} `json:"info,omitempty"`
}
// codexTokenUsage is the Codex transcript's running token total. Unlike Claude
// (per-message usage), Codex emits a CUMULATIVE total on each token_count event,
// so the LAST one is the session total. input_tokens already includes cached
// input, and total_tokens == input_tokens + output_tokens.
type codexTokenUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
type codexResponseItem struct {
Type string `json:"type"`
Role string `json:"role"`
Name string `json:"name"`
Arguments string `json:"arguments"`
Output string `json:"output"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
// ParseResult contains the result of parsing a JSONL stream.
type ParseResult struct {
Messages []types.TranscriptMessage
TotalLines int
MalformedLines int
Errors []error
// Checksum is SHA256 hash of the parsed content (first 16 hex chars).
// Used for detecting changes and validating re-parsing.
Checksum string
// FilePath is the source file path (if parsed from file).
FilePath string
// ParsedAt is when parsing completed.
ParsedAt time.Time
// FinalUsage is the Codex cumulative session token total (the last
// token_count event). When set, it is authoritative and supersedes the
// per-message Claude usage sum — see TokenTotals.
FinalUsage *types.TokenUsage
}
// TokenTotals returns the session's real token footprint, choosing the right
// aggregation per runtime: Codex emits a cumulative total (FinalUsage, last
// wins), while Claude emits per-message usage that SumUsage de-dups by response
// id. Producer truth (age-membrane-memory-arch-tz2s.3.1/.3.2).
func (r *ParseResult) TokenTotals() (tokensIn, tokensOut int) {
if r.FinalUsage != nil {
return r.FinalUsage.TotalInputTokens(), r.FinalUsage.OutputTokens
}
return types.SumUsage(r.Messages)
}
// ParseError provides structured error information for transcript parsing failures.
type ParseError struct {
Line int `json:"line"`
Column int `json:"column,omitempty"`
Message string `json:"message"`
RawContent string `json:"raw_content,omitempty"`
ErrorType string `json:"error_type"` // "json", "schema", "encoding"
}
func (e *ParseError) Error() string {
if e.Column > 0 {
return fmt.Sprintf("line %d, col %d: %s (%s)", e.Line, e.Column, e.Message, e.ErrorType)
}
return fmt.Sprintf("line %d: %s (%s)", e.Line, e.Message, e.ErrorType)
}
// Parse reads JSONL from the reader and returns parsed messages.
func (p *Parser) Parse(r io.Reader) (*ParseResult, error) {
result := &ParseResult{
Messages: make([]types.TranscriptMessage, 0),
}
hasher := sha256.New()
if err := readJSONLLines(r, func(line []byte, lineNum int) error {
result.TotalLines = lineNum
if len(line) == 0 {
return nil
}
_, _ = hasher.Write(line)
_, _ = hasher.Write([]byte("\n"))
p.processLine(line, lineNum, result)
if p.OnProgress != nil && lineNum%100 == 0 {
p.OnProgress(lineNum, 0)
}
return nil
}); err != nil {
return result, fmt.Errorf("read jsonl: %w", err)
}
hash := hasher.Sum(nil)
result.Checksum = hex.EncodeToString(hash[:8])
result.ParsedAt = time.Now()
return result, nil
}
// processLine parses a single JSONL line and appends the result or error.
func (p *Parser) processLine(line []byte, lineNum int, result *ParseResult) {
msg, err := p.parseLine(line, lineNum)
if err != nil {
result.MalformedLines++
if !p.SkipMalformed {
result.Errors = append(result.Errors, &ParseError{
Line: lineNum,
Message: err.Error(),
ErrorType: classifyError(err),
RawContent: truncateForError(string(line), 100),
})
}
return
}
if msg != nil {
// Codex token_count events carry the CUMULATIVE session total; keep the
// last one as FinalUsage and never add it to the summed message list.
if msg.Type == msgTypeCodexTokenCount {
result.FinalUsage = msg.Usage
return
}
result.Messages = append(result.Messages, *msg)
}
}
// classifyError determines the error type for structured reporting.
func classifyError(err error) string {
errStr := err.Error()
switch {
case strings.Contains(errStr, "invalid character"):
return errClassJSON
case strings.Contains(errStr, "unexpected end"):
return errClassJSON
case strings.Contains(errStr, "cannot unmarshal"):
return errClassSchema
case strings.Contains(errStr, "invalid UTF-8"):
return errClassEncoding
default:
return errClassJSON
}
}
// truncateForError limits error context to a reasonable size.
func truncateForError(s string, maxLen int) string {
// Fast path: len(s) is an upper bound on rune count, so any string whose
// byte length already fits needs no conversion. This avoids a []rune
// allocation on the common short-ASCII case.
if len(s) <= maxLen {
return s
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}
// openFileFunc is the function used to open files for parsing.
// It can be overridden in tests to inject close errors.
var openFileFunc = func(path string) (io.ReadCloser, error) {
return os.Open(path)
}
// ParseFile parses a JSONL file by path.
func (p *Parser) ParseFile(path string) (result *ParseResult, err error) {
f, err := openFileFunc(path)
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
result, err = p.Parse(f)
if result != nil {
result.FilePath = path
}
return result, err
}
// timestampFormats lists the formats to try when parsing timestamps.
var timestampFormats = []string{
time.RFC3339,
"2006-01-02T15:04:05.000Z",
}
// parseTimestamp parses a timestamp string, trying multiple formats.
// Returns zero time if all formats fail.
func parseTimestamp(s string) time.Time {
for _, format := range timestampFormats {
if ts, err := time.Parse(format, s); err == nil {
return ts
}
}
return time.Time{}
}
// isValidMessageType checks if the type is one we should parse.
func isValidMessageType(msgType string) bool {
switch msgType {
case msgTypeUser, msgTypeAssistant, msgTypeToolUse, msgTypeToolResult:
return true
default:
return false
}
}
// parseContentBlocks extracts text and tool calls from content block array.
func (p *Parser) parseContentBlocks(blocks []any) (string, []types.ToolCall) {
var content string
var tools []types.ToolCall
for _, block := range blocks {
blockMap, ok := block.(map[string]any)
if !ok {
continue
}
text, tool := p.classifyBlock(blockMap)
content += text
if tool != nil {
tools = append(tools, *tool)
}
}
return content, tools
}
// classifyBlock dispatches a single content block into text or tool call.
func (p *Parser) classifyBlock(blockMap map[string]any) (string, *types.ToolCall) {
blockType, _ := blockMap["type"].(string)
switch blockType {
case "text":
if text, ok := blockMap["text"].(string); ok {
return p.truncate(text), nil
}
case msgTypeToolUse:
return "", p.parseToolUse(blockMap)
case msgTypeToolResult:
return "", p.parseToolResult(blockMap)
}
return "", nil
}
// parseLine parses a single JSON line.
func (p *Parser) parseLine(line []byte, lineNum int) (*types.TranscriptMessage, error) {
var raw rawMessage
if err := json.Unmarshal(line, &raw); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
switch raw.Type {
case msgTypeUser, msgTypeAssistant:
return p.parseClaudeMessage(raw, lineNum), nil
case msgTypeToolUse:
return p.parseClaudeToolUse(raw, lineNum), nil
case msgTypeToolResult:
return p.parseClaudeToolResult(raw, lineNum), nil
case "session_meta":
return p.parseCodexSessionMeta(raw, lineNum)
case "event_msg":
return p.parseCodexEvent(raw, lineNum)
case "response_item":
return p.parseCodexResponseItem(raw, lineNum)
default:
return nil, nil
}
}
func (p *Parser) parseClaudeMessage(raw rawMessage, lineNum int) *types.TranscriptMessage {
msg := &types.TranscriptMessage{
Type: raw.Type,
Timestamp: parseTimestamp(raw.Timestamp),
SessionID: raw.SessionID,
MessageIndex: lineNum,
Role: raw.Role,
}
if raw.Message != nil {
msg.Role = raw.Message.Role
p.extractMessageContent(raw.Message.Content, msg)
if raw.Message.Usage != nil {
msg.Usage = raw.Message.Usage
}
// Response identity for usage de-dup: prefer message.id, fall back to
// the top-level requestId. One response spans several rows that repeat
// the same usage; SumUsage counts each id once.
if raw.Message.ID != "" {
msg.MessageID = raw.Message.ID
} else if raw.RequestID != "" {
msg.MessageID = raw.RequestID
}
}
if raw.Content != nil {
p.extractMessageContent(raw.Content, msg)
}
if msg.Role == "" {
msg.Role = raw.Type
}
return msg
}
func (p *Parser) parseClaudeToolUse(raw rawMessage, lineNum int) *types.TranscriptMessage {
var tools []types.ToolCall
if raw.ToolName != "" {
tools = append(tools, types.ToolCall{
Name: raw.ToolName,
Input: raw.ToolInput,
})
}
if raw.Message != nil {
for _, tool := range raw.Message.Tools {
if tool.Name == "" {
continue
}
tools = append(tools, types.ToolCall{
Name: tool.Name,
Input: tool.Input,
Output: p.extractTopLevelToolOutput(tool.Output),
})
}
}
if len(tools) == 0 && raw.Message != nil {
msg := &types.TranscriptMessage{
Type: msgTypeToolUse,
Role: raw.Message.Role,
Timestamp: parseTimestamp(raw.Timestamp),
SessionID: raw.SessionID,
MessageIndex: lineNum,
}
p.extractMessageContent(raw.Message.Content, msg)
return msg
}
if len(tools) == 0 {
return nil
}
return &types.TranscriptMessage{
Type: msgTypeToolUse,
Role: msgTypeAssistant,
Timestamp: parseTimestamp(raw.Timestamp),
SessionID: raw.SessionID,
MessageIndex: lineNum,
Tools: tools,
}
}
func (p *Parser) parseClaudeToolResult(raw rawMessage, lineNum int) *types.TranscriptMessage {
output := p.extractTopLevelToolOutput(raw.ToolOutput)
if output == "" && raw.ToolUseResult != nil {
output = p.extractTopLevelToolOutput(raw.ToolUseResult)
}
if output == "" && raw.Message != nil && raw.Message.Content != nil {
output = p.extractTopLevelToolOutput(raw.Message.Content)
}
role := raw.Role
if role == "" && raw.Message != nil {
role = raw.Message.Role
}
return &types.TranscriptMessage{
Type: msgTypeToolResult,
Role: coalesce(role, msgTypeAssistant),
Timestamp: parseTimestamp(raw.Timestamp),
SessionID: raw.SessionID,
MessageIndex: lineNum,
Tools: []types.ToolCall{{
Name: coalesce(raw.ToolName, msgTypeToolResult),
Input: raw.ToolInput,
Output: output,
}},
}
}
func (p *Parser) parseCodexSessionMeta(raw rawMessage, lineNum int) (*types.TranscriptMessage, error) {
var meta codexSessionMeta
if err := json.Unmarshal(raw.Payload, &meta); err != nil {
return nil, fmt.Errorf("invalid session_meta payload: %w", err)
}
return &types.TranscriptMessage{
Type: "session_meta",
Timestamp: parseTimestamp(coalesce(meta.Timestamp, raw.Timestamp)),
SessionID: coalesce(meta.ID, raw.SessionID),
MessageIndex: lineNum,
}, nil
}
func (p *Parser) parseCodexEvent(raw rawMessage, lineNum int) (*types.TranscriptMessage, error) {
var payload codexEventPayload
if err := json.Unmarshal(raw.Payload, &payload); err != nil {
return nil, fmt.Errorf("invalid event payload: %w", err)
}
var (
msgType string
role string
)
switch payload.Type {
case "user_message":
msgType = msgTypeUser
role = msgTypeUser
case "agent_message":
msgType = msgTypeAssistant
role = msgTypeAssistant
case "token_count":
// Codex's cumulative session total. Surfaced as a sentinel message that
// processLine folds into ParseResult.FinalUsage (last wins), never into
// the summed message list.
if payload.Info == nil || payload.Info.TotalTokenUsage == nil {
return nil, nil
}
return &types.TranscriptMessage{
Type: msgTypeCodexTokenCount,
Timestamp: parseTimestamp(raw.Timestamp),
SessionID: raw.SessionID,
MessageIndex: lineNum,
Usage: &types.TokenUsage{
InputTokens: payload.Info.TotalTokenUsage.InputTokens,
OutputTokens: payload.Info.TotalTokenUsage.OutputTokens,
},
}, nil
default:
return nil, nil
}
return &types.TranscriptMessage{
Type: msgType,
Role: role,
Content: p.truncate(payload.Message),
Timestamp: parseTimestamp(raw.Timestamp),
SessionID: raw.SessionID,
MessageIndex: lineNum,
}, nil
}
func (p *Parser) parseCodexResponseItem(raw rawMessage, lineNum int) (*types.TranscriptMessage, error) {
var item codexResponseItem
if err := json.Unmarshal(raw.Payload, &item); err != nil {
return nil, fmt.Errorf("invalid response_item payload: %w", err)
}
switch item.Type {
case "message":
if item.Role != msgTypeUser && item.Role != msgTypeAssistant {
return nil, nil
}
var content strings.Builder
for _, block := range item.Content {
switch block.Type {
case "input_text", "output_text", "text":
content.WriteString(block.Text)
}
}
return &types.TranscriptMessage{
Type: item.Role,
Role: item.Role,
Content: p.truncate(content.String()),
Timestamp: parseTimestamp(raw.Timestamp),
SessionID: raw.SessionID,
MessageIndex: lineNum,
}, nil
case "function_call", "custom_tool_call":
return &types.TranscriptMessage{
Type: msgTypeToolUse,
Role: msgTypeAssistant,
Timestamp: parseTimestamp(raw.Timestamp),
SessionID: raw.SessionID,
MessageIndex: lineNum,
Tools: []types.ToolCall{{
Name: coalesce(item.Name, item.Type),
Input: parseCodexToolInput(item.Arguments),
}},
}, nil
case "function_call_output", "custom_tool_call_output":
return &types.TranscriptMessage{
Type: msgTypeToolResult,
Role: msgTypeAssistant,
Timestamp: parseTimestamp(raw.Timestamp),
SessionID: raw.SessionID,
MessageIndex: lineNum,
Tools: []types.ToolCall{{
Name: msgTypeToolResult,
Output: p.truncate(item.Output),
}},
}, nil
default:
return nil, nil
}
}
// extractMessageContent populates msg.Content and msg.Tools from raw content.
func (p *Parser) extractMessageContent(rawContent any, msg *types.TranscriptMessage) {
switch content := rawContent.(type) {
case string:
msg.Content = p.truncate(content)
case []any:
msg.Content, msg.Tools = p.parseContentBlocks(content)
}
}
// parseToolUse extracts tool call information from a tool_use block.
func (p *Parser) parseToolUse(block map[string]any) *types.ToolCall {
name, _ := block["name"].(string)
if name == "" {
return nil
}
toolCall := &types.ToolCall{
Name: name,
}
// Extract input parameters
if input, ok := block["input"].(map[string]any); ok {
toolCall.Input = input
}
return toolCall
}
// parseToolResult extracts tool result information from a tool_result block.
func (p *Parser) parseToolResult(block map[string]any) *types.ToolCall {
toolCall := &types.ToolCall{
Name: msgTypeToolResult,
}
// Check if it's an error result
if isError, ok := block["is_error"].(bool); ok && isError {
toolCall.Error = "tool error"
}
toolCall.Output = p.extractToolResultContent(block["content"])
return toolCall
}
// extractToolResultContent extracts text from a tool_result content field,
// which may be a plain string or an array of text blocks.
func (p *Parser) extractToolResultContent(content any) string {
switch c := content.(type) {
case string:
return p.truncate(c)
case []any:
var out string
for _, item := range c {
if itemMap, ok := item.(map[string]any); ok {
if text, ok := itemMap["text"].(string); ok {
out += p.truncate(text)
}
}
}
return out
default:
return ""
}
}
func (p *Parser) extractTopLevelToolOutput(content any) string {
switch c := content.(type) {
case nil:
return ""
case string:
return p.truncate(c)
case []any:
return p.extractToolResultContent(c)
default:
data, err := json.Marshal(c)
if err != nil {
return ""
}
return p.truncate(string(data))
}
}
// truncate limits content to MaxContentLength characters.
// Slices at rune boundaries to avoid splitting multi-byte UTF-8 sequences.
func (p *Parser) truncate(s string) string {
if p.MaxContentLength <= 0 {
return s
}
// Fast path: byte length is an upper bound on rune count.
if len(s) <= p.MaxContentLength {
return s
}
runes := []rune(s)
if len(runes) <= p.MaxContentLength {
return s
}
return string(runes[:p.MaxContentLength]) + "... [truncated]"
}
// ParseChannel returns a channel that emits messages as they're parsed.
// Useful for streaming large files without loading all into memory.
func (p *Parser) ParseChannel(r io.Reader) (<-chan types.TranscriptMessage, <-chan error) {
msgCh := make(chan types.TranscriptMessage, 100)
errCh := make(chan error, 1)
go func() {
defer close(msgCh)
defer close(errCh)
p.channelScanner(r, msgCh, errCh)
}()
return msgCh, errCh
}
// processChannelLine parses one scanner line and forwards the result to msgCh/errCh.
// Returns false if scanning should stop (fatal parse error).
func (p *Parser) processChannelLine(line []byte, lineNum int, msgCh chan<- types.TranscriptMessage, errCh chan<- error) bool {
if len(line) == 0 {
return true
}
msg, err := p.parseLine(line, lineNum)
if err != nil {
if !p.SkipMalformed {
errCh <- fmt.Errorf("line %d: %w", lineNum, err)
return false
}
return true
}
if msg != nil {
msgCh <- *msg
}
return true
}
// channelScanner scans r line by line, sending parsed messages to msgCh and errors to errCh.
func (p *Parser) channelScanner(r io.Reader, msgCh chan<- types.TranscriptMessage, errCh chan<- error) {
if err := readJSONLLines(r, func(line []byte, lineNum int) error {
if !p.processChannelLine(line, lineNum, msgCh, errCh) {
return errStopChannelScan
}
return nil
}); err != nil && !errors.Is(err, errStopChannelScan) {
errCh <- fmt.Errorf("read jsonl: %w", err)
}
}
var errStopChannelScan = errors.New("stop channel scan")
func readJSONLLines(r io.Reader, fn func([]byte, int) error) error {
reader := bufio.NewReader(r)
lineNum := 0
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
lineNum++
line = bytes.TrimSuffix(line, []byte("\n"))
line = bytes.TrimSuffix(line, []byte("\r"))
if callErr := fn(line, lineNum); callErr != nil {
return callErr
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
func parseCodexToolInput(raw string) map[string]any {
if strings.TrimSpace(raw) == "" {
return nil
}
var data any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return map[string]any{"raw": raw}
}
if obj, ok := data.(map[string]any); ok {
return obj
}
return map[string]any{"value": data}
}
func coalesce(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}