Compare commits
1 commit
ax/review-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36c184e7dd |
14 changed files with 225 additions and 517 deletions
62
analytics.go
62
analytics.go
|
|
@ -31,7 +31,7 @@ type SessionAnalytics struct {
|
||||||
// Example:
|
// Example:
|
||||||
// analytics := session.Analyse(sess)
|
// analytics := session.Analyse(sess)
|
||||||
func Analyse(sess *Session) *SessionAnalytics {
|
func Analyse(sess *Session) *SessionAnalytics {
|
||||||
analytics := &SessionAnalytics{
|
a := &SessionAnalytics{
|
||||||
ToolCounts: make(map[string]int),
|
ToolCounts: make(map[string]int),
|
||||||
ErrorCounts: make(map[string]int),
|
ErrorCounts: make(map[string]int),
|
||||||
AvgLatency: make(map[string]time.Duration),
|
AvgLatency: make(map[string]time.Duration),
|
||||||
|
|
@ -39,11 +39,11 @@ func Analyse(sess *Session) *SessionAnalytics {
|
||||||
}
|
}
|
||||||
|
|
||||||
if sess == nil {
|
if sess == nil {
|
||||||
return analytics
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
analytics.Duration = sess.EndTime.Sub(sess.StartTime)
|
a.Duration = sess.EndTime.Sub(sess.StartTime)
|
||||||
analytics.EventCount = len(sess.Events)
|
a.EventCount = len(sess.Events)
|
||||||
|
|
||||||
// Track totals for latency averaging
|
// Track totals for latency averaging
|
||||||
type latencyAccum struct {
|
type latencyAccum struct {
|
||||||
|
|
@ -57,23 +57,23 @@ func Analyse(sess *Session) *SessionAnalytics {
|
||||||
|
|
||||||
for evt := range sess.EventsSeq() {
|
for evt := range sess.EventsSeq() {
|
||||||
// Token estimation: ~4 chars per token
|
// Token estimation: ~4 chars per token
|
||||||
analytics.EstimatedInputTokens += len(evt.Input) / 4
|
a.EstimatedInputTokens += len(evt.Input) / 4
|
||||||
analytics.EstimatedOutputTokens += len(evt.Output) / 4
|
a.EstimatedOutputTokens += len(evt.Output) / 4
|
||||||
|
|
||||||
if evt.Type != "tool_use" {
|
if evt.Type != "tool_use" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
totalToolCalls++
|
totalToolCalls++
|
||||||
analytics.ToolCounts[evt.Tool]++
|
a.ToolCounts[evt.Tool]++
|
||||||
|
|
||||||
if !evt.Success {
|
if !evt.Success {
|
||||||
totalErrors++
|
totalErrors++
|
||||||
analytics.ErrorCounts[evt.Tool]++
|
a.ErrorCounts[evt.Tool]++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active time: sum of tool call durations
|
// Active time: sum of tool call durations
|
||||||
analytics.ActiveTime += evt.Duration
|
a.ActiveTime += evt.Duration
|
||||||
|
|
||||||
// Latency tracking
|
// Latency tracking
|
||||||
if _, ok := latencies[evt.Tool]; !ok {
|
if _, ok := latencies[evt.Tool]; !ok {
|
||||||
|
|
@ -82,24 +82,24 @@ func Analyse(sess *Session) *SessionAnalytics {
|
||||||
latencies[evt.Tool].total += evt.Duration
|
latencies[evt.Tool].total += evt.Duration
|
||||||
latencies[evt.Tool].count++
|
latencies[evt.Tool].count++
|
||||||
|
|
||||||
if evt.Duration > analytics.MaxLatency[evt.Tool] {
|
if evt.Duration > a.MaxLatency[evt.Tool] {
|
||||||
analytics.MaxLatency[evt.Tool] = evt.Duration
|
a.MaxLatency[evt.Tool] = evt.Duration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute averages
|
// Compute averages
|
||||||
for tool, accumulator := range latencies {
|
for tool, acc := range latencies {
|
||||||
if accumulator.count > 0 {
|
if acc.count > 0 {
|
||||||
analytics.AvgLatency[tool] = accumulator.total / time.Duration(accumulator.count)
|
a.AvgLatency[tool] = acc.total / time.Duration(acc.count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success rate
|
// Success rate
|
||||||
if totalToolCalls > 0 {
|
if totalToolCalls > 0 {
|
||||||
analytics.SuccessRate = float64(totalToolCalls-totalErrors) / float64(totalToolCalls)
|
a.SuccessRate = float64(totalToolCalls-totalErrors) / float64(totalToolCalls)
|
||||||
}
|
}
|
||||||
|
|
||||||
return analytics
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatAnalytics returns a tabular text summary suitable for CLI display.
|
// FormatAnalytics returns a tabular text summary suitable for CLI display.
|
||||||
|
|
@ -107,35 +107,35 @@ func Analyse(sess *Session) *SessionAnalytics {
|
||||||
// Example:
|
// Example:
|
||||||
// summary := session.FormatAnalytics(analytics)
|
// summary := session.FormatAnalytics(analytics)
|
||||||
func FormatAnalytics(a *SessionAnalytics) string {
|
func FormatAnalytics(a *SessionAnalytics) string {
|
||||||
builder := core.NewBuilder()
|
b := core.NewBuilder()
|
||||||
|
|
||||||
builder.WriteString("Session Analytics\n")
|
b.WriteString("Session Analytics\n")
|
||||||
builder.WriteString(repeatString("=", 50) + "\n\n")
|
b.WriteString(repeatString("=", 50) + "\n\n")
|
||||||
|
|
||||||
builder.WriteString(core.Sprintf(" Duration: %s\n", formatDuration(a.Duration)))
|
b.WriteString(core.Sprintf(" Duration: %s\n", formatDuration(a.Duration)))
|
||||||
builder.WriteString(core.Sprintf(" Active Time: %s\n", formatDuration(a.ActiveTime)))
|
b.WriteString(core.Sprintf(" Active Time: %s\n", formatDuration(a.ActiveTime)))
|
||||||
builder.WriteString(core.Sprintf(" Events: %d\n", a.EventCount))
|
b.WriteString(core.Sprintf(" Events: %d\n", a.EventCount))
|
||||||
builder.WriteString(core.Sprintf(" Success Rate: %.1f%%\n", a.SuccessRate*100))
|
b.WriteString(core.Sprintf(" Success Rate: %.1f%%\n", a.SuccessRate*100))
|
||||||
builder.WriteString(core.Sprintf(" Est. Input Tk: %d\n", a.EstimatedInputTokens))
|
b.WriteString(core.Sprintf(" Est. Input Tk: %d\n", a.EstimatedInputTokens))
|
||||||
builder.WriteString(core.Sprintf(" Est. Output Tk: %d\n", a.EstimatedOutputTokens))
|
b.WriteString(core.Sprintf(" Est. Output Tk: %d\n", a.EstimatedOutputTokens))
|
||||||
|
|
||||||
if len(a.ToolCounts) > 0 {
|
if len(a.ToolCounts) > 0 {
|
||||||
builder.WriteString("\n Tool Breakdown\n")
|
b.WriteString("\n Tool Breakdown\n")
|
||||||
builder.WriteString(" " + repeatString("-", 48) + "\n")
|
b.WriteString(" " + repeatString("-", 48) + "\n")
|
||||||
builder.WriteString(core.Sprintf(" %-14s %6s %6s %10s %10s\n",
|
b.WriteString(core.Sprintf(" %-14s %6s %6s %10s %10s\n",
|
||||||
"Tool", "Calls", "Errors", "Avg", "Max"))
|
"Tool", "Calls", "Errors", "Avg", "Max"))
|
||||||
builder.WriteString(" " + repeatString("-", 48) + "\n")
|
b.WriteString(" " + repeatString("-", 48) + "\n")
|
||||||
|
|
||||||
// Sort tools for deterministic output
|
// Sort tools for deterministic output
|
||||||
for _, tool := range slices.Sorted(maps.Keys(a.ToolCounts)) {
|
for _, tool := range slices.Sorted(maps.Keys(a.ToolCounts)) {
|
||||||
errors := a.ErrorCounts[tool]
|
errors := a.ErrorCounts[tool]
|
||||||
avg := a.AvgLatency[tool]
|
avg := a.AvgLatency[tool]
|
||||||
max := a.MaxLatency[tool]
|
max := a.MaxLatency[tool]
|
||||||
builder.WriteString(core.Sprintf(" %-14s %6d %6d %10s %10s\n",
|
b.WriteString(core.Sprintf(" %-14s %6d %6d %10s %10s\n",
|
||||||
tool, a.ToolCounts[tool], errors,
|
tool, a.ToolCounts[tool], errors,
|
||||||
formatDuration(avg), formatDuration(max)))
|
formatDuration(avg), formatDuration(max)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,101 +283,3 @@ func TestAnalytics_FormatAnalyticsEmptyAnalytics_Good(t *testing.T) {
|
||||||
// No tool breakdown section when no tools
|
// No tool breakdown section when no tools
|
||||||
assert.NotContains(t, output, "Tool Breakdown")
|
assert.NotContains(t, output, "Tool Breakdown")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnalytics_AnalyseAllToolsFailed_Bad(t *testing.T) {
|
|
||||||
sess := &Session{
|
|
||||||
ID: "all-failed",
|
|
||||||
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
|
||||||
EndTime: time.Date(2026, 2, 20, 10, 0, 5, 0, time.UTC),
|
|
||||||
Events: []Event{
|
|
||||||
{
|
|
||||||
Type: "tool_use",
|
|
||||||
Tool: "Bash",
|
|
||||||
Input: "false",
|
|
||||||
Output: "exit code 1",
|
|
||||||
Duration: 100 * time.Millisecond,
|
|
||||||
Success: false,
|
|
||||||
ErrorMsg: "exit code 1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: "tool_use",
|
|
||||||
Tool: "Read",
|
|
||||||
Input: "/nonexistent",
|
|
||||||
Output: "file not found",
|
|
||||||
Duration: 50 * time.Millisecond,
|
|
||||||
Success: false,
|
|
||||||
ErrorMsg: "file not found",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
a := Analyse(sess)
|
|
||||||
|
|
||||||
assert.Equal(t, 0.0, a.SuccessRate, "all failures should give 0.0 success rate")
|
|
||||||
assert.Equal(t, 1, a.ErrorCounts["Bash"])
|
|
||||||
assert.Equal(t, 1, a.ErrorCounts["Read"])
|
|
||||||
assert.Equal(t, 1, a.ToolCounts["Bash"])
|
|
||||||
assert.Equal(t, 1, a.ToolCounts["Read"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnalytics_AnalyseSessionOnlyNonToolEvents_Bad(t *testing.T) {
|
|
||||||
// Session with only user/assistant events — no tool_use — should produce zero tool stats
|
|
||||||
sess := &Session{
|
|
||||||
ID: "no-tools",
|
|
||||||
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
|
||||||
EndTime: time.Date(2026, 2, 20, 10, 0, 10, 0, time.UTC),
|
|
||||||
Events: []Event{
|
|
||||||
{Type: "user", Input: "Hello"},
|
|
||||||
{Type: "assistant", Input: "Hi"},
|
|
||||||
{Type: "user", Input: "Thanks"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
a := Analyse(sess)
|
|
||||||
|
|
||||||
assert.Equal(t, 3, a.EventCount)
|
|
||||||
assert.Equal(t, 0.0, a.SuccessRate)
|
|
||||||
assert.Empty(t, a.ToolCounts)
|
|
||||||
assert.Empty(t, a.ErrorCounts)
|
|
||||||
assert.Equal(t, time.Duration(0), a.ActiveTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnalytics_FormatAnalyticsNilMaps_Ugly(t *testing.T) {
|
|
||||||
// FormatAnalytics with nil maps — should not panic
|
|
||||||
a := &SessionAnalytics{}
|
|
||||||
|
|
||||||
var output string
|
|
||||||
assert.NotPanics(t, func() {
|
|
||||||
output = FormatAnalytics(a)
|
|
||||||
})
|
|
||||||
assert.Contains(t, output, "Session Analytics")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnalytics_AnalyseVeryLargeEventCount_Ugly(t *testing.T) {
|
|
||||||
// Stress: session with 10 000 tool events — should not panic or overflow
|
|
||||||
events := make([]Event, 10_000)
|
|
||||||
for i := range events {
|
|
||||||
events[i] = Event{
|
|
||||||
Type: "tool_use",
|
|
||||||
Tool: "Bash",
|
|
||||||
Input: repeatString("x", 100),
|
|
||||||
Output: repeatString("y", 200),
|
|
||||||
Duration: time.Millisecond,
|
|
||||||
Success: i%2 == 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sess := &Session{
|
|
||||||
ID: "huge",
|
|
||||||
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
|
||||||
EndTime: time.Date(2026, 2, 20, 11, 0, 0, 0, time.UTC),
|
|
||||||
Events: events,
|
|
||||||
}
|
|
||||||
|
|
||||||
var a *SessionAnalytics
|
|
||||||
assert.NotPanics(t, func() {
|
|
||||||
a = Analyse(sess)
|
|
||||||
})
|
|
||||||
assert.Equal(t, 10_000, a.EventCount)
|
|
||||||
assert.Equal(t, 10_000, a.ToolCounts["Bash"])
|
|
||||||
assert.InDelta(t, 0.5, a.SuccessRate, 0.001)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -92,12 +92,12 @@ func BenchmarkSearch(b *testing.B) {
|
||||||
func generateBenchJSONL(b testing.TB, dir string, numTools int) string {
|
func generateBenchJSONL(b testing.TB, dir string, numTools int) string {
|
||||||
b.Helper()
|
b.Helper()
|
||||||
|
|
||||||
builder := core.NewBuilder()
|
sb := core.NewBuilder()
|
||||||
baseTS := "2026-02-20T10:00:00Z"
|
baseTS := "2026-02-20T10:00:00Z"
|
||||||
|
|
||||||
// Opening user message
|
// Opening user message
|
||||||
builder.WriteString(core.Sprintf(`{"type":"user","timestamp":"%s","sessionId":"bench","message":{"role":"user","content":[{"type":"text","text":"Start benchmark session"}]}}`, baseTS))
|
sb.WriteString(core.Sprintf(`{"type":"user","timestamp":"%s","sessionId":"bench","message":{"role":"user","content":[{"type":"text","text":"Start benchmark session"}]}}`, baseTS))
|
||||||
builder.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
|
|
||||||
for i := range numTools {
|
for i := range numTools {
|
||||||
toolID := core.Sprintf("tool-%d", i)
|
toolID := core.Sprintf("tool-%d", i)
|
||||||
|
|
@ -133,18 +133,18 @@ func generateBenchJSONL(b testing.TB, dir string, numTools int) string {
|
||||||
(offset+1)/60, (offset+1)%60, toolID)
|
(offset+1)/60, (offset+1)%60, toolID)
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.WriteString(toolUse)
|
sb.WriteString(toolUse)
|
||||||
builder.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
builder.WriteString(toolResult)
|
sb.WriteString(toolResult)
|
||||||
builder.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closing assistant message
|
// Closing assistant message
|
||||||
builder.WriteString(core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T12:00:00Z","sessionId":"bench","message":{"role":"assistant","content":[{"type":"text","text":"Benchmark session complete."}]}}%s`, "\n"))
|
sb.WriteString(core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T12:00:00Z","sessionId":"bench","message":{"role":"assistant","content":[{"type":"text","text":"Benchmark session complete."}]}}%s`, "\n"))
|
||||||
|
|
||||||
name := core.Sprintf("bench-%d.jsonl", numTools)
|
name := core.Sprintf("bench-%d.jsonl", numTools)
|
||||||
filePath := path.Join(dir, name)
|
filePath := path.Join(dir, name)
|
||||||
writeResult := hostFS.Write(filePath, builder.String())
|
writeResult := hostFS.Write(filePath, sb.String())
|
||||||
if !writeResult.OK {
|
if !writeResult.OK {
|
||||||
b.Fatal(resultError(writeResult))
|
b.Fatal(resultError(writeResult))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,73 +195,6 @@ func parseGoFiles(t *testing.T, dir string) []parsedFile {
|
||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConventions_BannedImportPresent_Bad(t *testing.T) {
|
|
||||||
// Verify the banned-import check fires when a banned import is used.
|
|
||||||
// We parse a synthetic file that imports "fmt" and confirm the list is non-empty.
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeTestFile(t, path.Join(dir, "bad.go"), "package session\nimport \"fmt\"\nvar _ = fmt.Sprintf\n")
|
|
||||||
|
|
||||||
files := parseGoFiles(t, dir)
|
|
||||||
if len(files) != 1 {
|
|
||||||
t.Fatalf("expected 1 file, got %d", len(files))
|
|
||||||
}
|
|
||||||
|
|
||||||
banned := map[string]bool{"fmt": true}
|
|
||||||
var found []string
|
|
||||||
for _, spec := range files[0].ast.Imports {
|
|
||||||
importPath := trimQuotes(spec.Path.Value)
|
|
||||||
if banned[importPath] {
|
|
||||||
found = append(found, importPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(found) == 0 {
|
|
||||||
t.Fatal("expected to detect banned import 'fmt' in synthetic file, got none")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConventions_TestNamingInvalidPattern_Bad(t *testing.T) {
|
|
||||||
// A function that begins with Test but lacks the Good/Bad/Ugly suffix should not
|
|
||||||
// match testNamePattern.
|
|
||||||
badNames := []string{
|
|
||||||
"TestFoo",
|
|
||||||
"TestFoo_Bar",
|
|
||||||
"TestFoo_Bar_",
|
|
||||||
"TestFoo_Bar_Maybe",
|
|
||||||
}
|
|
||||||
for _, name := range badNames {
|
|
||||||
if testNamePattern.MatchString(name) {
|
|
||||||
t.Errorf("name %q should NOT match testNamePattern", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
goodNames := []string{
|
|
||||||
"TestFoo_Bar_Good",
|
|
||||||
"TestFoo_Bar_Bad",
|
|
||||||
"TestFoo_Bar_Ugly",
|
|
||||||
}
|
|
||||||
for _, name := range goodNames {
|
|
||||||
if !testNamePattern.MatchString(name) {
|
|
||||||
t.Errorf("name %q should match testNamePattern", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConventions_ParseGoFilesEmptyDir_Ugly(t *testing.T) {
|
|
||||||
// parseGoFiles with no Go files in dir should fatalf — we test via a dir
|
|
||||||
// that only has a non-Go file.
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeTestFile(t, path.Join(dir, "readme.md"), "# hello\n")
|
|
||||||
|
|
||||||
// We cannot call t.Fatal from within the deferred function, so we capture the
|
|
||||||
// outcome by running parseGoFiles through a stub that overrides Fatal.
|
|
||||||
// Instead, verify that PathGlob returns empty for that dir.
|
|
||||||
matches := core.PathGlob(path.Join(dir, "*.go"))
|
|
||||||
if len(matches) != 0 {
|
|
||||||
t.Fatalf("expected no .go files in temp dir, got %d", len(matches))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConventions_ParseGoFilesMultiplePackages_Good(t *testing.T) {
|
func TestConventions_ParseGoFilesMultiplePackages_Good(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,6 @@ func (m rawJSON) MarshalJSON() ([]byte, error) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resultError extracts the error from a core.Result, or constructs one if absent.
|
|
||||||
//
|
|
||||||
// err := resultError(hostFS.Write(path, content))
|
|
||||||
func resultError(result core.Result) error {
|
func resultError(result core.Result) error {
|
||||||
if result.OK {
|
if result.OK {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -39,44 +36,32 @@ func resultError(result core.Result) error {
|
||||||
return core.E("resultError", "unexpected core result failure", nil)
|
return core.E("resultError", "unexpected core result failure", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// repeatString returns text repeated count times.
|
func repeatString(s string, count int) string {
|
||||||
//
|
if s == "" || count <= 0 {
|
||||||
// repeatString("=", 50) // "=================================================="
|
|
||||||
func repeatString(text string, count int) string {
|
|
||||||
if text == "" || count <= 0 {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return string(bytes.Repeat([]byte(text), count))
|
return string(bytes.Repeat([]byte(s), count))
|
||||||
}
|
}
|
||||||
|
|
||||||
// containsAny reports whether text contains any rune in chars.
|
func containsAny(s, chars string) bool {
|
||||||
//
|
for _, ch := range chars {
|
||||||
// containsAny("/tmp/file", `/\`) // true
|
if bytes.IndexRune([]byte(s), ch) >= 0 {
|
||||||
func containsAny(text, chars string) bool {
|
|
||||||
for _, character := range chars {
|
|
||||||
if bytes.IndexRune([]byte(text), character) >= 0 {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// indexOf returns the byte offset of substr in text, or -1 if not found.
|
func indexOf(s, substr string) int {
|
||||||
//
|
return bytes.Index([]byte(s), []byte(substr))
|
||||||
// indexOf("hello world", "world") // 6
|
|
||||||
func indexOf(text, substr string) int {
|
|
||||||
return bytes.Index([]byte(text), []byte(substr))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// trimQuotes strips a single layer of matching double-quotes or back-ticks.
|
func trimQuotes(s string) string {
|
||||||
//
|
if len(s) < 2 {
|
||||||
// trimQuotes(`"hello"`) // "hello"
|
return s
|
||||||
func trimQuotes(text string) string {
|
|
||||||
if len(text) < 2 {
|
|
||||||
return text
|
|
||||||
}
|
}
|
||||||
if (text[0] == '"' && text[len(text)-1] == '"') || (text[0] == '`' && text[len(text)-1] == '`') {
|
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '`' && s[len(s)-1] == '`') {
|
||||||
return text[1 : len(text)-1]
|
return s[1 : len(s)-1]
|
||||||
}
|
}
|
||||||
return text
|
return s
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -239,10 +239,11 @@ Success or failure of a `tool_use` event is indicated by a Unicode check mark (U
|
||||||
|
|
||||||
Each event is rendered as a `<div class="event">` containing:
|
Each event is rendered as a `<div class="event">` containing:
|
||||||
|
|
||||||
- `.event-header`: always visible; shows timestamp, tool label, truncated input (120 chars), duration, and status icon.
|
- `.event-header`: always visible; shows timestamp, tool label, truncated input (120 chars), duration, status icon, and a permalink anchor.
|
||||||
- `.event-body`: hidden by default; shown on click via the `toggle(i)` JavaScript function which toggles the `open` class.
|
- `.event-body`: hidden by default; shown on click via the `toggle(i)` JavaScript function which toggles the `open` class.
|
||||||
|
|
||||||
The arrow indicator rotates 90 degrees (CSS `transform: rotate(90deg)`) when the panel is open. Output text in `.event-body` is capped at 400px height with `overflow-y: auto`.
|
The arrow indicator rotates 90 degrees (CSS `transform: rotate(90deg)`) when the panel is open. Output text in `.event-body` is capped at 400px height with `overflow-y: auto`.
|
||||||
|
If the page loads with an `#evt-N` fragment, that event is opened automatically and scrolled into view.
|
||||||
|
|
||||||
Input label semantics vary per tool:
|
Input label semantics vary per tool:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,5 +76,5 @@ The following have been identified as potential improvements but are not current
|
||||||
- **Parallel search**: fan out `ParseTranscript` calls across goroutines with a result channel to reduce wall time for large directories.
|
- **Parallel search**: fan out `ParseTranscript` calls across goroutines with a result channel to reduce wall time for large directories.
|
||||||
- **Persistent index**: a lightweight SQLite index or binary cache per session file to avoid re-parsing on every `Search` or `ListSessions` call.
|
- **Persistent index**: a lightweight SQLite index or binary cache per session file to avoid re-parsing on every `Search` or `ListSessions` call.
|
||||||
- **Additional tool types**: the parser's `extractToolInput` fallback handles any unknown tool by listing its JSON keys. Dedicated handling could be added for `WebFetch`, `WebSearch`, `NotebookEdit`, and other tools that appear in Claude Code sessions.
|
- **Additional tool types**: the parser's `extractToolInput` fallback handles any unknown tool by listing its JSON keys. Dedicated handling could be added for `WebFetch`, `WebSearch`, `NotebookEdit`, and other tools that appear in Claude Code sessions.
|
||||||
- **HTML export options**: configurable truncation limits, optional full-output display, and per-event direct links (anchor IDs already exist as `evt-{i}`).
|
- **HTML export options**: configurable truncation limits and optional full-output display remain open; per-event direct links are now available via `#evt-{i}` permalinks.
|
||||||
- **VHS alternative**: a pure-Go terminal animation renderer to eliminate the `vhs` dependency for MP4 output.
|
- **VHS alternative**: a pure-Go terminal animation renderer to eliminate the `vhs` dependency for MP4 output.
|
||||||
|
|
|
||||||
68
html.go
68
html.go
|
|
@ -21,17 +21,17 @@ func RenderHTML(sess *Session, outputPath string) error {
|
||||||
duration := sess.EndTime.Sub(sess.StartTime)
|
duration := sess.EndTime.Sub(sess.StartTime)
|
||||||
toolCount := 0
|
toolCount := 0
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
for evt := range sess.EventsSeq() {
|
for e := range sess.EventsSeq() {
|
||||||
if evt.Type == "tool_use" {
|
if e.Type == "tool_use" {
|
||||||
toolCount++
|
toolCount++
|
||||||
if !evt.Success {
|
if !e.Success {
|
||||||
errorCount++
|
errorCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
builder := core.NewBuilder()
|
b := core.NewBuilder()
|
||||||
builder.WriteString(core.Sprintf(`<!DOCTYPE html>
|
b.WriteString(core.Sprintf(`<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
|
@ -71,6 +71,8 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
||||||
.event-header .input { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.event-header .input { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.event-header .dur { color: var(--dim); font-size: 11px; min-width: 50px; text-align: right; }
|
.event-header .dur { color: var(--dim); font-size: 11px; min-width: 50px; text-align: right; }
|
||||||
.event-header .status { font-size: 14px; min-width: 20px; text-align: center; }
|
.event-header .status { font-size: 14px; min-width: 20px; text-align: center; }
|
||||||
|
.event-header .permalink { color: var(--dim); font-size: 12px; min-width: 16px; text-align: center; text-decoration: none; }
|
||||||
|
.event-header .permalink:hover { color: var(--accent); }
|
||||||
.event-header .arrow { color: var(--dim); font-size: 10px; transition: transform 0.15s; min-width: 16px; }
|
.event-header .arrow { color: var(--dim); font-size: 10px; transition: transform 0.15s; min-width: 16px; }
|
||||||
.event.open .arrow { transform: rotate(90deg); }
|
.event.open .arrow { transform: rotate(90deg); }
|
||||||
.event-body { display: none; padding: 12px; background: var(--bg); border-top: 1px solid var(--border); }
|
.event-body { display: none; padding: 12px; background: var(--bg); border-top: 1px solid var(--border); }
|
||||||
|
|
@ -96,11 +98,11 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
||||||
toolCount))
|
toolCount))
|
||||||
|
|
||||||
if errorCount > 0 {
|
if errorCount > 0 {
|
||||||
builder.WriteString(core.Sprintf(`
|
b.WriteString(core.Sprintf(`
|
||||||
<span class="err">%d errors</span>`, errorCount))
|
<span class="err">%d errors</span>`, errorCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.WriteString(`
|
b.WriteString(`
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="search">
|
<div class="search">
|
||||||
|
|
@ -117,7 +119,7 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
||||||
<div class="timeline" id="timeline">
|
<div class="timeline" id="timeline">
|
||||||
`)
|
`)
|
||||||
|
|
||||||
var eventIndex int
|
var i int
|
||||||
for evt := range sess.EventsSeq() {
|
for evt := range sess.EventsSeq() {
|
||||||
toolClass := core.Lower(evt.Tool)
|
toolClass := core.Lower(evt.Tool)
|
||||||
if evt.Type == "user" {
|
if evt.Type == "user" {
|
||||||
|
|
@ -147,12 +149,12 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
||||||
toolLabel = "Claude"
|
toolLabel = "Claude"
|
||||||
}
|
}
|
||||||
|
|
||||||
durationString := ""
|
durStr := ""
|
||||||
if evt.Duration > 0 {
|
if evt.Duration > 0 {
|
||||||
durationString = formatDuration(evt.Duration)
|
durStr = formatDuration(evt.Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.WriteString(core.Sprintf(`<div class="event%s" data-type="%s" data-tool="%s" data-text="%s" id="evt-%d">
|
b.WriteString(core.Sprintf(`<div class="event%s" data-type="%s" data-tool="%s" data-text="%s" id="evt-%d">
|
||||||
<div class="event-header" onclick="toggle(%d)">
|
<div class="event-header" onclick="toggle(%d)">
|
||||||
<span class="arrow">▶</span>
|
<span class="arrow">▶</span>
|
||||||
<span class="time">%s</span>
|
<span class="time">%s</span>
|
||||||
|
|
@ -160,6 +162,7 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
||||||
<span class="input">%s</span>
|
<span class="input">%s</span>
|
||||||
<span class="dur">%s</span>
|
<span class="dur">%s</span>
|
||||||
<span class="status">%s</span>
|
<span class="status">%s</span>
|
||||||
|
<a class="permalink" href="#evt-%d" aria-label="Direct link to this event" onclick="event.stopPropagation()">#</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-body">
|
<div class="event-body">
|
||||||
`,
|
`,
|
||||||
|
|
@ -167,14 +170,15 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
||||||
evt.Type,
|
evt.Type,
|
||||||
evt.Tool,
|
evt.Tool,
|
||||||
html.EscapeString(core.Lower(core.Concat(evt.Input, " ", evt.Output))),
|
html.EscapeString(core.Lower(core.Concat(evt.Input, " ", evt.Output))),
|
||||||
eventIndex,
|
i,
|
||||||
eventIndex,
|
i,
|
||||||
evt.Timestamp.Format("15:04:05"),
|
evt.Timestamp.Format("15:04:05"),
|
||||||
toolClass,
|
toolClass,
|
||||||
html.EscapeString(toolLabel),
|
html.EscapeString(toolLabel),
|
||||||
html.EscapeString(truncate(evt.Input, 120)),
|
html.EscapeString(truncate(evt.Input, 120)),
|
||||||
durationString,
|
durStr,
|
||||||
statusIcon))
|
statusIcon,
|
||||||
|
i))
|
||||||
|
|
||||||
if evt.Input != "" {
|
if evt.Input != "" {
|
||||||
label := "Command"
|
label := "Command"
|
||||||
|
|
@ -187,26 +191,26 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
||||||
} else if evt.Tool == "Edit" || evt.Tool == "Write" {
|
} else if evt.Tool == "Edit" || evt.Tool == "Write" {
|
||||||
label = "File"
|
label = "File"
|
||||||
}
|
}
|
||||||
builder.WriteString(core.Sprintf(` <div class="section"><div class="label">%s</div><pre>%s</pre></div>
|
b.WriteString(core.Sprintf(` <div class="section"><div class="label">%s</div><pre>%s</pre></div>
|
||||||
`, label, html.EscapeString(evt.Input)))
|
`, label, html.EscapeString(evt.Input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if evt.Output != "" {
|
if evt.Output != "" {
|
||||||
outputClass := "output"
|
outClass := "output"
|
||||||
if !evt.Success {
|
if !evt.Success {
|
||||||
outputClass = "output err"
|
outClass = "output err"
|
||||||
}
|
}
|
||||||
builder.WriteString(core.Sprintf(` <div class="section"><div class="label">Output</div><pre class="%s">%s</pre></div>
|
b.WriteString(core.Sprintf(` <div class="section"><div class="label">Output</div><pre class="%s">%s</pre></div>
|
||||||
`, outputClass, html.EscapeString(evt.Output)))
|
`, outClass, html.EscapeString(evt.Output)))
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.WriteString(` </div>
|
b.WriteString(` </div>
|
||||||
</div>
|
</div>
|
||||||
`)
|
`)
|
||||||
eventIndex++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.WriteString(`</div>
|
b.WriteString(`</div>
|
||||||
<script>
|
<script>
|
||||||
function toggle(i) {
|
function toggle(i) {
|
||||||
document.getElementById('evt-'+i).classList.toggle('open');
|
document.getElementById('evt-'+i).classList.toggle('open');
|
||||||
|
|
@ -227,18 +231,28 @@ function filterEvents() {
|
||||||
el.classList.toggle('hidden', !show);
|
el.classList.toggle('hidden', !show);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function openHashEvent() {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (!hash || !hash.startsWith('#evt-')) return;
|
||||||
|
const el = document.getElementById(hash.slice(1));
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add('open');
|
||||||
|
el.scrollIntoView({block: 'start'});
|
||||||
|
}
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
|
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.getElementById('search').focus();
|
document.getElementById('search').focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
window.addEventListener('hashchange', openHashEvent);
|
||||||
|
document.addEventListener('DOMContentLoaded', openHashEvent);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`)
|
`)
|
||||||
|
|
||||||
writeResult := hostFS.Write(outputPath, builder.String())
|
writeResult := hostFS.Write(outputPath, b.String())
|
||||||
if !writeResult.OK {
|
if !writeResult.OK {
|
||||||
return core.E("RenderHTML", "write html", resultError(writeResult))
|
return core.E("RenderHTML", "write html", resultError(writeResult))
|
||||||
}
|
}
|
||||||
|
|
@ -246,9 +260,6 @@ document.addEventListener('keydown', e => {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortID returns the first 8 characters of an ID for display purposes.
|
|
||||||
//
|
|
||||||
// shortID("abc123def456") // "abc123de"
|
|
||||||
func shortID(id string) string {
|
func shortID(id string) string {
|
||||||
if len(id) > 8 {
|
if len(id) > 8 {
|
||||||
return id[:8]
|
return id[:8]
|
||||||
|
|
@ -256,9 +267,6 @@ func shortID(id string) string {
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatDuration renders a duration as a compact human-readable string.
|
|
||||||
//
|
|
||||||
// formatDuration(5*time.Minute + 30*time.Second) // "5m30s"
|
|
||||||
func formatDuration(d time.Duration) string {
|
func formatDuration(d time.Duration) string {
|
||||||
if d < time.Second {
|
if d < time.Second {
|
||||||
return core.Sprintf("%dms", d.Milliseconds())
|
return core.Sprintf("%dms", d.Milliseconds())
|
||||||
|
|
|
||||||
67
html_test.go
67
html_test.go
|
|
@ -73,6 +73,8 @@ func TestHTML_RenderHTMLBasicSession_Good(t *testing.T) {
|
||||||
assert.Contains(t, html, "Claude") // assistant event label
|
assert.Contains(t, html, "Claude") // assistant event label
|
||||||
assert.Contains(t, html, "Bash")
|
assert.Contains(t, html, "Bash")
|
||||||
assert.Contains(t, html, "Read")
|
assert.Contains(t, html, "Read")
|
||||||
|
assert.Contains(t, html, `href="#evt-0"`)
|
||||||
|
assert.Contains(t, html, "openHashEvent")
|
||||||
// Should contain JS for toggle and filter
|
// Should contain JS for toggle and filter
|
||||||
assert.Contains(t, html, "function toggle")
|
assert.Contains(t, html, "function toggle")
|
||||||
assert.Contains(t, html, "function filterEvents")
|
assert.Contains(t, html, "function filterEvents")
|
||||||
|
|
@ -230,68 +232,3 @@ func TestHTML_RenderHTMLLabelsByToolType_Good(t *testing.T) {
|
||||||
// Edit, Write get "File" label
|
// Edit, Write get "File" label
|
||||||
assert.True(t, core.Contains(html, "File"), "Edit/Write events should use 'File' label")
|
assert.True(t, core.Contains(html, "File"), "Edit/Write events should use 'File' label")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHTML_RenderHTMLSessionWithNoEvents_Bad(t *testing.T) {
|
|
||||||
// A session that has zero events should still produce valid HTML with 0 tool calls
|
|
||||||
dir := t.TempDir()
|
|
||||||
outputPath := dir + "/no-events.html"
|
|
||||||
|
|
||||||
sess := &Session{
|
|
||||||
ID: "no-events",
|
|
||||||
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
|
||||||
EndTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
|
||||||
Events: []Event{},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := RenderHTML(sess, outputPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
readResult := hostFS.Read(outputPath)
|
|
||||||
require.True(t, readResult.OK)
|
|
||||||
htmlContent := readResult.Value.(string)
|
|
||||||
assert.Contains(t, htmlContent, "0 tool calls")
|
|
||||||
assert.Contains(t, htmlContent, "<!DOCTYPE html>")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHTML_RenderHTMLAllEventsAreErrors_Bad(t *testing.T) {
|
|
||||||
// All tool events failed — error count should match tool count
|
|
||||||
dir := t.TempDir()
|
|
||||||
outputPath := dir + "/all-errors.html"
|
|
||||||
|
|
||||||
sess := &Session{
|
|
||||||
ID: "all-errors",
|
|
||||||
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
|
||||||
EndTime: time.Date(2026, 2, 20, 10, 0, 5, 0, time.UTC),
|
|
||||||
Events: []Event{
|
|
||||||
{
|
|
||||||
Timestamp: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
|
||||||
Type: "tool_use",
|
|
||||||
Tool: "Bash",
|
|
||||||
Input: "false",
|
|
||||||
Output: "exit 1",
|
|
||||||
Duration: 50 * time.Millisecond,
|
|
||||||
Success: false,
|
|
||||||
ErrorMsg: "exit 1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Timestamp: time.Date(2026, 2, 20, 10, 0, 2, 0, time.UTC),
|
|
||||||
Type: "tool_use",
|
|
||||||
Tool: "Bash",
|
|
||||||
Input: "cat /missing",
|
|
||||||
Output: "No such file",
|
|
||||||
Duration: 30 * time.Millisecond,
|
|
||||||
Success: false,
|
|
||||||
ErrorMsg: "No such file",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := RenderHTML(sess, outputPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
readResult := hostFS.Read(outputPath)
|
|
||||||
require.True(t, readResult.OK)
|
|
||||||
htmlContent := readResult.Value.(string)
|
|
||||||
assert.Contains(t, htmlContent, "2 errors")
|
|
||||||
assert.Contains(t, htmlContent, `class="event error"`)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ go-session provides two output formats for visualising parsed sessions: a self-c
|
||||||
- Yellow: User messages
|
- Yellow: User messages
|
||||||
- Grey: Assistant responses
|
- Grey: Assistant responses
|
||||||
- Red border: Failed tool calls
|
- Red border: Failed tool calls
|
||||||
|
- **Permalinks** on each event card for direct `#evt-N` links
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
|
|
|
||||||
173
parser.go
173
parser.go
|
|
@ -165,7 +165,7 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sess := Session{
|
s := Session{
|
||||||
ID: id,
|
ID: id,
|
||||||
Path: filePath,
|
Path: filePath,
|
||||||
}
|
}
|
||||||
|
|
@ -175,14 +175,14 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] {
|
||||||
if !openResult.OK {
|
if !openResult.OK {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fileHandle, ok := openResult.Value.(io.ReadCloser)
|
f, ok := openResult.Value.(io.ReadCloser)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner := bufio.NewScanner(fileHandle)
|
scanner := bufio.NewScanner(f)
|
||||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
var firstTimestamp, lastTimestamp string
|
var firstTS, lastTS string
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
var entry rawEntry
|
var entry rawEntry
|
||||||
if !core.JSONUnmarshal(scanner.Bytes(), &entry).OK {
|
if !core.JSONUnmarshal(scanner.Bytes(), &entry).OK {
|
||||||
|
|
@ -191,36 +191,36 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] {
|
||||||
if entry.Timestamp == "" {
|
if entry.Timestamp == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if firstTimestamp == "" {
|
if firstTS == "" {
|
||||||
firstTimestamp = entry.Timestamp
|
firstTS = entry.Timestamp
|
||||||
}
|
}
|
||||||
lastTimestamp = entry.Timestamp
|
lastTS = entry.Timestamp
|
||||||
}
|
}
|
||||||
fileHandle.Close()
|
f.Close()
|
||||||
|
|
||||||
if firstTimestamp != "" {
|
if firstTS != "" {
|
||||||
if parsedTime, err := time.Parse(time.RFC3339Nano, firstTimestamp); err == nil {
|
if t, err := time.Parse(time.RFC3339Nano, firstTS); err == nil {
|
||||||
sess.StartTime = parsedTime
|
s.StartTime = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if lastTimestamp != "" {
|
if lastTS != "" {
|
||||||
if parsedTime, err := time.Parse(time.RFC3339Nano, lastTimestamp); err == nil {
|
if t, err := time.Parse(time.RFC3339Nano, lastTS); err == nil {
|
||||||
sess.EndTime = parsedTime
|
s.EndTime = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sess.StartTime.IsZero() {
|
if s.StartTime.IsZero() {
|
||||||
sess.StartTime = info.ModTime()
|
s.StartTime = info.ModTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions = append(sessions, sess)
|
sessions = append(sessions, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
slices.SortFunc(sessions, func(i, j Session) int {
|
slices.SortFunc(sessions, func(i, j Session) int {
|
||||||
return j.StartTime.Compare(i.StartTime)
|
return j.StartTime.Compare(i.StartTime)
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, sess := range sessions {
|
for _, s := range sessions {
|
||||||
if !yield(sess) {
|
if !yield(s) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -292,16 +292,16 @@ func ParseTranscript(filePath string) (*Session, *ParseStats, error) {
|
||||||
if !openResult.OK {
|
if !openResult.OK {
|
||||||
return nil, nil, core.E("ParseTranscript", "open transcript", resultError(openResult))
|
return nil, nil, core.E("ParseTranscript", "open transcript", resultError(openResult))
|
||||||
}
|
}
|
||||||
fileHandle, ok := openResult.Value.(io.ReadCloser)
|
f, ok := openResult.Value.(io.ReadCloser)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, nil, core.E("ParseTranscript", "unexpected file handle type", nil)
|
return nil, nil, core.E("ParseTranscript", "unexpected file handle type", nil)
|
||||||
}
|
}
|
||||||
defer fileHandle.Close()
|
defer f.Close()
|
||||||
|
|
||||||
base := path.Base(filePath)
|
base := path.Base(filePath)
|
||||||
id := core.TrimSuffix(base, ".jsonl")
|
id := core.TrimSuffix(base, ".jsonl")
|
||||||
|
|
||||||
sess, stats, err := parseFromReader(fileHandle, id)
|
sess, stats, err := parseFromReader(f, id)
|
||||||
if sess != nil {
|
if sess != nil {
|
||||||
sess.Path = filePath
|
sess.Path = filePath
|
||||||
}
|
}
|
||||||
|
|
@ -325,9 +325,9 @@ func ParseTranscriptReader(r io.Reader, id string) (*Session, *ParseStats, error
|
||||||
return sess, stats, nil
|
return sess, stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseFromReader scans r line-by-line and returns parsed session events.
|
// parseFromReader is the shared implementation for both file-based and
|
||||||
//
|
// reader-based parsing. It scans line-by-line using bufio.Scanner with
|
||||||
// sess, stats, err := parseFromReader(f, "abc123")
|
// an 8 MiB buffer, gracefully skipping malformed lines.
|
||||||
func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
||||||
sess := &Session{
|
sess := &Session{
|
||||||
ID: id,
|
ID: id,
|
||||||
|
|
@ -375,27 +375,27 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
entryTime, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
|
ts, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: bad timestamp %q: %v", lineNum, entry.Timestamp, err))
|
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: bad timestamp %q: %v", lineNum, entry.Timestamp, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if sess.StartTime.IsZero() && !entryTime.IsZero() {
|
if sess.StartTime.IsZero() && !ts.IsZero() {
|
||||||
sess.StartTime = entryTime
|
sess.StartTime = ts
|
||||||
}
|
}
|
||||||
if !entryTime.IsZero() {
|
if !ts.IsZero() {
|
||||||
sess.EndTime = entryTime
|
sess.EndTime = ts
|
||||||
}
|
}
|
||||||
|
|
||||||
switch entry.Type {
|
switch entry.Type {
|
||||||
case "assistant":
|
case "assistant":
|
||||||
var message rawMessage
|
var msg rawMessage
|
||||||
if !core.JSONUnmarshal(entry.Message, &message).OK {
|
if !core.JSONUnmarshal(entry.Message, &msg).OK {
|
||||||
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: failed to unmarshal assistant message", lineNum))
|
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: failed to unmarshal assistant message", lineNum))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for i, raw := range message.Content {
|
for i, raw := range msg.Content {
|
||||||
var block contentBlock
|
var block contentBlock
|
||||||
if !core.JSONUnmarshal(raw, &block).OK {
|
if !core.JSONUnmarshal(raw, &block).OK {
|
||||||
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d block %d: failed to unmarshal content", lineNum, i))
|
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d block %d: failed to unmarshal content", lineNum, i))
|
||||||
|
|
@ -406,29 +406,29 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
||||||
case "text":
|
case "text":
|
||||||
if text := core.Trim(block.Text); text != "" {
|
if text := core.Trim(block.Text); text != "" {
|
||||||
sess.Events = append(sess.Events, Event{
|
sess.Events = append(sess.Events, Event{
|
||||||
Timestamp: entryTime,
|
Timestamp: ts,
|
||||||
Type: "assistant",
|
Type: "assistant",
|
||||||
Input: truncate(text, 500),
|
Input: truncate(text, 500),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
toolInput := extractToolInput(block.Name, block.Input)
|
inputStr := extractToolInput(block.Name, block.Input)
|
||||||
pendingTools[block.ID] = toolUse{
|
pendingTools[block.ID] = toolUse{
|
||||||
timestamp: entryTime,
|
timestamp: ts,
|
||||||
tool: block.Name,
|
tool: block.Name,
|
||||||
input: toolInput,
|
input: inputStr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "user":
|
case "user":
|
||||||
var message rawMessage
|
var msg rawMessage
|
||||||
if !core.JSONUnmarshal(entry.Message, &message).OK {
|
if !core.JSONUnmarshal(entry.Message, &msg).OK {
|
||||||
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: failed to unmarshal user message", lineNum))
|
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: failed to unmarshal user message", lineNum))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for i, raw := range message.Content {
|
for i, raw := range msg.Content {
|
||||||
var block contentBlock
|
var block contentBlock
|
||||||
if !core.JSONUnmarshal(raw, &block).OK {
|
if !core.JSONUnmarshal(raw, &block).OK {
|
||||||
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d block %d: failed to unmarshal content", lineNum, i))
|
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d block %d: failed to unmarshal content", lineNum, i))
|
||||||
|
|
@ -437,17 +437,17 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
||||||
|
|
||||||
switch block.Type {
|
switch block.Type {
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
if pendingTool, ok := pendingTools[block.ToolUseID]; ok {
|
if tu, ok := pendingTools[block.ToolUseID]; ok {
|
||||||
output := extractResultContent(block.Content)
|
output := extractResultContent(block.Content)
|
||||||
isError := block.IsError != nil && *block.IsError
|
isError := block.IsError != nil && *block.IsError
|
||||||
evt := Event{
|
evt := Event{
|
||||||
Timestamp: pendingTool.timestamp,
|
Timestamp: tu.timestamp,
|
||||||
Type: "tool_use",
|
Type: "tool_use",
|
||||||
Tool: pendingTool.tool,
|
Tool: tu.tool,
|
||||||
ToolID: block.ToolUseID,
|
ToolID: block.ToolUseID,
|
||||||
Input: pendingTool.input,
|
Input: tu.input,
|
||||||
Output: truncate(output, 2000),
|
Output: truncate(output, 2000),
|
||||||
Duration: entryTime.Sub(pendingTool.timestamp),
|
Duration: ts.Sub(tu.timestamp),
|
||||||
Success: !isError,
|
Success: !isError,
|
||||||
}
|
}
|
||||||
if isError {
|
if isError {
|
||||||
|
|
@ -460,7 +460,7 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
||||||
case "text":
|
case "text":
|
||||||
if text := core.Trim(block.Text); text != "" {
|
if text := core.Trim(block.Text); text != "" {
|
||||||
sess.Events = append(sess.Events, Event{
|
sess.Events = append(sess.Events, Event{
|
||||||
Timestamp: entryTime,
|
Timestamp: ts,
|
||||||
Type: "user",
|
Type: "user",
|
||||||
Input: truncate(text, 500),
|
Input: truncate(text, 500),
|
||||||
})
|
})
|
||||||
|
|
@ -492,9 +492,6 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
||||||
return sess, stats, nil
|
return sess, stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractToolInput decodes a tool's raw JSON input to a human-readable string.
|
|
||||||
//
|
|
||||||
// label := extractToolInput("Bash", raw) // "ls -la # list files"
|
|
||||||
func extractToolInput(toolName string, raw rawJSON) string {
|
func extractToolInput(toolName string, raw rawJSON) string {
|
||||||
if raw == nil {
|
if raw == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -502,67 +499,64 @@ func extractToolInput(toolName string, raw rawJSON) string {
|
||||||
|
|
||||||
switch toolName {
|
switch toolName {
|
||||||
case "Bash":
|
case "Bash":
|
||||||
var input bashInput
|
var inp bashInput
|
||||||
if core.JSONUnmarshal(raw, &input).OK {
|
if core.JSONUnmarshal(raw, &inp).OK {
|
||||||
desc := input.Description
|
desc := inp.Description
|
||||||
if desc != "" {
|
if desc != "" {
|
||||||
desc = " # " + desc
|
desc = " # " + desc
|
||||||
}
|
}
|
||||||
return input.Command + desc
|
return inp.Command + desc
|
||||||
}
|
}
|
||||||
case "Read":
|
case "Read":
|
||||||
var input readInput
|
var inp readInput
|
||||||
if core.JSONUnmarshal(raw, &input).OK {
|
if core.JSONUnmarshal(raw, &inp).OK {
|
||||||
return input.FilePath
|
return inp.FilePath
|
||||||
}
|
}
|
||||||
case "Edit":
|
case "Edit":
|
||||||
var input editInput
|
var inp editInput
|
||||||
if core.JSONUnmarshal(raw, &input).OK {
|
if core.JSONUnmarshal(raw, &inp).OK {
|
||||||
return core.Sprintf("%s (edit)", input.FilePath)
|
return core.Sprintf("%s (edit)", inp.FilePath)
|
||||||
}
|
}
|
||||||
case "Write":
|
case "Write":
|
||||||
var input writeInput
|
var inp writeInput
|
||||||
if core.JSONUnmarshal(raw, &input).OK {
|
if core.JSONUnmarshal(raw, &inp).OK {
|
||||||
return core.Sprintf("%s (%d bytes)", input.FilePath, len(input.Content))
|
return core.Sprintf("%s (%d bytes)", inp.FilePath, len(inp.Content))
|
||||||
}
|
}
|
||||||
case "Grep":
|
case "Grep":
|
||||||
var input grepInput
|
var inp grepInput
|
||||||
if core.JSONUnmarshal(raw, &input).OK {
|
if core.JSONUnmarshal(raw, &inp).OK {
|
||||||
grepPath := input.Path
|
path := inp.Path
|
||||||
if grepPath == "" {
|
if path == "" {
|
||||||
grepPath = "."
|
path = "."
|
||||||
}
|
}
|
||||||
return core.Sprintf("/%s/ in %s", input.Pattern, grepPath)
|
return core.Sprintf("/%s/ in %s", inp.Pattern, path)
|
||||||
}
|
}
|
||||||
case "Glob":
|
case "Glob":
|
||||||
var input globInput
|
var inp globInput
|
||||||
if core.JSONUnmarshal(raw, &input).OK {
|
if core.JSONUnmarshal(raw, &inp).OK {
|
||||||
return input.Pattern
|
return inp.Pattern
|
||||||
}
|
}
|
||||||
case "Task":
|
case "Task":
|
||||||
var input taskInput
|
var inp taskInput
|
||||||
if core.JSONUnmarshal(raw, &input).OK {
|
if core.JSONUnmarshal(raw, &inp).OK {
|
||||||
desc := input.Description
|
desc := inp.Description
|
||||||
if desc == "" {
|
if desc == "" {
|
||||||
desc = truncate(input.Prompt, 80)
|
desc = truncate(inp.Prompt, 80)
|
||||||
}
|
}
|
||||||
return core.Sprintf("[%s] %s", input.SubagentType, desc)
|
return core.Sprintf("[%s] %s", inp.SubagentType, desc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: show raw JSON keys
|
// Fallback: show raw JSON keys
|
||||||
var jsonFields map[string]any
|
var m map[string]any
|
||||||
if core.JSONUnmarshal(raw, &jsonFields).OK {
|
if core.JSONUnmarshal(raw, &m).OK {
|
||||||
parts := slices.Sorted(maps.Keys(jsonFields))
|
parts := slices.Sorted(maps.Keys(m))
|
||||||
return core.Join(", ", parts...)
|
return core.Join(", ", parts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractResultContent coerces a tool_result content value to a plain string.
|
|
||||||
//
|
|
||||||
// text := extractResultContent(block.Content) // "total 42\n..."
|
|
||||||
func extractResultContent(content any) string {
|
func extractResultContent(content any) string {
|
||||||
switch v := content.(type) {
|
switch v := content.(type) {
|
||||||
case string:
|
case string:
|
||||||
|
|
@ -570,8 +564,8 @@ func extractResultContent(content any) string {
|
||||||
case []any:
|
case []any:
|
||||||
var parts []string
|
var parts []string
|
||||||
for _, item := range v {
|
for _, item := range v {
|
||||||
if contentMap, ok := item.(map[string]any); ok {
|
if m, ok := item.(map[string]any); ok {
|
||||||
if text, ok := contentMap["text"].(string); ok {
|
if text, ok := m["text"].(string); ok {
|
||||||
parts = append(parts, text)
|
parts = append(parts, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -585,12 +579,9 @@ func extractResultContent(content any) string {
|
||||||
return core.Sprint(content)
|
return core.Sprint(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncate clips text to at most maxLen bytes, appending "..." if clipped.
|
func truncate(s string, max int) string {
|
||||||
//
|
if len(s) <= max {
|
||||||
// truncate("hello world", 5) // "hello..."
|
return s
|
||||||
func truncate(text string, maxLen int) string {
|
|
||||||
if len(text) <= maxLen {
|
|
||||||
return text
|
|
||||||
}
|
}
|
||||||
return text[:maxLen] + "..."
|
return s[:max] + "..."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
search.go
12
search.go
|
|
@ -54,17 +54,17 @@ func SearchSeq(projectsDir, query string) iter.Seq[SearchResult] {
|
||||||
}
|
}
|
||||||
text := core.Lower(core.Concat(evt.Input, " ", evt.Output))
|
text := core.Lower(core.Concat(evt.Input, " ", evt.Output))
|
||||||
if core.Contains(text, query) {
|
if core.Contains(text, query) {
|
||||||
matchContext := evt.Input
|
matchCtx := evt.Input
|
||||||
if matchContext == "" {
|
if matchCtx == "" {
|
||||||
matchContext = truncate(evt.Output, 120)
|
matchCtx = truncate(evt.Output, 120)
|
||||||
}
|
}
|
||||||
result := SearchResult{
|
res := SearchResult{
|
||||||
SessionID: sess.ID,
|
SessionID: sess.ID,
|
||||||
Timestamp: evt.Timestamp,
|
Timestamp: evt.Timestamp,
|
||||||
Tool: evt.Tool,
|
Tool: evt.Tool,
|
||||||
Match: matchContext,
|
Match: matchCtx,
|
||||||
}
|
}
|
||||||
if !yield(result) {
|
if !yield(res) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"path"
|
"path"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -164,38 +163,3 @@ func TestSearch_SearchMalformedSessionSkipped_Bad(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, results, 1, "should still find matches in valid sessions")
|
assert.Len(t, results, 1, "should still find matches in valid sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearch_SearchEmptyQuery_Ugly(t *testing.T) {
|
|
||||||
// An empty query string matches every tool event (empty substring is always contained)
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeJSONL(t, dir, "session.jsonl",
|
|
||||||
toolUseEntry(ts(0), "Bash", "t1", map[string]any{"command": "ls"}),
|
|
||||||
toolResultEntry(ts(1), "t1", "file.go", false),
|
|
||||||
toolUseEntry(ts(2), "Read", "t2", map[string]any{"file_path": "/tmp/x.go"}),
|
|
||||||
toolResultEntry(ts(3), "t2", "package main", false),
|
|
||||||
)
|
|
||||||
|
|
||||||
results, err := Search(dir, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, results, 2, "empty query should match all tool events")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearch_SearchSeqEarlyTermination_Ugly(t *testing.T) {
|
|
||||||
// Caller breaks out of SearchSeq early — should not hang or panic
|
|
||||||
dir := t.TempDir()
|
|
||||||
for i := range 5 {
|
|
||||||
name := core.Sprintf("session-%c.jsonl", rune('a'+i))
|
|
||||||
writeJSONL(t, dir, name,
|
|
||||||
toolUseEntry(ts(0), "Bash", "t1", map[string]any{"command": "go test"}),
|
|
||||||
toolResultEntry(ts(1), "t1", "PASS", false),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int
|
|
||||||
for range SearchSeq(dir, "go test") {
|
|
||||||
count++
|
|
||||||
break // early termination
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, 1, count, "early break from SearchSeq should yield exactly one result")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
92
video.go
92
video.go
|
|
@ -40,31 +40,28 @@ func RenderMP4(sess *Session, outputPath string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateTape produces a VHS .tape script from session events.
|
|
||||||
//
|
|
||||||
// tape := generateTape(sess, "/tmp/session.mp4")
|
|
||||||
func generateTape(sess *Session, outputPath string) string {
|
func generateTape(sess *Session, outputPath string) string {
|
||||||
builder := core.NewBuilder()
|
b := core.NewBuilder()
|
||||||
|
|
||||||
builder.WriteString(core.Sprintf("Output %s\n", outputPath))
|
b.WriteString(core.Sprintf("Output %s\n", outputPath))
|
||||||
builder.WriteString("Set FontSize 16\n")
|
b.WriteString("Set FontSize 16\n")
|
||||||
builder.WriteString("Set Width 1400\n")
|
b.WriteString("Set Width 1400\n")
|
||||||
builder.WriteString("Set Height 800\n")
|
b.WriteString("Set Height 800\n")
|
||||||
builder.WriteString("Set TypingSpeed 30ms\n")
|
b.WriteString("Set TypingSpeed 30ms\n")
|
||||||
builder.WriteString("Set Theme \"Catppuccin Mocha\"\n")
|
b.WriteString("Set Theme \"Catppuccin Mocha\"\n")
|
||||||
builder.WriteString("Set Shell bash\n")
|
b.WriteString("Set Shell bash\n")
|
||||||
builder.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
// Title frame
|
// Title frame
|
||||||
sessionID := sess.ID
|
id := sess.ID
|
||||||
if len(sessionID) > 8 {
|
if len(id) > 8 {
|
||||||
sessionID = sessionID[:8]
|
id = id[:8]
|
||||||
}
|
}
|
||||||
builder.WriteString(core.Sprintf("Type \"# Session %s | %s\"\n",
|
b.WriteString(core.Sprintf("Type \"# Session %s | %s\"\n",
|
||||||
sessionID, sess.StartTime.Format("2006-01-02 15:04")))
|
id, sess.StartTime.Format("2006-01-02 15:04")))
|
||||||
builder.WriteString("Enter\n")
|
b.WriteString("Enter\n")
|
||||||
builder.WriteString("Sleep 2s\n")
|
b.WriteString("Sleep 2s\n")
|
||||||
builder.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
for _, evt := range sess.Events {
|
for _, evt := range sess.Events {
|
||||||
if evt.Type != "tool_use" {
|
if evt.Type != "tool_use" {
|
||||||
|
|
@ -78,8 +75,8 @@ func generateTape(sess *Session, outputPath string) string {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Show the command
|
// Show the command
|
||||||
builder.WriteString(core.Sprintf("Type %q\n", "$ "+cmd))
|
b.WriteString(core.Sprintf("Type %q\n", "$ "+cmd))
|
||||||
builder.WriteString("Enter\n")
|
b.WriteString("Enter\n")
|
||||||
|
|
||||||
// Show abbreviated output
|
// Show abbreviated output
|
||||||
output := evt.Output
|
output := evt.Output
|
||||||
|
|
@ -91,52 +88,47 @@ func generateTape(sess *Session, outputPath string) string {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
builder.WriteString(core.Sprintf("Type %q\n", line))
|
b.WriteString(core.Sprintf("Type %q\n", line))
|
||||||
builder.WriteString("Enter\n")
|
b.WriteString("Enter\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status indicator
|
// Status indicator
|
||||||
if !evt.Success {
|
if !evt.Success {
|
||||||
builder.WriteString("Type \"# ✗ FAILED\"\n")
|
b.WriteString("Type \"# ✗ FAILED\"\n")
|
||||||
} else {
|
} else {
|
||||||
builder.WriteString("Type \"# ✓ OK\"\n")
|
b.WriteString("Type \"# ✓ OK\"\n")
|
||||||
}
|
}
|
||||||
builder.WriteString("Enter\n")
|
b.WriteString("Enter\n")
|
||||||
builder.WriteString("Sleep 1s\n")
|
b.WriteString("Sleep 1s\n")
|
||||||
builder.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
case "Read", "Edit", "Write":
|
case "Read", "Edit", "Write":
|
||||||
builder.WriteString(core.Sprintf("Type %q\n",
|
b.WriteString(core.Sprintf("Type %q\n",
|
||||||
core.Sprintf("# %s: %s", evt.Tool, truncate(evt.Input, 80))))
|
core.Sprintf("# %s: %s", evt.Tool, truncate(evt.Input, 80))))
|
||||||
builder.WriteString("Enter\n")
|
b.WriteString("Enter\n")
|
||||||
builder.WriteString("Sleep 500ms\n")
|
b.WriteString("Sleep 500ms\n")
|
||||||
|
|
||||||
case "Task":
|
case "Task":
|
||||||
builder.WriteString(core.Sprintf("Type %q\n",
|
b.WriteString(core.Sprintf("Type %q\n",
|
||||||
core.Sprintf("# Agent: %s", truncate(evt.Input, 80))))
|
core.Sprintf("# Agent: %s", truncate(evt.Input, 80))))
|
||||||
builder.WriteString("Enter\n")
|
b.WriteString("Enter\n")
|
||||||
builder.WriteString("Sleep 1s\n")
|
b.WriteString("Sleep 1s\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.WriteString("Sleep 3s\n")
|
b.WriteString("Sleep 3s\n")
|
||||||
return builder.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractCommand strips the description suffix from a Bash input string.
|
|
||||||
//
|
|
||||||
// extractCommand("ls -la # list files") // "ls -la"
|
|
||||||
func extractCommand(input string) string {
|
func extractCommand(input string) string {
|
||||||
if index := indexOf(input, " # "); index > 0 {
|
// Remove description suffix (after " # ")
|
||||||
return input[:index]
|
if idx := indexOf(input, " # "); idx > 0 {
|
||||||
|
return input[:idx]
|
||||||
}
|
}
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookupExecutable searches PATH for an executable with the given name.
|
|
||||||
//
|
|
||||||
// lookupExecutable("vhs") // "/usr/local/bin/vhs"
|
|
||||||
func lookupExecutable(name string) string {
|
func lookupExecutable(name string) string {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -160,9 +152,6 @@ func lookupExecutable(name string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// isExecutablePath reports whether filePath is a regular executable file.
|
|
||||||
//
|
|
||||||
// isExecutablePath("/usr/bin/vhs") // true
|
|
||||||
func isExecutablePath(filePath string) bool {
|
func isExecutablePath(filePath string) bool {
|
||||||
statResult := hostFS.Stat(filePath)
|
statResult := hostFS.Stat(filePath)
|
||||||
if !statResult.OK {
|
if !statResult.OK {
|
||||||
|
|
@ -175,17 +164,14 @@ func isExecutablePath(filePath string) bool {
|
||||||
return info.Mode()&0111 != 0
|
return info.Mode()&0111 != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCommand executes command with args, inheriting stdio, and waits for exit.
|
|
||||||
//
|
|
||||||
// err := runCommand("/usr/local/bin/vhs", "/tmp/session.tape")
|
|
||||||
func runCommand(command string, args ...string) error {
|
func runCommand(command string, args ...string) error {
|
||||||
arguments := append([]string{command}, args...)
|
argv := append([]string{command}, args...)
|
||||||
procAttr := &syscall.ProcAttr{
|
procAttr := &syscall.ProcAttr{
|
||||||
Env: syscall.Environ(),
|
Env: syscall.Environ(),
|
||||||
Files: []uintptr{0, 1, 2},
|
Files: []uintptr{0, 1, 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
pid, err := syscall.ForkExec(command, arguments, procAttr)
|
pid, err := syscall.ForkExec(command, argv, procAttr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return core.E("runCommand", "fork exec command", err)
|
return core.E("runCommand", "fork exec command", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue