f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
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
398 lines
9.9 KiB
Go
398 lines
9.9 KiB
Go
package parser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
// parseSession parses a QClaw JSONL session file.
|
|
// QClaw stores messages in a JSONL format with a session header
|
|
// line, message entries, compaction summaries, and metadata events.
|
|
func (p *qClawProvider) parseSession(
|
|
path, project, machine 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)
|
|
var (
|
|
messages []ParsedMessage
|
|
startedAt time.Time
|
|
endedAt time.Time
|
|
ordinal int
|
|
realUserCount int
|
|
firstMsg string
|
|
sessionID string
|
|
cwd string
|
|
)
|
|
|
|
for {
|
|
line, ok := lr.next()
|
|
if !ok {
|
|
break
|
|
}
|
|
if !gjson.Valid(line) {
|
|
continue
|
|
}
|
|
|
|
entryType := gjson.Get(line, "type").Str
|
|
|
|
if ts := parseQClawTimestamp(line); !ts.IsZero() {
|
|
if startedAt.IsZero() || ts.Before(startedAt) {
|
|
startedAt = ts
|
|
}
|
|
if ts.After(endedAt) {
|
|
endedAt = ts
|
|
}
|
|
}
|
|
|
|
switch entryType {
|
|
case "session":
|
|
if sessionID == "" {
|
|
sessionID = gjson.Get(line, "id").Str
|
|
}
|
|
if cwd == "" {
|
|
cwd = gjson.Get(line, "cwd").Str
|
|
}
|
|
continue
|
|
|
|
case "model_change", "thinking_level_change", "custom",
|
|
"compaction":
|
|
continue
|
|
|
|
case "message":
|
|
default:
|
|
continue
|
|
}
|
|
|
|
msg := gjson.Get(line, "message")
|
|
if !msg.Exists() {
|
|
continue
|
|
}
|
|
|
|
role := msg.Get("role").Str
|
|
ts := parseTimestamp(msg.Get("timestamp").Str)
|
|
if ts.IsZero() {
|
|
ts = parseTimestamp(gjson.Get(line, "timestamp").Str)
|
|
}
|
|
|
|
switch role {
|
|
case "user":
|
|
content := msg.Get("content")
|
|
text, thinkingText, hasThinking, hasToolUse, tcs, trs :=
|
|
ExtractTextContent(content)
|
|
text = strings.TrimSpace(text)
|
|
if text == "" && len(tcs) == 0 && len(trs) == 0 {
|
|
continue
|
|
}
|
|
|
|
if firstMsg == "" && text != "" {
|
|
firstMsg = truncate(
|
|
strings.ReplaceAll(
|
|
stripQClawDatePrefix(text),
|
|
"\n", " ",
|
|
), 300,
|
|
)
|
|
}
|
|
|
|
messages = append(messages, ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleUser,
|
|
Content: text,
|
|
Timestamp: ts,
|
|
HasThinking: hasThinking,
|
|
ThinkingText: thinkingText,
|
|
HasToolUse: hasToolUse,
|
|
ContentLength: len(text),
|
|
ToolCalls: tcs,
|
|
ToolResults: trs,
|
|
})
|
|
ordinal++
|
|
realUserCount++
|
|
|
|
case "assistant":
|
|
content := msg.Get("content")
|
|
text, thinkingText, hasThinking, hasToolUse, tcs, trs :=
|
|
ExtractTextContent(content)
|
|
text = strings.TrimSpace(text)
|
|
if text == "" && len(tcs) == 0 && len(trs) == 0 {
|
|
continue
|
|
}
|
|
|
|
pm := ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleAssistant,
|
|
Content: text,
|
|
Timestamp: ts,
|
|
HasThinking: hasThinking,
|
|
ThinkingText: thinkingText,
|
|
HasToolUse: hasToolUse,
|
|
ContentLength: len(text),
|
|
ToolCalls: tcs,
|
|
ToolResults: trs,
|
|
tokenPresenceKnown: true,
|
|
}
|
|
applyQClawAssistantUsage(&pm, msg)
|
|
messages = append(messages, pm)
|
|
ordinal++
|
|
|
|
case "toolResult":
|
|
// Tool results in QClaw are separate messages.
|
|
// Emit as a user message with empty Content so
|
|
// pairAndFilter removes it after pairToolResults
|
|
// copies ResultContentLength to the matching call.
|
|
toolCallID := msg.Get("toolCallId").Str
|
|
if toolCallID == "" {
|
|
continue
|
|
}
|
|
|
|
content := msg.Get("content")
|
|
resultText := extractQClawToolResultText(content)
|
|
contentLen := len(resultText)
|
|
|
|
messages = append(messages, ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleUser,
|
|
Content: "",
|
|
Timestamp: ts,
|
|
HasThinking: false,
|
|
HasToolUse: false,
|
|
ContentLength: contentLen,
|
|
ToolResults: []ParsedToolResult{{
|
|
ToolUseID: toolCallID,
|
|
ContentLength: contentLen,
|
|
ContentRaw: content.Raw,
|
|
}},
|
|
})
|
|
ordinal++
|
|
}
|
|
}
|
|
|
|
if err := lr.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf("reading %s: %w", path, err)
|
|
}
|
|
|
|
if len(messages) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// Build session ID with prefix, including the agent
|
|
// subdirectory to avoid collisions across agents.
|
|
if sessionID == "" {
|
|
sessionID = QClawSessionID(filepath.Base(path))
|
|
}
|
|
agentID := qClawAgentIDFromPath(path)
|
|
fullID := "qclaw:" + agentID + ":" + sessionID
|
|
|
|
if project == "" && cwd != "" {
|
|
project = ExtractProjectFromCwd(cwd)
|
|
}
|
|
if project == "" {
|
|
project = "qclaw"
|
|
}
|
|
|
|
sess := &ParsedSession{
|
|
ID: fullID,
|
|
Project: project,
|
|
Machine: machine,
|
|
Agent: AgentQClaw,
|
|
FirstMessage: firstMsg,
|
|
StartedAt: startedAt,
|
|
EndedAt: endedAt,
|
|
MessageCount: len(messages),
|
|
UserMessageCount: realUserCount,
|
|
File: FileInfo{
|
|
Path: path,
|
|
Size: info.Size(),
|
|
Mtime: info.ModTime().UnixNano(),
|
|
},
|
|
}
|
|
|
|
accumulateMessageTokenUsage(sess, messages)
|
|
|
|
return sess, messages, nil
|
|
}
|
|
|
|
// applyQClawAssistantUsage copies the assistant turn's model id
|
|
// and per-message token counts into pm so the usage dashboard can
|
|
// attribute cost. QClaw uses its own usage shape — short field
|
|
// names (input, output, cacheRead, cacheWrite) under message.usage,
|
|
// with provider/model on message itself. We map the token fields
|
|
// onto the agentsview-native input_tokens/output_tokens/
|
|
// cache_creation_input_tokens/cache_read_input_tokens keys that
|
|
// internal/db/usage.go reads.
|
|
//
|
|
// Cost (message.usage.cost.total) is intentionally not propagated:
|
|
// agentsview re-prices via the model_pricing table (loaded from
|
|
// LiteLLM), so trusting the gateway's at-request cost would skew
|
|
// totals against the canonical pricing source. The model name is
|
|
// the load-bearing field for accurate pricing lookup.
|
|
//
|
|
// Defensive about missing fields — older sessions may carry a model
|
|
// without a usage block, or a usage block without cost; either is
|
|
// fine.
|
|
func applyQClawAssistantUsage(
|
|
pm *ParsedMessage, msg gjson.Result,
|
|
) {
|
|
if model := msg.Get("model").Str; model != "" {
|
|
pm.Model = model
|
|
}
|
|
|
|
usage := msg.Get("usage")
|
|
if !usage.Exists() {
|
|
return
|
|
}
|
|
|
|
var (
|
|
input int
|
|
output int
|
|
cacheRead int
|
|
cacheWrite int
|
|
|
|
hasInput bool
|
|
hasOutput bool
|
|
hasCacheRead bool
|
|
hasCacheWrite bool
|
|
)
|
|
if f := usage.Get("input"); f.Exists() {
|
|
input = int(f.Int())
|
|
hasInput = true
|
|
}
|
|
if f := usage.Get("output"); f.Exists() {
|
|
output = int(f.Int())
|
|
hasOutput = true
|
|
}
|
|
if f := usage.Get("cacheRead"); f.Exists() {
|
|
cacheRead = int(f.Int())
|
|
hasCacheRead = true
|
|
}
|
|
if f := usage.Get("cacheWrite"); f.Exists() {
|
|
cacheWrite = int(f.Int())
|
|
hasCacheWrite = true
|
|
}
|
|
|
|
if !hasInput && !hasOutput && !hasCacheRead && !hasCacheWrite {
|
|
return
|
|
}
|
|
|
|
normalized := map[string]int{
|
|
"input_tokens": input,
|
|
"output_tokens": output,
|
|
"cache_read_input_tokens": cacheRead,
|
|
"cache_creation_input_tokens": cacheWrite,
|
|
}
|
|
j, err := json.Marshal(normalized)
|
|
if err != nil {
|
|
return
|
|
}
|
|
pm.TokenUsage = j
|
|
pm.OutputTokens = output
|
|
pm.HasOutputTokens = hasOutput
|
|
pm.ContextTokens = input + cacheRead + cacheWrite
|
|
pm.HasContextTokens = hasInput || hasCacheRead || hasCacheWrite
|
|
}
|
|
|
|
// extractQClawToolResultText extracts plain text from a QClaw
|
|
// tool result content field (which is an array of blocks).
|
|
func extractQClawToolResultText(content gjson.Result) string {
|
|
if content.Type == gjson.String {
|
|
return content.Str
|
|
}
|
|
if !content.IsArray() {
|
|
return ""
|
|
}
|
|
|
|
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
|
|
})
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
// IsQClawSessionFile reports whether a filename is a QClaw
|
|
// session file. It matches active files (*.jsonl) and the known
|
|
// archive suffixes: .jsonl.deleted.<ts>, .jsonl.reset.<ts>, and
|
|
// .jsonl.full.bak.
|
|
func IsQClawSessionFile(name string) bool {
|
|
if strings.HasSuffix(name, ".jsonl") {
|
|
return true
|
|
}
|
|
idx := strings.Index(name, ".jsonl.")
|
|
if idx <= 0 {
|
|
return false
|
|
}
|
|
suffix := name[idx+len(".jsonl."):]
|
|
return strings.HasPrefix(suffix, "deleted.") ||
|
|
strings.HasPrefix(suffix, "reset.") ||
|
|
suffix == "full.bak"
|
|
}
|
|
|
|
// QClawSessionID extracts the session UUID from a QClaw
|
|
// session filename, stripping any archive suffix.
|
|
// "abc.jsonl" → "abc"
|
|
// "abc.jsonl.deleted.2026-02-19T08-59-24.951Z" → "abc"
|
|
// "abc.jsonl.full.bak" → "abc"
|
|
func QClawSessionID(name string) string {
|
|
if idx := strings.Index(name, ".jsonl"); idx > 0 {
|
|
return name[:idx]
|
|
}
|
|
return strings.TrimSuffix(name, ".jsonl")
|
|
}
|
|
|
|
// qClawAgentIDFromPath extracts the agent subdirectory name
|
|
// from a QClaw session file path. The expected layout is
|
|
// <agentsDir>/<agentId>/sessions/<sessionId>.jsonl, so the
|
|
// agent ID is the grandparent directory of the file.
|
|
func qClawAgentIDFromPath(path string) string {
|
|
// path = .../agents/<agentId>/sessions/<file>.jsonl
|
|
sessionsDir := filepath.Dir(path) // .../agents/<agentId>/sessions
|
|
agentDir := filepath.Dir(sessionsDir) // .../agents/<agentId>
|
|
name := filepath.Base(agentDir)
|
|
if name == "" || name == "." || name == "/" {
|
|
return "unknown"
|
|
}
|
|
return name
|
|
}
|
|
|
|
// stripQClawDatePrefix removes the gateway-injected date
|
|
// prefix from user messages. QClaw prepends timestamps like
|
|
// "[Wed 2026-02-18 11:21 GMT+1] " to messages received via
|
|
// Telegram/channels. We strip this so session titles are clean.
|
|
func stripQClawDatePrefix(s string) string {
|
|
if len(s) < 2 || s[0] != '[' {
|
|
return s
|
|
}
|
|
idx := strings.Index(s, "] ")
|
|
if idx < 0 || idx > 40 {
|
|
return s
|
|
}
|
|
return strings.TrimSpace(s[idx+2:])
|
|
}
|
|
|
|
// parseQClawTimestamp extracts and parses the timestamp from
|
|
// any QClaw JSONL entry.
|
|
func parseQClawTimestamp(line string) time.Time {
|
|
tsStr := gjson.Get(line, "timestamp").Str
|
|
return parseTimestamp(tsStr)
|
|
}
|