Add new packages for cryptographic operations, session management, and I/O handling: - pkg/crypt/chachapoly: ChaCha20-Poly1305 AEAD encryption - pkg/crypt/lthn: Lethean-specific key derivation and encryption - pkg/crypt/rsa: RSA key generation, encryption, and signing - pkg/io/node: CryptoNote node I/O and protocol handling - pkg/io/sigil: Cryptographic sigil generation and verification - pkg/session: Session parsing, HTML rendering, search, and video - internal/cmd/forge: Forgejo auth status command - internal/cmd/session: Session management CLI command Also gitignore build artifacts (bugseti binary, i18n-validate). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
383 lines
8.5 KiB
Go
383 lines
8.5 KiB
Go
package session
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Event represents a single action in a session timeline.
|
|
type Event struct {
|
|
Timestamp time.Time
|
|
Type string // "tool_use", "user", "assistant", "error"
|
|
Tool string // "Bash", "Read", "Edit", "Write", "Grep", "Glob", etc.
|
|
ToolID string
|
|
Input string // Command, file path, or message text
|
|
Output string // Result text
|
|
Duration time.Duration
|
|
Success bool
|
|
ErrorMsg string
|
|
}
|
|
|
|
// Session holds parsed session metadata and events.
|
|
type Session struct {
|
|
ID string
|
|
Path string
|
|
StartTime time.Time
|
|
EndTime time.Time
|
|
Events []Event
|
|
}
|
|
|
|
// rawEntry is the top-level structure of a Claude Code JSONL line.
|
|
type rawEntry struct {
|
|
Type string `json:"type"`
|
|
Timestamp string `json:"timestamp"`
|
|
SessionID string `json:"sessionId"`
|
|
Message json.RawMessage `json:"message"`
|
|
UserType string `json:"userType"`
|
|
}
|
|
|
|
type rawMessage struct {
|
|
Role string `json:"role"`
|
|
Content []json.RawMessage `json:"content"`
|
|
}
|
|
|
|
type contentBlock struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
|
Content interface{} `json:"content,omitempty"`
|
|
IsError *bool `json:"is_error,omitempty"`
|
|
}
|
|
|
|
type bashInput struct {
|
|
Command string `json:"command"`
|
|
Description string `json:"description"`
|
|
Timeout int `json:"timeout"`
|
|
}
|
|
|
|
type readInput struct {
|
|
FilePath string `json:"file_path"`
|
|
Offset int `json:"offset"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
type editInput struct {
|
|
FilePath string `json:"file_path"`
|
|
OldString string `json:"old_string"`
|
|
NewString string `json:"new_string"`
|
|
}
|
|
|
|
type writeInput struct {
|
|
FilePath string `json:"file_path"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type grepInput struct {
|
|
Pattern string `json:"pattern"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type globInput struct {
|
|
Pattern string `json:"pattern"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type taskInput struct {
|
|
Prompt string `json:"prompt"`
|
|
Description string `json:"description"`
|
|
SubagentType string `json:"subagent_type"`
|
|
}
|
|
|
|
// ListSessions returns all sessions found in the Claude projects directory.
|
|
func ListSessions(projectsDir string) ([]Session, error) {
|
|
matches, err := filepath.Glob(filepath.Join(projectsDir, "*.jsonl"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("glob sessions: %w", err)
|
|
}
|
|
|
|
var sessions []Session
|
|
for _, path := range matches {
|
|
base := filepath.Base(path)
|
|
id := strings.TrimSuffix(base, ".jsonl")
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
s := Session{
|
|
ID: id,
|
|
Path: path,
|
|
}
|
|
|
|
// Quick scan for first and last timestamps
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
|
var firstTS, lastTS string
|
|
for scanner.Scan() {
|
|
var entry rawEntry
|
|
if json.Unmarshal(scanner.Bytes(), &entry) != nil {
|
|
continue
|
|
}
|
|
if entry.Timestamp == "" {
|
|
continue
|
|
}
|
|
if firstTS == "" {
|
|
firstTS = entry.Timestamp
|
|
}
|
|
lastTS = entry.Timestamp
|
|
}
|
|
f.Close()
|
|
|
|
if firstTS != "" {
|
|
s.StartTime, _ = time.Parse(time.RFC3339Nano, firstTS)
|
|
}
|
|
if lastTS != "" {
|
|
s.EndTime, _ = time.Parse(time.RFC3339Nano, lastTS)
|
|
}
|
|
if s.StartTime.IsZero() {
|
|
s.StartTime = info.ModTime()
|
|
}
|
|
|
|
sessions = append(sessions, s)
|
|
}
|
|
|
|
sort.Slice(sessions, func(i, j int) bool {
|
|
return sessions[i].StartTime.After(sessions[j].StartTime)
|
|
})
|
|
|
|
return sessions, nil
|
|
}
|
|
|
|
// ParseTranscript reads a JSONL session file and returns structured events.
|
|
func ParseTranscript(path string) (*Session, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open transcript: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
base := filepath.Base(path)
|
|
sess := &Session{
|
|
ID: strings.TrimSuffix(base, ".jsonl"),
|
|
Path: path,
|
|
}
|
|
|
|
// Collect tool_use entries keyed by ID
|
|
type toolUse struct {
|
|
timestamp time.Time
|
|
tool string
|
|
input string
|
|
}
|
|
pendingTools := make(map[string]toolUse)
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
var entry rawEntry
|
|
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
|
|
continue
|
|
}
|
|
|
|
ts, _ := time.Parse(time.RFC3339Nano, entry.Timestamp)
|
|
|
|
if sess.StartTime.IsZero() && !ts.IsZero() {
|
|
sess.StartTime = ts
|
|
}
|
|
if !ts.IsZero() {
|
|
sess.EndTime = ts
|
|
}
|
|
|
|
switch entry.Type {
|
|
case "assistant":
|
|
var msg rawMessage
|
|
if json.Unmarshal(entry.Message, &msg) != nil {
|
|
continue
|
|
}
|
|
for _, raw := range msg.Content {
|
|
var block contentBlock
|
|
if json.Unmarshal(raw, &block) != nil {
|
|
continue
|
|
}
|
|
|
|
switch block.Type {
|
|
case "text":
|
|
if text := strings.TrimSpace(block.Text); text != "" {
|
|
sess.Events = append(sess.Events, Event{
|
|
Timestamp: ts,
|
|
Type: "assistant",
|
|
Input: truncate(text, 500),
|
|
})
|
|
}
|
|
|
|
case "tool_use":
|
|
inputStr := extractToolInput(block.Name, block.Input)
|
|
pendingTools[block.ID] = toolUse{
|
|
timestamp: ts,
|
|
tool: block.Name,
|
|
input: inputStr,
|
|
}
|
|
}
|
|
}
|
|
|
|
case "user":
|
|
var msg rawMessage
|
|
if json.Unmarshal(entry.Message, &msg) != nil {
|
|
continue
|
|
}
|
|
for _, raw := range msg.Content {
|
|
var block contentBlock
|
|
if json.Unmarshal(raw, &block) != nil {
|
|
continue
|
|
}
|
|
|
|
switch block.Type {
|
|
case "tool_result":
|
|
if tu, ok := pendingTools[block.ToolUseID]; ok {
|
|
output := extractResultContent(block.Content)
|
|
isError := block.IsError != nil && *block.IsError
|
|
evt := Event{
|
|
Timestamp: tu.timestamp,
|
|
Type: "tool_use",
|
|
Tool: tu.tool,
|
|
ToolID: block.ToolUseID,
|
|
Input: tu.input,
|
|
Output: truncate(output, 2000),
|
|
Duration: ts.Sub(tu.timestamp),
|
|
Success: !isError,
|
|
}
|
|
if isError {
|
|
evt.ErrorMsg = truncate(output, 500)
|
|
}
|
|
sess.Events = append(sess.Events, evt)
|
|
delete(pendingTools, block.ToolUseID)
|
|
}
|
|
|
|
case "text":
|
|
if text := strings.TrimSpace(block.Text); text != "" {
|
|
sess.Events = append(sess.Events, Event{
|
|
Timestamp: ts,
|
|
Type: "user",
|
|
Input: truncate(text, 500),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return sess, scanner.Err()
|
|
}
|
|
|
|
func extractToolInput(toolName string, raw json.RawMessage) string {
|
|
if raw == nil {
|
|
return ""
|
|
}
|
|
|
|
switch toolName {
|
|
case "Bash":
|
|
var inp bashInput
|
|
if json.Unmarshal(raw, &inp) == nil {
|
|
desc := inp.Description
|
|
if desc != "" {
|
|
desc = " # " + desc
|
|
}
|
|
return inp.Command + desc
|
|
}
|
|
case "Read":
|
|
var inp readInput
|
|
if json.Unmarshal(raw, &inp) == nil {
|
|
return inp.FilePath
|
|
}
|
|
case "Edit":
|
|
var inp editInput
|
|
if json.Unmarshal(raw, &inp) == nil {
|
|
return fmt.Sprintf("%s (edit)", inp.FilePath)
|
|
}
|
|
case "Write":
|
|
var inp writeInput
|
|
if json.Unmarshal(raw, &inp) == nil {
|
|
return fmt.Sprintf("%s (%d bytes)", inp.FilePath, len(inp.Content))
|
|
}
|
|
case "Grep":
|
|
var inp grepInput
|
|
if json.Unmarshal(raw, &inp) == nil {
|
|
path := inp.Path
|
|
if path == "" {
|
|
path = "."
|
|
}
|
|
return fmt.Sprintf("/%s/ in %s", inp.Pattern, path)
|
|
}
|
|
case "Glob":
|
|
var inp globInput
|
|
if json.Unmarshal(raw, &inp) == nil {
|
|
return inp.Pattern
|
|
}
|
|
case "Task":
|
|
var inp taskInput
|
|
if json.Unmarshal(raw, &inp) == nil {
|
|
desc := inp.Description
|
|
if desc == "" {
|
|
desc = truncate(inp.Prompt, 80)
|
|
}
|
|
return fmt.Sprintf("[%s] %s", inp.SubagentType, desc)
|
|
}
|
|
}
|
|
|
|
// Fallback: show raw JSON keys
|
|
var m map[string]interface{}
|
|
if json.Unmarshal(raw, &m) == nil {
|
|
var parts []string
|
|
for k := range m {
|
|
parts = append(parts, k)
|
|
}
|
|
sort.Strings(parts)
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func extractResultContent(content interface{}) string {
|
|
switch v := content.(type) {
|
|
case string:
|
|
return v
|
|
case []interface{}:
|
|
var parts []string
|
|
for _, item := range v {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
if text, ok := m["text"].(string); ok {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
case map[string]interface{}:
|
|
if text, ok := v["text"].(string); ok {
|
|
return text
|
|
}
|
|
}
|
|
return fmt.Sprintf("%v", content)
|
|
}
|
|
|
|
func truncate(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max] + "..."
|
|
}
|