Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

459 lines
11 KiB
Go

package parser
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/tidwall/gjson"
)
const kiroSQLiteDBName = "data.sqlite3"
// KiroSQLiteSessionMeta is lightweight per-conversation metadata
// used to detect changed Kiro SQLite sessions without parsing the
// entire JSON payload on every sync.
type KiroSQLiteSessionMeta struct {
SessionID string
VirtualPath string
FileMtime int64
}
type kiroSQLiteRow struct {
key string
conversationID string
value string
createdAt int64
updatedAt int64
}
// KiroSQLiteStore keeps one read-only SQLite handle open while a
// caller performs multiple Kiro conversation lookups from the same DB.
type KiroSQLiteStore struct {
dbPath string
db *sql.DB
}
// OpenKiroSQLiteStore opens a read-only current-store Kiro SQLite DB.
func OpenKiroSQLiteStore(dbPath string) (*KiroSQLiteStore, error) {
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, fmt.Errorf("kiro sqlite db not found: %s", dbPath)
}
db, err := openKiroSQLiteDB(dbPath)
if err != nil {
return nil, err
}
return &KiroSQLiteStore{dbPath: dbPath, db: db}, nil
}
// Close releases the underlying SQLite handle.
func (s *KiroSQLiteStore) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
// kiroSQLiteDBPath returns the current-store Kiro SQLite DB when the
// configured root contains one.
func kiroSQLiteDBPath(dir string) string {
if dir == "" {
return ""
}
path := filepath.Join(dir, kiroSQLiteDBName)
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return ""
}
return path
}
// KiroSQLiteVirtualPath gives each conversation inside the shared
// Kiro DB a stable source identity for the AgentsView archive.
func KiroSQLiteVirtualPath(dbPath, sessionID string) string {
return VirtualSourcePath(dbPath, sessionID)
}
// kiroSQLiteVirtualPathParts splits a virtual Kiro SQLite source
// path back into its database path and raw session ID.
func kiroSQLiteVirtualPathParts(path string) (string, string, bool) {
return ParseVirtualSourcePathForBase(path, kiroSQLiteDBName)
}
// KiroSQLiteSessionExists reports whether the current Kiro DB has
// at least one row for sessionID.
func KiroSQLiteSessionExists(dbPath, sessionID string) bool {
if dbPath == "" || sessionID == "" {
return false
}
store, err := OpenKiroSQLiteStore(dbPath)
if err != nil {
return false
}
defer store.Close()
return store.SessionExists(sessionID)
}
// SessionExists reports whether the current Kiro DB has at least one
// row for sessionID.
func (s *KiroSQLiteStore) SessionExists(sessionID string) bool {
if s == nil || s.db == nil || sessionID == "" {
return false
}
var found int
err := s.db.QueryRow(
`SELECT 1
FROM conversations_v2
WHERE conversation_id = ?
LIMIT 1`,
sessionID,
).Scan(&found)
return err == nil
}
// ListKiroSQLiteSessionMeta returns one metadata row per logical
// conversation. Kiro's schema permits the same conversation_id
// under multiple cwd keys, so the newest row is canonical.
func ListKiroSQLiteSessionMeta(
dbPath string,
) ([]KiroSQLiteSessionMeta, error) {
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, nil
}
store, err := OpenKiroSQLiteStore(dbPath)
if err != nil {
return nil, err
}
defer store.Close()
return store.ListSessionMeta()
}
// ListSessionMeta returns one metadata row per logical conversation
// using the store's existing SQLite handle.
func (s *KiroSQLiteStore) ListSessionMeta() ([]KiroSQLiteSessionMeta, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("kiro sqlite store is closed")
}
rows, err := s.db.Query(`
SELECT conversation_id, MAX(updated_at)
FROM conversations_v2
GROUP BY conversation_id
`)
if err != nil {
return nil, fmt.Errorf(
"listing kiro sqlite sessions: %w", err,
)
}
defer rows.Close()
var metas []KiroSQLiteSessionMeta
for rows.Next() {
var id string
var updatedAt int64
if err := rows.Scan(&id, &updatedAt); err != nil {
return nil, fmt.Errorf(
"scanning kiro sqlite session meta: %w", err,
)
}
if id == "" {
continue
}
metas = append(metas, KiroSQLiteSessionMeta{
SessionID: id,
VirtualPath: KiroSQLiteVirtualPath(s.dbPath, id),
FileMtime: updatedAt * 1_000_000,
})
}
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
}
// KiroSQLiteSessionIDs returns the set of current-store logical
// conversation IDs under dir.
func KiroSQLiteSessionIDs(dir string) map[string]struct{} {
dbPath := kiroSQLiteDBPath(dir)
if dbPath == "" {
return nil
}
metas, err := ListKiroSQLiteSessionMeta(dbPath)
if err != nil {
return nil
}
ids := make(map[string]struct{}, len(metas))
for _, meta := range metas {
ids[meta.SessionID] = struct{}{}
}
return ids
}
// KiroSQLiteSourceMtime resolves the canonical per-session
// updated_at timestamp for a virtual SQLite source path.
func KiroSQLiteSourceMtime(path string) (int64, error) {
dbPath, sessionID, ok := kiroSQLiteVirtualPathParts(path)
if !ok {
return 0, fmt.Errorf("not a kiro sqlite virtual path: %s", path)
}
row, err := loadKiroSQLiteRow(dbPath, sessionID)
if err != nil {
return 0, err
}
return row.updatedAt * 1_000_000, nil
}
// parseKiroSQLiteSession parses one current-store Kiro CLI conversation
// into normal AgentsView session/message records.
func parseKiroSQLiteSession(
dbPath, sessionID, machine string,
) (*ParsedSession, []ParsedMessage, error) {
store, err := OpenKiroSQLiteStore(dbPath)
if err != nil {
return nil, nil, err
}
defer store.Close()
return store.ParseSession(sessionID, machine)
}
// ParseSession parses one current-store Kiro CLI conversation using
// the store's existing SQLite handle.
func (s *KiroSQLiteStore) ParseSession(
sessionID, machine string,
) (*ParsedSession, []ParsedMessage, error) {
row, err := s.loadRow(sessionID)
if err != nil {
return nil, nil, err
}
if !gjson.Valid(row.value) {
return nil, nil, fmt.Errorf(
"kiro sqlite session %s has malformed payload",
sessionID,
)
}
var messages []ParsedMessage
var firstMessage string
var cwd string
ordinal := 0
gjson.Get(row.value, "history").ForEach(
func(_, turn gjson.Result) bool {
user := turn.Get("user")
if cwd == "" {
cwd = user.Get(
"env_context.env_state.current_working_directory",
).Str
}
if prompt := strings.TrimSpace(
user.Get("content.Prompt.prompt").Str,
); prompt != "" {
if firstMessage == "" {
firstMessage = truncate(
strings.ReplaceAll(prompt, "\n", " "),
300,
)
}
messages = append(messages, ParsedMessage{
Ordinal: ordinal,
Role: RoleUser,
Content: prompt,
ContentLength: len(prompt),
Timestamp: parseTimestamp(
user.Get("timestamp").Str,
),
})
ordinal++
}
if assistant, ok := parseKiroSQLiteAssistant(
turn.Get("assistant"),
turn.Get("request_metadata.stream_end_timestamp_ms").Int(),
ordinal,
); ok {
messages = append(messages, assistant)
ordinal++
}
return true
},
)
hasContent := false
for _, msg := range messages {
if msg.Content != "" {
hasContent = true
break
}
}
if !hasContent {
return nil, nil, nil
}
if row.key != "" {
cwd = row.key
}
project := ExtractProjectFromCwd(cwd)
if project == "" {
project = "unknown"
}
userCount := 0
for _, msg := range messages {
if msg.Role == RoleUser && msg.Content != "" {
userCount++
}
}
sess := &ParsedSession{
ID: "kiro:" + row.conversationID,
Project: project,
Machine: machine,
Agent: AgentKiro,
Cwd: cwd,
FirstMessage: firstMessage,
StartedAt: time.UnixMilli(row.createdAt).UTC(),
EndedAt: time.UnixMilli(row.updatedAt).UTC(),
MessageCount: len(messages),
UserMessageCount: userCount,
File: FileInfo{
Path: KiroSQLiteVirtualPath(s.dbPath, row.conversationID),
Size: int64(len(row.value)),
Mtime: row.updatedAt * 1_000_000,
},
}
return sess, messages, nil
}
func openKiroSQLiteDB(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 kiro sqlite db %s: %w", dbPath, err,
)
}
return db, nil
}
func loadKiroSQLiteRow(
dbPath, sessionID string,
) (kiroSQLiteRow, error) {
store, err := OpenKiroSQLiteStore(dbPath)
if err != nil {
return kiroSQLiteRow{}, err
}
defer store.Close()
return store.loadRow(sessionID)
}
func (s *KiroSQLiteStore) loadRow(
sessionID string,
) (kiroSQLiteRow, error) {
if s == nil || s.db == nil {
return kiroSQLiteRow{}, fmt.Errorf("kiro sqlite store is closed")
}
var row kiroSQLiteRow
err := s.db.QueryRow(`
SELECT key, conversation_id, value,
created_at, updated_at
FROM conversations_v2
WHERE conversation_id = ?
ORDER BY updated_at DESC, created_at DESC, key DESC
LIMIT 1
`, sessionID).Scan(
&row.key, &row.conversationID, &row.value,
&row.createdAt, &row.updatedAt,
)
if err != nil {
return kiroSQLiteRow{}, fmt.Errorf(
"loading kiro sqlite session %s: %w",
sessionID, err,
)
}
return row, nil
}
func parseKiroSQLiteAssistant(
assistant gjson.Result, streamEndMS int64, ordinal int,
) (ParsedMessage, bool) {
timestamp := time.Time{}
if streamEndMS > 0 {
timestamp = time.UnixMilli(streamEndMS).UTC()
}
if response := assistant.Get("Response"); response.Exists() {
content := strings.TrimSpace(response.Get("content").Str)
if content == "" {
return ParsedMessage{}, false
}
return ParsedMessage{
Ordinal: ordinal,
Role: RoleAssistant,
Content: content,
ContentLength: len(content),
Timestamp: timestamp,
}, true
}
toolUse := assistant.Get("ToolUse")
if !toolUse.Exists() {
return ParsedMessage{}, false
}
text := strings.TrimSpace(toolUse.Get("content").Str)
toolCalls := kiroSQLiteToolCalls(toolUse.Get("tool_uses"))
hasToolUse := len(toolCalls) > 0
displayContent := text
if displayContent == "" && hasToolUse {
displayContent = kiroFormatToolCalls(toolCalls)
}
if displayContent == "" && !hasToolUse {
return ParsedMessage{}, false
}
return ParsedMessage{
Ordinal: ordinal,
Role: RoleAssistant,
Content: displayContent,
ContentLength: len(displayContent),
Timestamp: timestamp,
HasToolUse: hasToolUse,
ToolCalls: toolCalls,
}, true
}
func kiroSQLiteToolCalls(
toolUses gjson.Result,
) []ParsedToolCall {
var calls []ParsedToolCall
toolUses.ForEach(func(_, toolUse gjson.Result) bool {
name := toolUse.Get("name").Str
if name == "" {
return true
}
displayName := name
category := NormalizeToolCategory(name)
if name == "write" {
if toolUse.Get("args.command").Str == "strReplace" {
displayName = "Edit"
category = "Edit"
} else {
displayName = "Write"
}
}
calls = append(calls, ParsedToolCall{
ToolUseID: toolUse.Get("id").Str,
ToolName: displayName,
Category: category,
InputJSON: toolUse.Get("args").Raw,
})
return true
})
return calls
}