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
959 lines
24 KiB
Go
959 lines
24 KiB
Go
package parser
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
const ZCodeDBName = "db.sqlite"
|
|
|
|
const zcodeDBName = ZCodeDBName
|
|
|
|
type zcodeProviderFactory struct {
|
|
def AgentDef
|
|
}
|
|
|
|
func newZcodeProviderFactory(def AgentDef) ProviderFactory {
|
|
return zcodeProviderFactory{def: cloneAgentDef(def)}
|
|
}
|
|
|
|
func (f zcodeProviderFactory) Definition() AgentDef {
|
|
return cloneAgentDef(f.def)
|
|
}
|
|
|
|
func (f zcodeProviderFactory) Capabilities() Capabilities {
|
|
return zcodeProviderCapabilities()
|
|
}
|
|
|
|
func (f zcodeProviderFactory) NewProvider(cfg ProviderConfig) Provider {
|
|
cfg = cfg.Clone()
|
|
cfg.Roots = normalizeZCodeRoots(cfg.Roots)
|
|
spec := zcodeProviderSpec()
|
|
return &dbBackedProvider{
|
|
ProviderBase: ProviderBase{
|
|
Def: cloneAgentDef(f.def),
|
|
Caps: spec.caps,
|
|
Config: cfg,
|
|
},
|
|
spec: spec,
|
|
sources: newDBBackedSourceSet(spec, cfg.Roots),
|
|
}
|
|
}
|
|
|
|
func zcodeProviderCapabilities() Capabilities {
|
|
return Capabilities{
|
|
Source: dbBackedSourceCapabilities(CapabilityNotApplicable),
|
|
Content: ContentCapabilities{
|
|
FirstMessage: CapabilitySupported,
|
|
SessionName: CapabilitySupported,
|
|
Cwd: CapabilitySupported,
|
|
Thinking: CapabilitySupported,
|
|
ToolCalls: CapabilitySupported,
|
|
ToolResults: CapabilitySupported,
|
|
AggregateUsageEvents: CapabilitySupported,
|
|
Model: CapabilitySupported,
|
|
},
|
|
}
|
|
}
|
|
|
|
func zcodeProviderSpec() dbBackedProviderSpec {
|
|
return dbBackedProviderSpec{
|
|
agent: AgentZCode,
|
|
dbName: zcodeDBName,
|
|
findDB: zcodeDBPath,
|
|
listMeta: func(dbPath string) ([]dbBackedSessionMeta, error) {
|
|
return listZCodeSessionMeta(dbPath)
|
|
},
|
|
parse: func(dbPath, sessionID, machine string) ([]ParseResult, error) {
|
|
result, err := parseZCodeSession(dbPath, sessionID, machine)
|
|
if err != nil || result == nil {
|
|
return nil, err
|
|
}
|
|
return []ParseResult{*result}, nil
|
|
},
|
|
caps: zcodeProviderCapabilities(),
|
|
}
|
|
}
|
|
|
|
func normalizeZCodeRoots(roots []string) []string {
|
|
cleaned := cleanJSONLRoots(roots)
|
|
out := make([]string, 0, len(cleaned))
|
|
seen := make(map[string]struct{}, len(cleaned))
|
|
for _, root := range cleaned {
|
|
normalized := normalizeZCodeRoot(root)
|
|
if normalized == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[normalized]; ok {
|
|
continue
|
|
}
|
|
seen[normalized] = struct{}{}
|
|
out = append(out, normalized)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeZCodeRoot(root string) string {
|
|
root = filepath.Clean(root)
|
|
if root == "" || root == "." {
|
|
return ""
|
|
}
|
|
if filepath.Base(root) == "db" {
|
|
return root
|
|
}
|
|
return filepath.Join(root, "db")
|
|
}
|
|
|
|
func zcodeDBPath(dir string) string {
|
|
if dir == "" {
|
|
return ""
|
|
}
|
|
path := filepath.Join(dir, zcodeDBName)
|
|
info, err := os.Stat(path)
|
|
if err != nil || info.IsDir() {
|
|
return ""
|
|
}
|
|
return path
|
|
}
|
|
|
|
func zcodeVirtualPathParts(path string) (string, string, bool) {
|
|
return ParseVirtualSourcePathForBase(path, zcodeDBName)
|
|
}
|
|
|
|
func ZCodeSQLiteVirtualPath(dbPath, sessionID string) string {
|
|
return VirtualSourcePath(dbPath, sessionID)
|
|
}
|
|
|
|
func ZCodeSQLiteSourceMtime(path string) (int64, error) {
|
|
dbPath, sessionID, ok := zcodeVirtualPathParts(path)
|
|
if !ok {
|
|
return 0, fmt.Errorf("not a zcode sqlite virtual path: %s", path)
|
|
}
|
|
db, err := openZCodeDB(dbPath)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer db.Close()
|
|
|
|
row, err := loadZCodeSessionRow(db, sessionID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return zcodeSessionFileMtime(dbPath, db, row), nil
|
|
}
|
|
|
|
func listZCodeSessionMeta(dbPath string) ([]dbBackedSessionMeta, error) {
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
db, err := openZCodeDB(dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
rows, err := db.Query(`
|
|
SELECT id,
|
|
project_id,
|
|
workspace_id,
|
|
COALESCE(directory, ''),
|
|
COALESCE(title, ''),
|
|
COALESCE(time_created, ''),
|
|
COALESCE(time_updated, '')
|
|
FROM session
|
|
ORDER BY COALESCE(time_updated, time_created), id
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing zcode sessions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var metas []dbBackedSessionMeta
|
|
for rows.Next() {
|
|
row, err := scanZCodeSessionRow(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if row.id == "" {
|
|
continue
|
|
}
|
|
metas = append(metas, dbBackedSessionMeta{
|
|
SessionID: row.id,
|
|
VirtualPath: ZCodeSQLiteVirtualPath(dbPath, row.id),
|
|
FileMtime: zcodeSessionFileMtime(dbPath, db, row),
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(metas, func(i, j int) bool {
|
|
return metas[i].SessionID < metas[j].SessionID
|
|
})
|
|
return metas, nil
|
|
}
|
|
|
|
func parseZCodeSession(dbPath, sessionID, machine string) (*ParseResult, error) {
|
|
db, err := openZCodeDB(dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
row, err := loadZCodeSessionRow(db, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := buildZCodeParseResult(dbPath, machine, row, db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func openZCodeDB(dbPath string) (*sql.DB, error) {
|
|
dsn := "file:" + sqliteURIPath(dbPath) + "?mode=ro&immutable=0&_busy_timeout=3000"
|
|
db, err := sql.Open("sqlite3", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening zcode db %s: %w", dbPath, err)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
type zcodeSessionRow struct {
|
|
id string
|
|
projectID sql.NullString
|
|
workspaceID sql.NullString
|
|
directory sql.NullString
|
|
title sql.NullString
|
|
timeCreated sql.NullString
|
|
timeUpdated sql.NullString
|
|
}
|
|
|
|
type zcodeUsageRow struct {
|
|
sessionID string
|
|
turnID sql.NullString
|
|
providerID sql.NullString
|
|
modelID sql.NullString
|
|
status sql.NullString
|
|
inputTokens sql.NullInt64
|
|
outputTokens sql.NullInt64
|
|
reasoningTokens sql.NullInt64
|
|
cacheCreationInputTokens sql.NullInt64
|
|
cacheReadInputTokens sql.NullInt64
|
|
computedTotalTokens sql.NullInt64
|
|
startedAt sql.NullString
|
|
completedAt sql.NullString
|
|
durationMS sql.NullInt64
|
|
toolCallCount sql.NullInt64
|
|
}
|
|
|
|
type zcodeMessageRow struct {
|
|
id string
|
|
data string
|
|
timeCreated string
|
|
}
|
|
|
|
type zcodePartRow struct {
|
|
id string
|
|
messageID string
|
|
timeCreated string
|
|
data string
|
|
}
|
|
|
|
func loadZCodeSessionRow(db *sql.DB, sessionID string) (zcodeSessionRow, error) {
|
|
row := db.QueryRow(`
|
|
SELECT id,
|
|
project_id,
|
|
workspace_id,
|
|
COALESCE(directory, ''),
|
|
COALESCE(title, ''),
|
|
COALESCE(time_created, ''),
|
|
COALESCE(time_updated, '')
|
|
FROM session
|
|
WHERE id = ?
|
|
`, sessionID)
|
|
|
|
var out zcodeSessionRow
|
|
err := row.Scan(
|
|
&out.id, &out.projectID, &out.workspaceID,
|
|
&out.directory, &out.title, &out.timeCreated, &out.timeUpdated,
|
|
)
|
|
if err != nil {
|
|
return zcodeSessionRow{}, fmt.Errorf("loading zcode session %s: %w", sessionID, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func scanZCodeSessionRow(rows *sql.Rows) (zcodeSessionRow, error) {
|
|
var out zcodeSessionRow
|
|
if err := rows.Scan(
|
|
&out.id, &out.projectID, &out.workspaceID,
|
|
&out.directory, &out.title, &out.timeCreated, &out.timeUpdated,
|
|
); err != nil {
|
|
return zcodeSessionRow{}, fmt.Errorf("scanning zcode session meta: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func buildZCodeParseResult(
|
|
dbPath, machine string,
|
|
row zcodeSessionRow,
|
|
db *sql.DB,
|
|
) (ParseResult, error) {
|
|
startedAt := zcodeParseTime(row.timeCreated.String)
|
|
endedAt := zcodeParseTime(row.timeUpdated.String)
|
|
if startedAt.IsZero() {
|
|
startedAt = endedAt
|
|
}
|
|
if endedAt.IsZero() {
|
|
endedAt = startedAt
|
|
}
|
|
|
|
directory := row.directory.String
|
|
project := ExtractProjectFromCwd(directory)
|
|
if project == "" {
|
|
switch {
|
|
case row.projectID.Valid:
|
|
project = "project-" + strings.TrimSpace(row.projectID.String)
|
|
case row.workspaceID.Valid:
|
|
project = "workspace-" + strings.TrimSpace(row.workspaceID.String)
|
|
default:
|
|
project = "unknown"
|
|
}
|
|
}
|
|
|
|
title := strings.TrimSpace(row.title.String)
|
|
firstMessage := title
|
|
if firstMessage != "" {
|
|
firstMessage = truncate(strings.ReplaceAll(firstMessage, "\n", " "), 300)
|
|
}
|
|
|
|
msgs, err := loadZCodeMessages(db, row.id)
|
|
if err != nil {
|
|
return ParseResult{}, err
|
|
}
|
|
parts, err := loadZCodeParts(db, row.id)
|
|
if err != nil {
|
|
return ParseResult{}, err
|
|
}
|
|
parsedMessages := buildZCodeMessages(msgs, parts)
|
|
userMessageCount := 0
|
|
for _, msg := range parsedMessages {
|
|
if msg.Role == RoleUser {
|
|
userMessageCount++
|
|
}
|
|
}
|
|
|
|
sess := ParsedSession{
|
|
ID: "zcode:" + row.id,
|
|
Project: project,
|
|
Machine: machine,
|
|
Agent: AgentZCode,
|
|
Cwd: directory,
|
|
FirstMessage: firstMessage,
|
|
SessionName: title,
|
|
StartedAt: startedAt,
|
|
EndedAt: endedAt,
|
|
MessageCount: len(parsedMessages),
|
|
UserMessageCount: userMessageCount,
|
|
File: FileInfo{
|
|
Path: ZCodeSQLiteVirtualPath(dbPath, row.id),
|
|
},
|
|
}
|
|
if info, err := os.Stat(dbPath); err == nil {
|
|
sess.File.Size = info.Size()
|
|
}
|
|
sess.File.Mtime = zcodeSessionFileMtime(dbPath, db, row)
|
|
|
|
usageEvents, err := listZCodeUsageEvents(db, row.id, startedAt, endedAt)
|
|
if err != nil {
|
|
return ParseResult{}, err
|
|
}
|
|
applyUsageEventTokenTotals(&sess, usageEvents)
|
|
|
|
return ParseResult{
|
|
Session: sess,
|
|
Messages: parsedMessages,
|
|
UsageEvents: usageEvents,
|
|
}, nil
|
|
}
|
|
|
|
func loadZCodeMessages(
|
|
db *sql.DB, sessionID string,
|
|
) ([]zcodeMessageRow, error) {
|
|
rows, err := db.Query(`
|
|
SELECT id,
|
|
COALESCE(data, '{}'),
|
|
CAST(COALESCE(time_created, '') AS TEXT)
|
|
FROM message
|
|
WHERE session_id = ?
|
|
ORDER BY CAST(COALESCE(time_created, '') AS TEXT), id
|
|
`, sessionID)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "no such table: message") {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("listing zcode messages for %s: %w", sessionID, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
msgs := make([]zcodeMessageRow, 0)
|
|
for rows.Next() {
|
|
var row zcodeMessageRow
|
|
if err := rows.Scan(&row.id, &row.data, &row.timeCreated); err != nil {
|
|
return nil, fmt.Errorf("scanning zcode message row: %w", err)
|
|
}
|
|
if row.id == "" {
|
|
continue
|
|
}
|
|
msgs = append(msgs, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return msgs, nil
|
|
}
|
|
|
|
func loadZCodeParts(
|
|
db *sql.DB, sessionID string,
|
|
) (map[string][]zcodePartRow, error) {
|
|
selectTime := `''`
|
|
orderBy := `id`
|
|
if has, err := zcodeTableHasColumn(db, "part", "time_created"); err != nil {
|
|
return nil, err
|
|
} else if has {
|
|
selectTime = `CAST(COALESCE(time_created, '') AS TEXT)`
|
|
orderBy = selectTime + `, id`
|
|
}
|
|
|
|
rows, err := db.Query(`
|
|
SELECT id,
|
|
message_id,
|
|
`+selectTime+`,
|
|
COALESCE(data, '{}')
|
|
FROM part
|
|
WHERE session_id = ?
|
|
ORDER BY `+orderBy, sessionID)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "no such table: part") {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("listing zcode parts for %s: %w", sessionID, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
parts := make(map[string][]zcodePartRow)
|
|
for rows.Next() {
|
|
var row zcodePartRow
|
|
if err := rows.Scan(&row.id, &row.messageID, &row.timeCreated, &row.data); err != nil {
|
|
return nil, fmt.Errorf("scanning zcode part row: %w", err)
|
|
}
|
|
if row.messageID == "" {
|
|
continue
|
|
}
|
|
parts[row.messageID] = append(parts[row.messageID], row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
func buildZCodeMessages(
|
|
msgs []zcodeMessageRow,
|
|
parts map[string][]zcodePartRow,
|
|
) []ParsedMessage {
|
|
if msgs == nil || parts == nil {
|
|
return nil
|
|
}
|
|
|
|
parsed := make([]ParsedMessage, 0, len(msgs))
|
|
for _, row := range msgs {
|
|
msg, ok := buildZCodeMessage(len(parsed), row, parts[row.id])
|
|
if !ok {
|
|
continue
|
|
}
|
|
parsed = append(parsed, msg)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func buildZCodeMessage(
|
|
ordinal int,
|
|
row zcodeMessageRow,
|
|
parts []zcodePartRow,
|
|
) (ParsedMessage, bool) {
|
|
role, ok := normalizeZCodeRole(gjson.Get(row.data, "role").Str)
|
|
if !ok {
|
|
return ParsedMessage{}, false
|
|
}
|
|
|
|
msg := ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: role,
|
|
Timestamp: zcodeParseTime(row.timeCreated),
|
|
Model: zcodeMessageModel(row.data),
|
|
IsSystem: role == RoleSystem,
|
|
}
|
|
|
|
var texts []string
|
|
var thinking []string
|
|
for _, part := range parts {
|
|
block := gjson.Parse(part.data)
|
|
switch block.Get("type").Str {
|
|
case "text":
|
|
if text := zcodeBlockText(block); text != "" {
|
|
texts = append(texts, text)
|
|
}
|
|
case "thinking", "reasoning":
|
|
text := zcodeThinkingText(block)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
msg.HasThinking = true
|
|
thinking = append(thinking, text)
|
|
texts = append(texts, "[Thinking]\n"+text+"\n[/Thinking]")
|
|
case "tool_use", "tool":
|
|
msg.HasToolUse = true
|
|
if tc, ok := zcodeParseToolCall(block); ok {
|
|
msg.ToolCalls = append(msg.ToolCalls, tc)
|
|
}
|
|
if tr, ok := zcodeParseToolResult(block); ok {
|
|
msg.ToolResults = append(msg.ToolResults, tr)
|
|
}
|
|
case "tool_result":
|
|
if tr, ok := zcodeParseToolResult(block); ok {
|
|
msg.ToolResults = append(msg.ToolResults, tr)
|
|
}
|
|
}
|
|
}
|
|
|
|
msg.Content = strings.Join(texts, "\n")
|
|
msg.ThinkingText = strings.Join(thinking, "\n\n")
|
|
msg.ContentLength = len(msg.Content)
|
|
return msg, true
|
|
}
|
|
|
|
func normalizeZCodeRole(role string) (RoleType, bool) {
|
|
switch strings.ToLower(strings.TrimSpace(role)) {
|
|
case "user":
|
|
return RoleUser, true
|
|
case "assistant", "model":
|
|
return RoleAssistant, true
|
|
case "system":
|
|
return RoleSystem, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func zcodeMessageModel(raw string) string {
|
|
for _, path := range []string{
|
|
"modelID",
|
|
"model_id",
|
|
"model.modelID",
|
|
"model.id",
|
|
"model.name",
|
|
"model",
|
|
} {
|
|
if model := strings.TrimSpace(gjson.Get(raw, path).Str); model != "" {
|
|
return model
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func zcodeBlockText(block gjson.Result) string {
|
|
for _, path := range []string{"text", "content", "value"} {
|
|
value := block.Get(path)
|
|
if !value.Exists() {
|
|
continue
|
|
}
|
|
if text := decodeContent(value); text != "" {
|
|
return text
|
|
}
|
|
if value.Type == gjson.String && value.Str != "" {
|
|
return value.Str
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func zcodeThinkingText(block gjson.Result) string {
|
|
for _, path := range []string{"thinking", "text", "content"} {
|
|
value := block.Get(path)
|
|
if !value.Exists() {
|
|
continue
|
|
}
|
|
if text := decodeContent(value); text != "" {
|
|
return text
|
|
}
|
|
if value.Type == gjson.String && value.Str != "" {
|
|
return value.Str
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func zcodeParseToolCall(block gjson.Result) (ParsedToolCall, bool) {
|
|
name := block.Get("name").Str
|
|
if name == "" {
|
|
name = block.Get("tool_name").Str
|
|
}
|
|
if name == "" {
|
|
name = block.Get("toolName").Str
|
|
}
|
|
if name == "" {
|
|
name = block.Get("tool").Str
|
|
}
|
|
if name == "" {
|
|
return ParsedToolCall{}, false
|
|
}
|
|
|
|
input := toolCallInput(block)
|
|
if input.Raw == "" || input.Raw == "{}" {
|
|
if stateInput := block.Get("state.input"); stateInput.Exists() {
|
|
input = stateInput
|
|
}
|
|
}
|
|
inputJSON := input.Raw
|
|
if inputJSON == "" {
|
|
inputJSON = "{}"
|
|
}
|
|
toolUseID := block.Get("id").Str
|
|
if toolUseID == "" {
|
|
toolUseID = block.Get("tool_use_id").Str
|
|
}
|
|
if toolUseID == "" {
|
|
toolUseID = block.Get("toolUseID").Str
|
|
}
|
|
if toolUseID == "" {
|
|
toolUseID = block.Get("callID").Str
|
|
}
|
|
if toolUseID == "" {
|
|
toolUseID = block.Get("callId").Str
|
|
}
|
|
|
|
call := ParsedToolCall{
|
|
ToolUseID: toolUseID,
|
|
ToolName: name,
|
|
Category: NormalizeToolCategory(name),
|
|
InputJSON: inputJSON,
|
|
}
|
|
switch name {
|
|
case "Skill", "skill":
|
|
call.SkillName = input.Get("skill").Str
|
|
if call.SkillName == "" {
|
|
call.SkillName = input.Get("name").Str
|
|
}
|
|
default:
|
|
call.SkillName = inferToolSkillName(name, inputJSON)
|
|
}
|
|
return call, true
|
|
}
|
|
|
|
func zcodeParseToolResult(block gjson.Result) (ParsedToolResult, bool) {
|
|
toolUseID := block.Get("tool_use_id").Str
|
|
if toolUseID == "" {
|
|
toolUseID = block.Get("toolUseID").Str
|
|
}
|
|
if toolUseID == "" {
|
|
toolUseID = block.Get("tool_call_id").Str
|
|
}
|
|
if toolUseID == "" {
|
|
toolUseID = block.Get("callID").Str
|
|
}
|
|
if toolUseID == "" {
|
|
toolUseID = block.Get("callId").Str
|
|
}
|
|
if toolUseID == "" {
|
|
return ParsedToolResult{}, false
|
|
}
|
|
|
|
content := block.Get("content")
|
|
if !content.Exists() || content.Type == gjson.Null {
|
|
content = block.Get("result")
|
|
}
|
|
if !content.Exists() || content.Type == gjson.Null {
|
|
content = block.Get("output")
|
|
}
|
|
if !content.Exists() || content.Type == gjson.Null {
|
|
content = block.Get("state.output")
|
|
}
|
|
if !content.Exists() || content.Type == gjson.Null {
|
|
content = block.Get("state.error")
|
|
}
|
|
if content.Exists() && content.Type != gjson.Null {
|
|
return ParsedToolResult{
|
|
ToolUseID: toolUseID,
|
|
ContentLength: toolResultContentLength(content),
|
|
ContentRaw: content.Raw,
|
|
}, true
|
|
}
|
|
|
|
for _, key := range []string{"text", "error", "value"} {
|
|
if text := block.Get(key).Str; text != "" {
|
|
return ParsedToolResult{
|
|
ToolUseID: toolUseID,
|
|
ContentLength: len(text),
|
|
ContentRaw: strconv.Quote(text),
|
|
}, true
|
|
}
|
|
}
|
|
return ParsedToolResult{}, false
|
|
}
|
|
|
|
func zcodeTableHasColumn(db *sql.DB, table, column string) (bool, error) {
|
|
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
|
if err != nil {
|
|
return false, fmt.Errorf("listing zcode table info for %s: %w", table, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var (
|
|
cid int
|
|
name string
|
|
typeName string
|
|
notNull int
|
|
defaultV sql.NullString
|
|
primaryKey int
|
|
)
|
|
if err := rows.Scan(
|
|
&cid, &name, &typeName, ¬Null, &defaultV, &primaryKey,
|
|
); err != nil {
|
|
return false, fmt.Errorf("scanning zcode table info for %s: %w", table, err)
|
|
}
|
|
if strings.EqualFold(name, column) {
|
|
return true, nil
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func listZCodeUsageEvents(
|
|
db *sql.DB,
|
|
sessionID string,
|
|
startedAt, endedAt time.Time,
|
|
) ([]ParsedUsageEvent, error) {
|
|
rows, err := db.Query(`
|
|
SELECT session_id,
|
|
CAST(turn_id AS TEXT),
|
|
provider_id,
|
|
COALESCE(model_id, ''),
|
|
COALESCE(status, ''),
|
|
COALESCE(input_tokens, 0),
|
|
COALESCE(output_tokens, 0),
|
|
COALESCE(reasoning_tokens, 0),
|
|
COALESCE(cache_creation_input_tokens, 0),
|
|
COALESCE(cache_read_input_tokens, 0),
|
|
COALESCE(computed_total_tokens, 0),
|
|
COALESCE(started_at, ''),
|
|
COALESCE(completed_at, ''),
|
|
COALESCE(duration_ms, 0),
|
|
COALESCE(tool_call_count, 0)
|
|
FROM model_usage
|
|
WHERE session_id = ?
|
|
ORDER BY
|
|
COALESCE(turn_id, -1),
|
|
COALESCE(started_at, ''),
|
|
COALESCE(completed_at, ''),
|
|
COALESCE(provider_id, -1),
|
|
COALESCE(model_id, ''),
|
|
COALESCE(status, ''),
|
|
COALESCE(input_tokens, 0),
|
|
COALESCE(output_tokens, 0),
|
|
COALESCE(reasoning_tokens, 0),
|
|
COALESCE(cache_creation_input_tokens, 0),
|
|
COALESCE(cache_read_input_tokens, 0),
|
|
COALESCE(computed_total_tokens, 0),
|
|
COALESCE(duration_ms, 0),
|
|
COALESCE(tool_call_count, 0)
|
|
`, sessionID)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "no such table: model_usage") {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("listing zcode usage rows for %s: %w", sessionID, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var events []ParsedUsageEvent
|
|
for rows.Next() {
|
|
row, err := scanZCodeUsageRow(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if row.sessionID == "" {
|
|
continue
|
|
}
|
|
fullSessionID := "zcode:" + row.sessionID
|
|
ev := ParsedUsageEvent{
|
|
SessionID: fullSessionID,
|
|
Source: "session",
|
|
Model: row.modelID.String,
|
|
InputTokens: int(row.inputTokens.Int64),
|
|
OutputTokens: int(row.outputTokens.Int64),
|
|
CacheCreationInputTokens: int(row.cacheCreationInputTokens.Int64),
|
|
CacheReadInputTokens: int(row.cacheReadInputTokens.Int64),
|
|
ReasoningTokens: int(row.reasoningTokens.Int64),
|
|
OccurredAt: timeString(zcodeUsageTime(row.completedAt.String), zcodeUsageTime(row.startedAt.String)),
|
|
DedupKey: zcodeUsageDedupKey(fullSessionID, row),
|
|
}
|
|
if row.turnID.Valid {
|
|
if parsed, err := strconv.Atoi(strings.TrimSpace(row.turnID.String)); err == nil {
|
|
ev.MessageOrdinal = &parsed
|
|
}
|
|
}
|
|
if ev.OccurredAt == "" {
|
|
ev.OccurredAt = timeString(endedAt, startedAt)
|
|
}
|
|
events = append(events, ev)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
func scanZCodeUsageRow(rows *sql.Rows) (zcodeUsageRow, error) {
|
|
var out zcodeUsageRow
|
|
if err := rows.Scan(
|
|
&out.sessionID, &out.turnID, &out.providerID,
|
|
&out.modelID, &out.status, &out.inputTokens, &out.outputTokens,
|
|
&out.reasoningTokens, &out.cacheCreationInputTokens,
|
|
&out.cacheReadInputTokens, &out.computedTotalTokens,
|
|
&out.startedAt, &out.completedAt, &out.durationMS,
|
|
&out.toolCallCount,
|
|
); err != nil {
|
|
return zcodeUsageRow{}, fmt.Errorf("scanning zcode usage row: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func zcodeUsageDedupKey(fullSessionID string, row zcodeUsageRow) string {
|
|
parts := []string{
|
|
"session:" + fullSessionID,
|
|
"turn=" + row.turnID.String,
|
|
"provider=" + row.providerID.String,
|
|
"model=" + row.modelID.String,
|
|
"status=" + row.status.String,
|
|
"started_at=" + row.startedAt.String,
|
|
"completed_at=" + row.completedAt.String,
|
|
"duration_ms=" + zcodeNullInt64String(row.durationMS),
|
|
"tool_call_count=" + zcodeNullInt64String(row.toolCallCount),
|
|
"input_tokens=" + zcodeNullInt64String(row.inputTokens),
|
|
"output_tokens=" + zcodeNullInt64String(row.outputTokens),
|
|
"reasoning_tokens=" + zcodeNullInt64String(row.reasoningTokens),
|
|
"cache_creation_input_tokens=" + zcodeNullInt64String(row.cacheCreationInputTokens),
|
|
"cache_read_input_tokens=" + zcodeNullInt64String(row.cacheReadInputTokens),
|
|
"computed_total_tokens=" + zcodeNullInt64String(row.computedTotalTokens),
|
|
}
|
|
return strings.Join(parts, "|")
|
|
}
|
|
|
|
func zcodeNullInt64String(v sql.NullInt64) string {
|
|
if !v.Valid {
|
|
return ""
|
|
}
|
|
return strconv.FormatInt(v.Int64, 10)
|
|
}
|
|
|
|
func zcodeParseTime(raw string) time.Time {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return time.Time{}
|
|
}
|
|
if ts := parseTimestamp(raw); !ts.IsZero() {
|
|
return ts
|
|
}
|
|
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
|
switch {
|
|
case n >= 1_000_000_000_000_000_000:
|
|
return time.Unix(0, n).UTC()
|
|
case n >= 1_000_000_000_000:
|
|
return time.UnixMilli(n).UTC()
|
|
case n >= 1_000_000_000:
|
|
return time.Unix(n, 0).UTC()
|
|
default:
|
|
return time.Unix(n, 0).UTC()
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func zcodeUsageTime(raw string) time.Time {
|
|
return zcodeParseTime(raw)
|
|
}
|
|
|
|
func zcodeSessionFileMtime(dbPath string, db *sql.DB, row zcodeSessionRow) int64 {
|
|
maxMtime := zcodeTimeUnixNano(zcodeRowUpdatedAt(row))
|
|
if usageMtime, err := zcodeMaxUsageMtime(db, row.id); err == nil {
|
|
maxMtime = max(maxMtime, usageMtime)
|
|
}
|
|
for _, path := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
|
|
if info, err := os.Stat(path); err == nil {
|
|
maxMtime = max(maxMtime, info.ModTime().UnixNano())
|
|
}
|
|
}
|
|
return maxMtime
|
|
}
|
|
|
|
func zcodeMaxUsageMtime(db *sql.DB, sessionID string) (int64, error) {
|
|
rows, err := db.Query(`
|
|
SELECT CAST(COALESCE(completed_at, '') AS TEXT),
|
|
CAST(COALESCE(started_at, '') AS TEXT)
|
|
FROM model_usage
|
|
WHERE session_id = ?
|
|
`, sessionID)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "no such table: model_usage") {
|
|
return 0, nil
|
|
}
|
|
return 0, fmt.Errorf("listing zcode usage mtimes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var maxMtime int64
|
|
for rows.Next() {
|
|
var completedAt, startedAt string
|
|
if err := rows.Scan(&completedAt, &startedAt); err != nil {
|
|
return 0, fmt.Errorf("scanning zcode usage mtime: %w", err)
|
|
}
|
|
maxMtime = max(maxMtime, zcodeTimeUnixNano(zcodeUsageTime(completedAt)))
|
|
maxMtime = max(maxMtime, zcodeTimeUnixNano(zcodeUsageTime(startedAt)))
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
return maxMtime, nil
|
|
}
|
|
|
|
func zcodeTimeUnixNano(ts time.Time) int64 {
|
|
if ts.IsZero() {
|
|
return 0
|
|
}
|
|
return ts.UnixNano()
|
|
}
|
|
|
|
func zcodeRowUpdatedAt(row zcodeSessionRow) time.Time {
|
|
if ts := zcodeParseTime(row.timeUpdated.String); !ts.IsZero() {
|
|
return ts
|
|
}
|
|
if ts := zcodeParseTime(row.timeCreated.String); !ts.IsZero() {
|
|
return ts
|
|
}
|
|
return time.Time{}
|
|
}
|