@pi-in-go/pigpen-session-ingest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +15 -0
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/extensions/session-ingest/bench_test.go +68 -0
- package/extensions/session-ingest/extension.go +1111 -0
- package/extensions/session-ingest/extension_test.go +65 -0
- package/extensions/session-ingest/fakehost_test.go +548 -0
- package/extensions/session-ingest/go.mod +6 -0
- package/extensions/session-ingest/go.sum +2 -0
- package/extensions/session-ingest/modes.go +464 -0
- package/extensions/session-ingest/modes_test.go +254 -0
- package/extensions/session-ingest/parse_allocs_test.go +36 -0
- package/extensions/session-ingest/review_test.go +53 -0
- package/extensions/session-ingest/session_test.go +267 -0
- package/package.json +38 -0
- package/provenance.json +7 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
package sessioningest
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"cmp"
|
|
5
|
+
"fmt"
|
|
6
|
+
"slices"
|
|
7
|
+
"strings"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// ── mode=turn ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
var defaultInclude = []string{"text", "tool_calls", "errors"}
|
|
16
|
+
|
|
17
|
+
type turnReport struct {
|
|
18
|
+
Mode string `json:"mode"`
|
|
19
|
+
Path string `json:"path"`
|
|
20
|
+
Session sessionMetadata `json:"session"`
|
|
21
|
+
Turn int `json:"turn"`
|
|
22
|
+
TotalTurns int `json:"totalTurns"`
|
|
23
|
+
Include []string `json:"include"`
|
|
24
|
+
Messages []turnMessage `json:"messages"`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type turnMessage struct {
|
|
28
|
+
EntryIndex int `json:"entryIndex"`
|
|
29
|
+
MessageIndex int `json:"messageIndex"`
|
|
30
|
+
Timestamp string `json:"timestamp,omitempty"`
|
|
31
|
+
Role string `json:"role"`
|
|
32
|
+
Text []string `json:"text,omitempty"`
|
|
33
|
+
Thinking []string `json:"thinking,omitempty"`
|
|
34
|
+
ToolCalls []toolCall `json:"toolCalls,omitempty"`
|
|
35
|
+
ToolName string `json:"toolName,omitempty"`
|
|
36
|
+
IsError bool `json:"isError,omitempty"`
|
|
37
|
+
StopReason string `json:"stopReason,omitempty"`
|
|
38
|
+
ErrorMessage string `json:"errorMessage,omitempty"`
|
|
39
|
+
Model string `json:"model,omitempty"`
|
|
40
|
+
Usage *usageSummary `json:"usage,omitempty"`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func handleTurn(session sessionFile, params map[string]any, format, path string, start, limit, maxChars int) (any, error) {
|
|
44
|
+
turn := intParam(params, "turn", 0)
|
|
45
|
+
total := countTurns(session.Messages)
|
|
46
|
+
if turn < 1 {
|
|
47
|
+
return nil, sdk.NewToolError(fmt.Sprintf("turn is required for mode=turn (1..%d)", total))
|
|
48
|
+
}
|
|
49
|
+
if turn > total {
|
|
50
|
+
return nil, sdk.NewToolError(fmt.Sprintf("turn %d is out of range: the session has %d turns", turn, total))
|
|
51
|
+
}
|
|
52
|
+
include := stringListParam(params, "include")
|
|
53
|
+
if len(include) == 0 {
|
|
54
|
+
include = defaultInclude
|
|
55
|
+
}
|
|
56
|
+
report := buildTurnReport(session, turn, total, include)
|
|
57
|
+
if format == "json" {
|
|
58
|
+
return report, nil
|
|
59
|
+
}
|
|
60
|
+
content := truncateOutput(formatTurnMarkdown(report, session.Metadata), path, "turn", start, limit, 0)
|
|
61
|
+
return sdk.ToolResult{
|
|
62
|
+
Content: content,
|
|
63
|
+
Preview: fmt.Sprintf("Turn %d of %d — %d messages", turn, total, len(report.Messages)),
|
|
64
|
+
}, nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func countTurns(messages []sessionMessage) int {
|
|
68
|
+
n := 0
|
|
69
|
+
for _, m := range messages {
|
|
70
|
+
if m.Role == "user" {
|
|
71
|
+
n++
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return n
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func buildTurnReport(session sessionFile, turn, total int, include []string) turnReport {
|
|
78
|
+
want := map[string]bool{}
|
|
79
|
+
for _, name := range include {
|
|
80
|
+
want[name] = true
|
|
81
|
+
}
|
|
82
|
+
report := turnReport{Mode: "turn", Path: session.Path, Session: session.Metadata, Turn: turn, TotalTurns: total, Include: include, Messages: []turnMessage{}}
|
|
83
|
+
current := 0
|
|
84
|
+
for _, msg := range session.Messages {
|
|
85
|
+
if msg.Role == "user" {
|
|
86
|
+
current++
|
|
87
|
+
}
|
|
88
|
+
if current != turn {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
out := turnMessage{EntryIndex: msg.EntryIndex, MessageIndex: msg.MessageIndex, Timestamp: msg.Timestamp, Role: msg.Role, ToolName: msg.ToolName, Model: msg.Model}
|
|
92
|
+
if msg.Role == "toolResult" {
|
|
93
|
+
if want["tool_results"] {
|
|
94
|
+
out.Text = msg.Texts
|
|
95
|
+
}
|
|
96
|
+
} else if want["text"] {
|
|
97
|
+
out.Text = msg.Texts
|
|
98
|
+
}
|
|
99
|
+
if want["thinking"] {
|
|
100
|
+
out.Thinking = msg.Thinking
|
|
101
|
+
}
|
|
102
|
+
if want["tool_calls"] {
|
|
103
|
+
out.ToolCalls = msg.ToolCalls
|
|
104
|
+
}
|
|
105
|
+
if want["errors"] {
|
|
106
|
+
out.IsError = msg.IsError
|
|
107
|
+
out.ErrorMessage = msg.ErrorMessage
|
|
108
|
+
if strings.EqualFold(msg.StopReason, "error") {
|
|
109
|
+
out.StopReason = msg.StopReason
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if want["usage"] && msg.Usage != (usageSummary{}) {
|
|
113
|
+
usage := msg.Usage
|
|
114
|
+
out.Usage = &usage
|
|
115
|
+
}
|
|
116
|
+
report.Messages = append(report.Messages, out)
|
|
117
|
+
}
|
|
118
|
+
return report
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func formatTurnMarkdown(report turnReport, meta sessionMetadata) string {
|
|
122
|
+
var b strings.Builder
|
|
123
|
+
fmt.Fprintf(&b, "# Session turn %d of %d\n\n", report.Turn, report.TotalTurns)
|
|
124
|
+
fmt.Fprintf(&b, "- session: %s\n", emptyDash(shortID(meta.ID)))
|
|
125
|
+
fmt.Fprintf(&b, "- messages: %d\n", len(report.Messages))
|
|
126
|
+
fmt.Fprintf(&b, "- include: %s\n", strings.Join(report.Include, ", "))
|
|
127
|
+
for _, msg := range report.Messages {
|
|
128
|
+
fmt.Fprintf(&b, "\n## message %d %s\n\n", msg.MessageIndex, msg.Role)
|
|
129
|
+
if msg.ToolName != "" {
|
|
130
|
+
fmt.Fprintf(&b, "- tool: %s\n", msg.ToolName)
|
|
131
|
+
}
|
|
132
|
+
if msg.Model != "" {
|
|
133
|
+
fmt.Fprintf(&b, "- model: %s\n", msg.Model)
|
|
134
|
+
}
|
|
135
|
+
if msg.IsError {
|
|
136
|
+
fmt.Fprintf(&b, "- errors: tool result reported an error\n")
|
|
137
|
+
}
|
|
138
|
+
if msg.StopReason != "" {
|
|
139
|
+
detail := ""
|
|
140
|
+
if msg.ErrorMessage != "" {
|
|
141
|
+
detail = ": " + msg.ErrorMessage
|
|
142
|
+
}
|
|
143
|
+
fmt.Fprintf(&b, "- errors: stop reason %s%s\n", msg.StopReason, detail)
|
|
144
|
+
}
|
|
145
|
+
if len(msg.ToolCalls) > 0 {
|
|
146
|
+
labels := make([]string, 0, len(msg.ToolCalls))
|
|
147
|
+
for _, call := range msg.ToolCalls {
|
|
148
|
+
label := call.Name
|
|
149
|
+
if call.Summary != "" {
|
|
150
|
+
label += " (" + call.Summary + ")"
|
|
151
|
+
}
|
|
152
|
+
labels = append(labels, label)
|
|
153
|
+
}
|
|
154
|
+
fmt.Fprintf(&b, "- tool_calls: %s\n", strings.Join(labels, "; "))
|
|
155
|
+
}
|
|
156
|
+
if msg.Usage != nil {
|
|
157
|
+
u := msg.Usage
|
|
158
|
+
fmt.Fprintf(&b, "- usage: in %d, out %d, cache read %d, cache write %d, total %d, $%.4f\n", u.Input, u.Output, u.CacheRead, u.CacheWrite, u.Total, u.Cost)
|
|
159
|
+
}
|
|
160
|
+
for _, text := range msg.Thinking {
|
|
161
|
+
fmt.Fprintf(&b, "\n_thinking:_ %s\n", text)
|
|
162
|
+
}
|
|
163
|
+
for _, text := range msg.Text {
|
|
164
|
+
fmt.Fprintf(&b, "\n%s\n", text)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return b.String()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── mode=tools ───────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
type toolsReport struct {
|
|
173
|
+
Mode string `json:"mode"`
|
|
174
|
+
Path string `json:"path"`
|
|
175
|
+
Session sessionMetadata `json:"session"`
|
|
176
|
+
Turn int `json:"turn,omitempty"`
|
|
177
|
+
TotalCalls int `json:"totalCalls"`
|
|
178
|
+
Tools []toolUsage `json:"tools"`
|
|
179
|
+
FilesTotal int `json:"filesTotal"`
|
|
180
|
+
Start int `json:"start"`
|
|
181
|
+
Limit int `json:"limit"`
|
|
182
|
+
Files []fileTouched `json:"files"`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
type toolUsage struct {
|
|
186
|
+
Name string `json:"name"`
|
|
187
|
+
Calls int `json:"calls"`
|
|
188
|
+
Results int `json:"results"`
|
|
189
|
+
Errors int `json:"errors"`
|
|
190
|
+
FirstTurn int `json:"firstTurn"`
|
|
191
|
+
LastTurn int `json:"lastTurn"`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type fileTouched struct {
|
|
195
|
+
Path string `json:"path"`
|
|
196
|
+
Tools []string `json:"tools"`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func handleTools(session sessionFile, params map[string]any, format, path string, start, limit int) (any, error) {
|
|
200
|
+
turn := intParam(params, "turn", 0)
|
|
201
|
+
if turn < 0 {
|
|
202
|
+
return nil, sdk.NewToolError("turn must be 1 or greater")
|
|
203
|
+
}
|
|
204
|
+
if total := countTurns(session.Messages); turn > total {
|
|
205
|
+
return nil, sdk.NewToolError(fmt.Sprintf("turn %d is out of range: the session has %d turns", turn, total))
|
|
206
|
+
}
|
|
207
|
+
report := buildToolsReport(session, turn, start, limit)
|
|
208
|
+
if format == "json" {
|
|
209
|
+
return report, nil
|
|
210
|
+
}
|
|
211
|
+
return sdk.ToolResult{
|
|
212
|
+
Content: truncateOutput(formatToolsMarkdown(report), path, "tools", start, limit, report.FilesTotal),
|
|
213
|
+
Preview: fmt.Sprintf("%d tool calls across %d tools, %d files", report.TotalCalls, len(report.Tools), report.FilesTotal),
|
|
214
|
+
}, nil
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
func buildToolsReport(session sessionFile, turn, start, limit int) toolsReport {
|
|
218
|
+
usage := map[string]*toolUsage{}
|
|
219
|
+
touched := map[string]map[string]bool{}
|
|
220
|
+
get := func(name string, turnIndex int) *toolUsage {
|
|
221
|
+
u := usage[name]
|
|
222
|
+
if u == nil {
|
|
223
|
+
u = &toolUsage{Name: name, FirstTurn: turnIndex}
|
|
224
|
+
usage[name] = u
|
|
225
|
+
}
|
|
226
|
+
u.LastTurn = turnIndex
|
|
227
|
+
return u
|
|
228
|
+
}
|
|
229
|
+
report := toolsReport{Mode: "tools", Path: session.Path, Session: session.Metadata, Turn: turn, Limit: limit}
|
|
230
|
+
current := 0
|
|
231
|
+
for _, msg := range session.Messages {
|
|
232
|
+
if msg.Role == "user" {
|
|
233
|
+
current++
|
|
234
|
+
}
|
|
235
|
+
if current == 0 || (turn > 0 && current != turn) {
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
switch msg.Role {
|
|
239
|
+
case "assistant":
|
|
240
|
+
for _, call := range msg.ToolCalls {
|
|
241
|
+
get(call.Name, current).Calls++
|
|
242
|
+
report.TotalCalls++
|
|
243
|
+
if call.Path != "" {
|
|
244
|
+
if touched[call.Path] == nil {
|
|
245
|
+
touched[call.Path] = map[string]bool{}
|
|
246
|
+
}
|
|
247
|
+
touched[call.Path][call.Name] = true
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
case "toolResult":
|
|
251
|
+
name := msg.ToolName
|
|
252
|
+
if name == "" {
|
|
253
|
+
name = "unknown"
|
|
254
|
+
}
|
|
255
|
+
u := get(name, current)
|
|
256
|
+
u.Results++
|
|
257
|
+
if msg.IsError {
|
|
258
|
+
u.Errors++
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
report.Tools = make([]toolUsage, 0, len(usage))
|
|
263
|
+
for _, u := range usage {
|
|
264
|
+
report.Tools = append(report.Tools, *u)
|
|
265
|
+
}
|
|
266
|
+
slices.SortFunc(report.Tools, func(a, b toolUsage) int {
|
|
267
|
+
if c := cmp.Compare(b.Calls, a.Calls); c != 0 {
|
|
268
|
+
return c
|
|
269
|
+
}
|
|
270
|
+
return cmp.Compare(a.Name, b.Name)
|
|
271
|
+
})
|
|
272
|
+
files := make([]fileTouched, 0, len(touched))
|
|
273
|
+
for p, tools := range touched {
|
|
274
|
+
names := make([]string, 0, len(tools))
|
|
275
|
+
for name := range tools {
|
|
276
|
+
names = append(names, name)
|
|
277
|
+
}
|
|
278
|
+
slices.Sort(names)
|
|
279
|
+
files = append(files, fileTouched{Path: p, Tools: names})
|
|
280
|
+
}
|
|
281
|
+
slices.SortFunc(files, func(a, b fileTouched) int { return cmp.Compare(a.Path, b.Path) })
|
|
282
|
+
report.FilesTotal = len(files)
|
|
283
|
+
end := min(len(files), start+limit)
|
|
284
|
+
if start > len(files) {
|
|
285
|
+
start, end = len(files), len(files)
|
|
286
|
+
}
|
|
287
|
+
report.Start = start
|
|
288
|
+
report.Files = files[start:end]
|
|
289
|
+
return report
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
func formatToolsMarkdown(report toolsReport) string {
|
|
293
|
+
var b strings.Builder
|
|
294
|
+
fmt.Fprintf(&b, "# Session tools\n\n")
|
|
295
|
+
fmt.Fprintf(&b, "- session: %s\n", emptyDash(shortID(report.Session.ID)))
|
|
296
|
+
if report.Turn > 0 {
|
|
297
|
+
fmt.Fprintf(&b, "- turn: %d\n", report.Turn)
|
|
298
|
+
}
|
|
299
|
+
fmt.Fprintf(&b, "- calls: %d\n", report.TotalCalls)
|
|
300
|
+
if len(report.Tools) == 0 {
|
|
301
|
+
fmt.Fprintf(&b, "\nNo tool calls.\n")
|
|
302
|
+
return b.String()
|
|
303
|
+
}
|
|
304
|
+
fmt.Fprintf(&b, "\n| tool | calls | results | errors | turns |\n|---|---:|---:|---:|---|\n")
|
|
305
|
+
for _, u := range report.Tools {
|
|
306
|
+
turns := fmt.Sprintf("%d", u.FirstTurn)
|
|
307
|
+
if u.LastTurn != u.FirstTurn {
|
|
308
|
+
turns = fmt.Sprintf("%d-%d", u.FirstTurn, u.LastTurn)
|
|
309
|
+
}
|
|
310
|
+
fmt.Fprintf(&b, "| %s | %d | %d | %d | %s |\n", markdownCell(u.Name), u.Calls, u.Results, u.Errors, turns)
|
|
311
|
+
}
|
|
312
|
+
if report.FilesTotal > 0 {
|
|
313
|
+
fmt.Fprintf(&b, "\n## Files touched (%d, read/write/edit)\n\n| file | tools |\n|---|---|\n", report.FilesTotal)
|
|
314
|
+
for _, f := range report.Files {
|
|
315
|
+
fmt.Fprintf(&b, "| %s | %s |\n", markdownCell(f.Path), strings.Join(f.Tools, ", "))
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return b.String()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── mode=stats ───────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
type statsReport struct {
|
|
324
|
+
Mode string `json:"mode"`
|
|
325
|
+
Path string `json:"path"`
|
|
326
|
+
Session sessionMetadata `json:"session"`
|
|
327
|
+
Turns int `json:"turns"`
|
|
328
|
+
Messages int `json:"messages"`
|
|
329
|
+
Roles map[string]int `json:"roles"`
|
|
330
|
+
ToolCalls int `json:"toolCalls"`
|
|
331
|
+
Errors int `json:"errors"`
|
|
332
|
+
Usage usageSummary `json:"usage"`
|
|
333
|
+
Models []modelStat `json:"models,omitempty"`
|
|
334
|
+
FirstTimestamp string `json:"firstTimestamp,omitempty"`
|
|
335
|
+
LastTimestamp string `json:"lastTimestamp,omitempty"`
|
|
336
|
+
DurationSeconds int `json:"durationSeconds"`
|
|
337
|
+
LineCount int `json:"lineCount"`
|
|
338
|
+
MalformedJSON int `json:"malformedJson,omitempty"`
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
type modelStat struct {
|
|
342
|
+
Provider string `json:"provider,omitempty"`
|
|
343
|
+
Model string `json:"model"`
|
|
344
|
+
Messages int `json:"messages"`
|
|
345
|
+
Usage usageSummary `json:"usage"`
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
func handleStats(session sessionFile, format, path string) (any, error) {
|
|
349
|
+
report := buildStatsReport(session)
|
|
350
|
+
if format == "json" {
|
|
351
|
+
return report, nil
|
|
352
|
+
}
|
|
353
|
+
return sdk.ToolResult{
|
|
354
|
+
Content: truncateOutput(formatStatsMarkdown(report), path, "stats", 0, 0, 0),
|
|
355
|
+
Preview: fmt.Sprintf("%d turns, %d messages, %d tokens, $%.4f", report.Turns, report.Messages, report.Usage.Total, report.Usage.Cost),
|
|
356
|
+
}, nil
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
func buildStatsReport(session sessionFile) statsReport {
|
|
360
|
+
report := statsReport{
|
|
361
|
+
Mode: "stats", Path: session.Path, Session: session.Metadata, Roles: map[string]int{},
|
|
362
|
+
Messages: len(session.Messages), LineCount: session.LineCount, MalformedJSON: session.MalformedJSON,
|
|
363
|
+
}
|
|
364
|
+
type key struct{ provider, model string }
|
|
365
|
+
models := map[key]*modelStat{}
|
|
366
|
+
var first, last time.Time
|
|
367
|
+
for _, msg := range session.Messages {
|
|
368
|
+
report.Roles[msg.Role]++
|
|
369
|
+
if msg.Role == "user" {
|
|
370
|
+
report.Turns++
|
|
371
|
+
}
|
|
372
|
+
report.ToolCalls += len(msg.ToolCalls)
|
|
373
|
+
if msg.IsError || (msg.Role == "assistant" && strings.EqualFold(msg.StopReason, "error")) {
|
|
374
|
+
report.Errors++
|
|
375
|
+
}
|
|
376
|
+
report.Usage = addUsage(report.Usage, msg.Usage)
|
|
377
|
+
if msg.Role == "assistant" {
|
|
378
|
+
k := key{msg.Provider, msg.Model}
|
|
379
|
+
if msg.Model == "" {
|
|
380
|
+
k.model = "unknown"
|
|
381
|
+
}
|
|
382
|
+
m := models[k]
|
|
383
|
+
if m == nil {
|
|
384
|
+
m = &modelStat{Provider: k.provider, Model: k.model}
|
|
385
|
+
models[k] = m
|
|
386
|
+
}
|
|
387
|
+
m.Messages++
|
|
388
|
+
m.Usage = addUsage(m.Usage, msg.Usage)
|
|
389
|
+
}
|
|
390
|
+
if t, ok := parseTimestamp(msg.Timestamp); ok {
|
|
391
|
+
if first.IsZero() || t.Before(first) {
|
|
392
|
+
first, report.FirstTimestamp = t, msg.Timestamp
|
|
393
|
+
}
|
|
394
|
+
if last.IsZero() || t.After(last) {
|
|
395
|
+
last, report.LastTimestamp = t, msg.Timestamp
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if !first.IsZero() {
|
|
400
|
+
report.DurationSeconds = int(last.Sub(first).Seconds())
|
|
401
|
+
}
|
|
402
|
+
for _, m := range models {
|
|
403
|
+
report.Models = append(report.Models, *m)
|
|
404
|
+
}
|
|
405
|
+
slices.SortFunc(report.Models, func(a, b modelStat) int {
|
|
406
|
+
if c := cmp.Compare(b.Messages, a.Messages); c != 0 {
|
|
407
|
+
return c
|
|
408
|
+
}
|
|
409
|
+
return cmp.Compare(a.Model, b.Model)
|
|
410
|
+
})
|
|
411
|
+
return report
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
func parseTimestamp(ts string) (time.Time, bool) {
|
|
415
|
+
if ts == "" {
|
|
416
|
+
return time.Time{}, false
|
|
417
|
+
}
|
|
418
|
+
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
|
|
419
|
+
return t, true
|
|
420
|
+
}
|
|
421
|
+
if ms := floatValue(ts); ms > 0 {
|
|
422
|
+
return time.UnixMilli(int64(ms)), true
|
|
423
|
+
}
|
|
424
|
+
return time.Time{}, false
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
func formatStatsMarkdown(report statsReport) string {
|
|
428
|
+
var b strings.Builder
|
|
429
|
+
fmt.Fprintf(&b, "# Session stats\n\n")
|
|
430
|
+
fmt.Fprintf(&b, "- session: %s\n", emptyDash(shortID(report.Session.ID)))
|
|
431
|
+
fmt.Fprintf(&b, "- cwd: %s\n", emptyDash(report.Session.CWD))
|
|
432
|
+
fmt.Fprintf(&b, "- turns: %d\n", report.Turns)
|
|
433
|
+
fmt.Fprintf(&b, "- messages: %d\n", report.Messages)
|
|
434
|
+
roles := make([]string, 0, len(report.Roles))
|
|
435
|
+
for role := range report.Roles {
|
|
436
|
+
roles = append(roles, role)
|
|
437
|
+
}
|
|
438
|
+
slices.Sort(roles)
|
|
439
|
+
for _, role := range roles {
|
|
440
|
+
fmt.Fprintf(&b, " - %s: %d\n", role, report.Roles[role])
|
|
441
|
+
}
|
|
442
|
+
fmt.Fprintf(&b, "- tool_calls: %d\n", report.ToolCalls)
|
|
443
|
+
fmt.Fprintf(&b, "- errors: %d\n", report.Errors)
|
|
444
|
+
if report.DurationSeconds > 0 {
|
|
445
|
+
fmt.Fprintf(&b, "- duration: %s\n", (time.Duration(report.DurationSeconds) * time.Second).String())
|
|
446
|
+
}
|
|
447
|
+
u := report.Usage
|
|
448
|
+
fmt.Fprintf(&b, "- tokens: in %d, out %d, cache read %d, cache write %d, total %d\n", u.Input, u.Output, u.CacheRead, u.CacheWrite, u.Total)
|
|
449
|
+
fmt.Fprintf(&b, "- cost: $%.4f\n", u.Cost)
|
|
450
|
+
if report.MalformedJSON > 0 {
|
|
451
|
+
fmt.Fprintf(&b, "- malformed_json_lines: %d\n", report.MalformedJSON)
|
|
452
|
+
}
|
|
453
|
+
if len(report.Models) > 0 {
|
|
454
|
+
fmt.Fprintf(&b, "\n| model | assistant messages | tokens | cost |\n|---|---:|---:|---:|\n")
|
|
455
|
+
for _, m := range report.Models {
|
|
456
|
+
name := m.Model
|
|
457
|
+
if m.Provider != "" {
|
|
458
|
+
name = m.Provider + "/" + m.Model
|
|
459
|
+
}
|
|
460
|
+
fmt.Fprintf(&b, "| %s | %d | %d | $%.4f |\n", markdownCell(name), m.Messages, m.Usage.Total, m.Usage.Cost)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return b.String()
|
|
464
|
+
}
|