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
496 lines
12 KiB
Go
496 lines
12 KiB
Go
package parser
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// WarpDBFilename is the Warp session store filename inside its data dir.
|
|
const WarpDBFilename = "warp.sqlite"
|
|
|
|
// WarpSessionMeta is lightweight metadata for a session,
|
|
// used to detect changes without parsing messages.
|
|
type WarpSessionMeta struct {
|
|
SessionID string
|
|
VirtualPath string
|
|
FileMtime int64 // last_modified_at as UnixNano
|
|
}
|
|
|
|
// ListWarpSessionMeta returns lightweight metadata for all
|
|
// conversations without parsing exchanges. Used by the sync
|
|
// engine to detect which sessions have changed.
|
|
func ListWarpSessionMeta(
|
|
dbPath string,
|
|
) ([]WarpSessionMeta, error) {
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
|
|
db, err := openWarpDB(dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
rows, err := db.Query(
|
|
`SELECT conversation_id, last_modified_at
|
|
FROM agent_conversations`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"listing warp conversations: %w", err,
|
|
)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var metas []WarpSessionMeta
|
|
for rows.Next() {
|
|
var id string
|
|
var lastModified string
|
|
if err := rows.Scan(
|
|
&id, &lastModified,
|
|
); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"scanning warp session meta: %w", err,
|
|
)
|
|
}
|
|
mtime := parseWarpTimestamp(lastModified).UnixNano()
|
|
metas = append(metas, WarpSessionMeta{
|
|
SessionID: id,
|
|
VirtualPath: dbPath + "#" + id,
|
|
FileMtime: mtime,
|
|
})
|
|
}
|
|
return metas, rows.Err()
|
|
}
|
|
|
|
// parseWarpSession parses a single conversation by ID from
|
|
// the Warp database.
|
|
func parseWarpSession(
|
|
dbPath, conversationID, machine string,
|
|
) (*ParsedSession, []ParsedMessage, error) {
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
return nil, nil, fmt.Errorf(
|
|
"warp db not found: %s", dbPath,
|
|
)
|
|
}
|
|
|
|
db, err := openWarpDB(dbPath)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
c, err := loadOneWarpConversation(db, conversationID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"loading warp conversation %s: %w",
|
|
conversationID, err,
|
|
)
|
|
}
|
|
|
|
return buildWarpSession(db, c, dbPath, machine)
|
|
}
|
|
|
|
func openWarpDB(dbPath string) (*sql.DB, error) {
|
|
dsn := "file:" + sqliteURIPath(dbPath) +
|
|
"?mode=ro&_busy_timeout=3000"
|
|
db, err := sql.Open("sqlite3", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"opening warp db %s: %w", dbPath, err,
|
|
)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// warpConversationRow is a row from agent_conversations.
|
|
type warpConversationRow struct {
|
|
id string
|
|
conversationData string
|
|
lastModifiedAt string
|
|
}
|
|
|
|
func loadOneWarpConversation(
|
|
db *sql.DB, conversationID string,
|
|
) (warpConversationRow, error) {
|
|
row := db.QueryRow(`
|
|
SELECT conversation_id,
|
|
COALESCE(conversation_data, '{}'),
|
|
last_modified_at
|
|
FROM agent_conversations
|
|
WHERE conversation_id = ?
|
|
`, conversationID)
|
|
|
|
var c warpConversationRow
|
|
err := row.Scan(
|
|
&c.id, &c.conversationData, &c.lastModifiedAt,
|
|
)
|
|
return c, err
|
|
}
|
|
|
|
// warpExchangeRow is a row from ai_queries.
|
|
type warpExchangeRow struct {
|
|
exchangeID string
|
|
startTS string
|
|
input string
|
|
modelID string
|
|
workingDir string
|
|
outputStatus string
|
|
}
|
|
|
|
func loadWarpExchanges(
|
|
db *sql.DB, conversationID string,
|
|
) ([]warpExchangeRow, error) {
|
|
rows, err := db.Query(`
|
|
SELECT exchange_id, start_ts,
|
|
COALESCE(input, '[]'),
|
|
COALESCE(model_id, ''),
|
|
COALESCE(working_directory, ''),
|
|
COALESCE(output_status, '')
|
|
FROM ai_queries
|
|
WHERE conversation_id = ?
|
|
ORDER BY start_ts
|
|
`, conversationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var exchanges []warpExchangeRow
|
|
for rows.Next() {
|
|
var e warpExchangeRow
|
|
if err := rows.Scan(
|
|
&e.exchangeID, &e.startTS, &e.input,
|
|
&e.modelID, &e.workingDir, &e.outputStatus,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
exchanges = append(exchanges, e)
|
|
}
|
|
return exchanges, rows.Err()
|
|
}
|
|
|
|
func buildWarpSession(
|
|
db *sql.DB,
|
|
c warpConversationRow,
|
|
dbPath, machine string,
|
|
) (*ParsedSession, []ParsedMessage, error) {
|
|
exchanges, err := loadWarpExchanges(db, c.id)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"loading exchanges for %s: %w", c.id, err,
|
|
)
|
|
}
|
|
|
|
if len(exchanges) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
var (
|
|
parsed []ParsedMessage
|
|
firstMsg string
|
|
startedAt time.Time
|
|
endedAt time.Time
|
|
project string
|
|
cwd string
|
|
ordinal int
|
|
userCount int
|
|
lastModel string
|
|
)
|
|
|
|
for _, e := range exchanges {
|
|
ts := parseWarpTimestamp(e.startTS)
|
|
if startedAt.IsZero() {
|
|
startedAt = ts
|
|
}
|
|
endedAt = ts
|
|
if e.modelID != "" {
|
|
lastModel = normalizeWarpModel(e.modelID)
|
|
}
|
|
if e.workingDir != "" && cwd == "" {
|
|
cwd = e.workingDir
|
|
}
|
|
|
|
queryText := extractWarpQueryText(e.input)
|
|
if queryText == "" {
|
|
// Exchange without user input (assistant
|
|
// tool call / intermediate step). Count it
|
|
// for timing but don't create a message.
|
|
continue
|
|
}
|
|
|
|
// User message.
|
|
parsed = append(parsed, ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleUser,
|
|
Content: queryText,
|
|
Timestamp: ts,
|
|
ContentLength: len(queryText),
|
|
Model: lastModel,
|
|
})
|
|
ordinal++
|
|
userCount++
|
|
|
|
if firstMsg == "" {
|
|
firstMsg = truncate(
|
|
strings.ReplaceAll(queryText, "\n", " "),
|
|
300,
|
|
)
|
|
}
|
|
}
|
|
|
|
if len(parsed) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// Extract project from working directory.
|
|
if cwd != "" {
|
|
project = ExtractProjectFromCwd(cwd)
|
|
}
|
|
if project == "" {
|
|
project = "unknown"
|
|
}
|
|
|
|
// Parse conversation metadata for token usage.
|
|
meta := parseWarpConversationMeta(c.conversationData)
|
|
|
|
// Synthesize tool call messages from aggregate stats.
|
|
toolMsgs := synthesizeWarpToolMessages(
|
|
meta, endedAt, lastModel, &ordinal,
|
|
)
|
|
parsed = append(parsed, toolMsgs...)
|
|
|
|
sess := &ParsedSession{
|
|
ID: "warp:" + c.id,
|
|
Project: project,
|
|
Machine: machine,
|
|
Agent: AgentWarp,
|
|
Cwd: cwd,
|
|
FirstMessage: firstMsg,
|
|
StartedAt: startedAt,
|
|
EndedAt: endedAt,
|
|
MessageCount: len(parsed),
|
|
UserMessageCount: userCount,
|
|
File: FileInfo{
|
|
Path: dbPath + "#" + c.id,
|
|
Mtime: parseWarpTimestamp(c.lastModifiedAt).UnixNano(),
|
|
},
|
|
}
|
|
|
|
// Token usage from conversation metadata.
|
|
if meta.totalTokens > 0 {
|
|
sess.HasTotalOutputTokens = true
|
|
sess.TotalOutputTokens = meta.totalTokens
|
|
}
|
|
|
|
return sess, parsed, nil
|
|
}
|
|
|
|
// warpConversationMeta holds parsed metadata from
|
|
// conversation_data JSON.
|
|
type warpConversationMeta struct {
|
|
totalTokens int
|
|
toolStats warpToolStats
|
|
}
|
|
|
|
type warpToolStats struct {
|
|
RunCommand int
|
|
ReadFiles int
|
|
SearchCodebase int
|
|
Grep int
|
|
FileGlob int
|
|
ApplyFileDiff int
|
|
WriteLongRunning int
|
|
ReadMCPResource int
|
|
CallMCPTool int
|
|
SuggestPlan int
|
|
SuggestCreatePlan int
|
|
ReadShellCmdOutput int
|
|
UseComputer int
|
|
}
|
|
|
|
func parseWarpConversationMeta(data string) warpConversationMeta {
|
|
var meta warpConversationMeta
|
|
if data == "" || data == "{}" {
|
|
return meta
|
|
}
|
|
|
|
var raw struct {
|
|
Usage struct {
|
|
TokenUsage []struct {
|
|
WarpTokens int `json:"warp_tokens"`
|
|
BYOKTokens int `json:"byok_tokens"`
|
|
} `json:"token_usage"`
|
|
ToolUsage struct {
|
|
RunCommand struct{ Count int } `json:"run_command_stats"`
|
|
ReadFiles struct{ Count int } `json:"read_files_stats"`
|
|
SearchCodebase struct{ Count int } `json:"search_codebase_stats"`
|
|
Grep struct{ Count int } `json:"grep_stats"`
|
|
FileGlob struct{ Count int } `json:"file_glob_stats"`
|
|
ApplyFileDiff struct {
|
|
Count int `json:"count"`
|
|
} `json:"apply_file_diff_stats"`
|
|
WriteLongRunning struct{ Count int } `json:"write_to_long_running_shell_command_stats"`
|
|
ReadMCPResource struct{ Count int } `json:"read_mcp_resource_stats"`
|
|
CallMCPTool struct{ Count int } `json:"call_mcp_tool_stats"`
|
|
SuggestPlan struct{ Count int } `json:"suggest_plan_stats"`
|
|
SuggestCreatePlan struct{ Count int } `json:"suggest_create_plan_stats"`
|
|
ReadShellOutput struct{ Count int } `json:"read_shell_command_output_stats"`
|
|
UseComputer struct{ Count int } `json:"use_computer_stats"`
|
|
} `json:"tool_usage_metadata"`
|
|
} `json:"conversation_usage_metadata"`
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
|
return meta
|
|
}
|
|
|
|
for _, tu := range raw.Usage.TokenUsage {
|
|
meta.totalTokens += tu.WarpTokens + tu.BYOKTokens
|
|
}
|
|
|
|
ts := raw.Usage.ToolUsage
|
|
meta.toolStats = warpToolStats{
|
|
RunCommand: ts.RunCommand.Count,
|
|
ReadFiles: ts.ReadFiles.Count,
|
|
SearchCodebase: ts.SearchCodebase.Count,
|
|
Grep: ts.Grep.Count,
|
|
FileGlob: ts.FileGlob.Count,
|
|
ApplyFileDiff: ts.ApplyFileDiff.Count,
|
|
WriteLongRunning: ts.WriteLongRunning.Count,
|
|
ReadMCPResource: ts.ReadMCPResource.Count,
|
|
CallMCPTool: ts.CallMCPTool.Count,
|
|
SuggestPlan: ts.SuggestPlan.Count,
|
|
SuggestCreatePlan: ts.SuggestCreatePlan.Count,
|
|
ReadShellCmdOutput: ts.ReadShellOutput.Count,
|
|
UseComputer: ts.UseComputer.Count,
|
|
}
|
|
|
|
return meta
|
|
}
|
|
|
|
// synthesizeWarpToolMessages creates assistant messages with
|
|
// tool call metadata from aggregate tool usage stats. This
|
|
// gives agentsview accurate tool category breakdowns even
|
|
// though Warp doesn't persist individual tool call records.
|
|
func synthesizeWarpToolMessages(
|
|
meta warpConversationMeta,
|
|
ts time.Time,
|
|
model string,
|
|
ordinal *int,
|
|
) []ParsedMessage {
|
|
type toolEntry struct {
|
|
name string
|
|
count int
|
|
}
|
|
entries := []toolEntry{
|
|
{"run_command", meta.toolStats.RunCommand},
|
|
{"read_files", meta.toolStats.ReadFiles},
|
|
{"search_codebase", meta.toolStats.SearchCodebase},
|
|
{"grep", meta.toolStats.Grep},
|
|
{"file_glob", meta.toolStats.FileGlob},
|
|
{"apply_file_diff", meta.toolStats.ApplyFileDiff},
|
|
{"write_to_long_running_shell_command", meta.toolStats.WriteLongRunning},
|
|
{"read_mcp_resource", meta.toolStats.ReadMCPResource},
|
|
{"call_mcp_tool", meta.toolStats.CallMCPTool},
|
|
{"suggest_plan", meta.toolStats.SuggestPlan},
|
|
{"suggest_create_plan", meta.toolStats.SuggestCreatePlan},
|
|
{"read_shell_command_output", meta.toolStats.ReadShellCmdOutput},
|
|
{"use_computer", meta.toolStats.UseComputer},
|
|
}
|
|
|
|
var msgs []ParsedMessage
|
|
for _, e := range entries {
|
|
for range e.count {
|
|
category := NormalizeToolCategory(e.name)
|
|
content := fmt.Sprintf("[%s]", category)
|
|
msgs = append(msgs, ParsedMessage{
|
|
Ordinal: *ordinal,
|
|
Role: RoleAssistant,
|
|
Content: content,
|
|
Timestamp: ts,
|
|
HasToolUse: true,
|
|
ContentLength: len(content),
|
|
Model: model,
|
|
ToolCalls: []ParsedToolCall{{
|
|
ToolName: e.name,
|
|
Category: category,
|
|
}},
|
|
})
|
|
*ordinal++
|
|
}
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
// extractWarpQueryText extracts the user's query text from the
|
|
// ai_queries input JSON. The format is:
|
|
// [{"Query":{"text":"...","context":[...]}}]
|
|
func extractWarpQueryText(input string) string {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" || input == "[]" {
|
|
return ""
|
|
}
|
|
|
|
var items []json.RawMessage
|
|
if err := json.Unmarshal([]byte(input), &items); err != nil {
|
|
return ""
|
|
}
|
|
if len(items) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var wrapper struct {
|
|
Query struct {
|
|
Text string `json:"text"`
|
|
} `json:"Query"`
|
|
}
|
|
if err := json.Unmarshal(items[0], &wrapper); err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(wrapper.Query.Text)
|
|
}
|
|
|
|
// normalizeWarpModel cleans up Warp model IDs for display.
|
|
func normalizeWarpModel(modelID string) string {
|
|
// Warp uses IDs like "auto-genius", "auto" — keep as-is.
|
|
return modelID
|
|
}
|
|
|
|
// parseWarpTimestamp parses timestamps from Warp's SQLite DB.
|
|
// Format: "2026-04-07 08:55:40" or with fractional seconds.
|
|
func parseWarpTimestamp(s string) time.Time {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return time.Time{}
|
|
}
|
|
|
|
// Try with fractional seconds first.
|
|
for _, layout := range []string{
|
|
"2006-01-02 15:04:05.000000",
|
|
"2006-01-02 15:04:05",
|
|
time.RFC3339,
|
|
} {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
return t
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// warpDBPath returns the path to warp.sqlite inside the
|
|
// given directory, or "" if it doesn't exist.
|
|
func warpDBPath(dir string) string {
|
|
candidate := filepath.Join(dir, WarpDBFilename)
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
return ""
|
|
}
|