@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.
@@ -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
+ }