test(01-02): add failing tests then implement session + state
- TailReadJSONL: reverse-read JSONL without loading entire file - FindSessionForProcess: match PID to most recent JSONL via EncodePath - DetectState: heuristic based on last message stop_reason + tool name - ExtractPreview: extract first 3 lines of last assistant text - All 17 tests pass (session_test.go + state_test.go) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
197
session.go
Normal file
197
session.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// JSONLMessage represents a single entry in a Claude Code JSONL file.
|
||||
type JSONLMessage struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
GitBranch string `json:"gitBranch"`
|
||||
Message *MessagePayload `json:"message,omitempty"`
|
||||
Data *ProgressData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type MessagePayload struct {
|
||||
Role string `json:"role"`
|
||||
Content []ContentBlock `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type ProgressData struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
const tailBlockSize = 8192
|
||||
|
||||
// TailReadJSONL reads the last n complete JSONL lines from path without loading
|
||||
// the entire file. Incomplete trailing lines (race condition with writer) are ignored.
|
||||
func TailReadJSONL(path string, n int) ([]JSONLMessage, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
size, err := f.Seek(0, io.SeekEnd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if size == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Read backwards in blocks, collecting complete lines
|
||||
var rawLines [][]byte
|
||||
remaining := size
|
||||
var buf []byte
|
||||
|
||||
for remaining > 0 && len(rawLines) < n+1 {
|
||||
blockSize := int64(tailBlockSize)
|
||||
if blockSize > remaining {
|
||||
blockSize = remaining
|
||||
}
|
||||
remaining -= blockSize
|
||||
|
||||
if _, err := f.Seek(remaining, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block := make([]byte, blockSize)
|
||||
if _, err := io.ReadFull(f, block); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Prepend to accumulated buffer
|
||||
buf = append(block, buf...)
|
||||
|
||||
// Extract complete lines from buf
|
||||
rawLines = extractCompleteLines(buf, n+1)
|
||||
}
|
||||
|
||||
if rawLines == nil {
|
||||
rawLines = extractCompleteLines(buf, n+1)
|
||||
}
|
||||
|
||||
// Drop the first "line" if file didn't start at position 0 in our buffer
|
||||
// Actually, re-extract properly: buf now contains from 'remaining' to end of file.
|
||||
// If remaining == 0, buf is the entire file. Lines are all complete.
|
||||
// If remaining > 0, the first partial content before the first \n is not a complete line.
|
||||
|
||||
// Re-parse: split buf on \n, take last n complete lines
|
||||
lines := splitLines(buf)
|
||||
|
||||
// If buf doesn't end with \n, the last element is a truncated line: discard it
|
||||
if len(buf) > 0 && buf[len(buf)-1] != '\n' {
|
||||
if len(lines) > 0 {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// Take the last n lines
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
|
||||
// Parse each line
|
||||
var messages []JSONLMessage
|
||||
for _, line := range lines {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var msg JSONLMessage
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
continue // skip corrupt lines
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// splitLines splits data by \n, returning non-empty line contents.
|
||||
func splitLines(data []byte) [][]byte {
|
||||
var lines [][]byte
|
||||
start := 0
|
||||
for i, b := range data {
|
||||
if b == '\n' {
|
||||
line := data[start:i]
|
||||
if len(line) > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
// Remaining after last \n (possibly truncated)
|
||||
if start < len(data) {
|
||||
lines = append(lines, data[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// extractCompleteLines is a helper used during block reading (unused in final approach).
|
||||
func extractCompleteLines(data []byte, max int) [][]byte {
|
||||
lines := splitLines(data)
|
||||
if len(lines) > max {
|
||||
lines = lines[len(lines)-max:]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// FindSessionForProcess finds the most recently modified JSONL file matching
|
||||
// the process's cwd in claudeDir (typically ~/.claude/projects/).
|
||||
// Only top-level *.jsonl files are considered (subagent directories are excluded).
|
||||
func FindSessionForProcess(claudeDir string, proc Process) (string, []JSONLMessage, error) {
|
||||
encoded := EncodePath(proc.Cwd)
|
||||
pattern := filepath.Join(claudeDir, encoded, "*.jsonl")
|
||||
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Filter out files in subdirectories (Glob with *.jsonl at one level shouldn't
|
||||
// match deeper, but be defensive)
|
||||
var topLevel []string
|
||||
expectedDir := filepath.Join(claudeDir, encoded)
|
||||
for _, m := range matches {
|
||||
if filepath.Dir(m) == expectedDir {
|
||||
topLevel = append(topLevel, m)
|
||||
}
|
||||
}
|
||||
|
||||
if len(topLevel) == 0 {
|
||||
return "", nil, errors.New("no JSONL files found for " + proc.Cwd)
|
||||
}
|
||||
|
||||
// Sort by mtime, most recent first
|
||||
sort.Slice(topLevel, func(i, j int) bool {
|
||||
si, _ := os.Stat(topLevel[i])
|
||||
sj, _ := os.Stat(topLevel[j])
|
||||
if si == nil || sj == nil {
|
||||
return false
|
||||
}
|
||||
return si.ModTime().After(sj.ModTime())
|
||||
})
|
||||
|
||||
newest := topLevel[0]
|
||||
messages, err := TailReadJSONL(newest, 5)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return newest, messages, nil
|
||||
}
|
||||
Reference in New Issue
Block a user