@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,1111 @@
1
+ // Package sessioningest provides the read_session tool: token-safe queries over
2
+ // Pi and PiG JSONL session transcripts, in a compact indexed format.
3
+ package sessioningest
4
+
5
+ import (
6
+ "bufio"
7
+ "bytes"
8
+ "cmp"
9
+ "encoding/json"
10
+ "fmt"
11
+ "os"
12
+ "path/filepath"
13
+ "regexp"
14
+ "slices"
15
+ "strings"
16
+ "time"
17
+ "unicode/utf8"
18
+
19
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
20
+ )
21
+
22
+ // Extension returns the session-ingest extension.
23
+ func Extension() *sdk.Extension {
24
+ ext := sdk.New("session-ingest")
25
+
26
+ ext.Tool(
27
+ "read_session",
28
+ "Read, query, and slice Pi/PiG JSONL session transcripts in a token-friendly format. Modes: toc (turn overview), query (text search), slice (message range), turn (one turn in full), tools (tool usage and files touched), stats (tokens, cost, roles, errors).",
29
+ readSessionSchema(),
30
+ handleReadSession,
31
+ )
32
+
33
+ return ext
34
+ }
35
+
36
+ func readSessionSchema() sdk.Schema {
37
+ return sdk.Schema{
38
+ "type": "object",
39
+ "required": []string{"path"},
40
+ "properties": map[string]any{
41
+ "path": map[string]any{
42
+ "type": "string",
43
+ "description": "Path to a Pi or PiG .jsonl session file.",
44
+ },
45
+ "mode": map[string]any{
46
+ "type": "string",
47
+ "enum": []string{"toc", "query", "slice", "turn", "tools", "stats"},
48
+ "default": "toc",
49
+ },
50
+ "query": map[string]any{"type": "string"},
51
+ "regex": map[string]any{"type": "boolean", "default": false},
52
+ "caseSensitive": map[string]any{"type": "boolean", "default": false},
53
+ "turn": map[string]any{"type": "integer", "minimum": 1, "description": "1-based turn number. Required for mode=turn; optional filter for mode=tools."},
54
+ "start": map[string]any{"type": "integer", "default": 0},
55
+ "limit": map[string]any{"type": "integer", "default": 20, "minimum": 1, "maximum": 200},
56
+ "roles": map[string]any{
57
+ "type": "array",
58
+ "items": map[string]any{"type": "string", "enum": []string{"user", "assistant", "toolResult", "custom", "system"}},
59
+ },
60
+ "include": map[string]any{
61
+ "type": "array",
62
+ "items": map[string]any{"type": "string", "enum": []string{"text", "thinking", "tool_calls", "tool_results", "errors", "usage"}},
63
+ "default": []string{"text", "tool_calls", "errors"},
64
+ },
65
+ "maxCharsPerItem": map[string]any{"type": "integer", "default": 0, "minimum": 0, "maximum": 50000, "description": "Max chars per message text block. 0 = no per-item truncation (default)."},
66
+ "format": map[string]any{"type": "string", "enum": []string{"compact_markdown", "json"}, "default": "compact_markdown"},
67
+ },
68
+ }
69
+ }
70
+
71
+ // Output truncation constants — match pig's built-in tool limits.
72
+ const (
73
+ defaultMaxOutputBytes = 50 * 1024 // 50 KB
74
+ defaultMaxOutputLines = 2000
75
+ )
76
+
77
+ func handleReadSession(ctx sdk.Context, params map[string]any) (any, error) {
78
+ mode := stringParam(params, "mode", "toc")
79
+ switch mode {
80
+ case "toc", "query", "slice", "turn", "tools", "stats":
81
+ default:
82
+ return nil, sdk.NewToolError(fmt.Sprintf("unknown mode %q", mode))
83
+ }
84
+
85
+ path := stringParam(params, "path", "")
86
+ if strings.TrimSpace(path) == "" {
87
+ return nil, sdk.NewToolError("path is required")
88
+ }
89
+ // maxCharsPerItem: default 0 means no per-item truncation (let pig's TUI
90
+ // handle collapse/expand). Callers can still pass a value to compress output.
91
+ maxChars := intParam(params, "maxCharsPerItem", 0)
92
+ start := intParam(params, "start", 0)
93
+ if start < 0 {
94
+ start = 0
95
+ }
96
+ limit := intParam(params, "limit", 20)
97
+ if limit <= 0 {
98
+ limit = 20
99
+ }
100
+ if limit > 200 {
101
+ limit = 200
102
+ }
103
+
104
+ width := safeWidth(ctx)
105
+ if width <= 0 {
106
+ width = 120
107
+ }
108
+
109
+ // Query searches the full text: maxCharsPerItem bounds each excerpt, not what
110
+ // is searched, so a match past the first maxCharsPerItem characters is found.
111
+ parseChars := maxChars
112
+ if mode == "query" {
113
+ parseChars = 0
114
+ }
115
+ session, err := parseSessionFile(path, parseChars)
116
+ if err != nil {
117
+ return nil, sdk.NewToolError(err.Error())
118
+ }
119
+ format := stringParam(params, "format", "compact_markdown")
120
+ if mode == "query" {
121
+ query := stringParam(params, "query", "")
122
+ excerptChars := maxChars
123
+ if excerptChars <= 0 {
124
+ excerptChars = max(200, width*2)
125
+ }
126
+ report, err := buildQueryReport(session, query, boolParam(params, "regex", false), boolParam(params, "caseSensitive", false), start, limit, excerptChars)
127
+ if err != nil {
128
+ return nil, sdk.NewToolError(err.Error())
129
+ }
130
+ if format == "json" {
131
+ return report, nil
132
+ }
133
+ content := truncateOutput(formatQueryMarkdown(report, width), path, mode, start, limit, report.TotalHits)
134
+ preview := queryPreview(report)
135
+ return sdk.ToolResult{Content: content, Preview: preview}, nil
136
+ }
137
+ switch mode {
138
+ case "turn":
139
+ return handleTurn(session, params, format, path, start, limit, maxChars)
140
+ case "tools":
141
+ return handleTools(session, params, format, path, start, limit)
142
+ case "stats":
143
+ return handleStats(session, format, path)
144
+ }
145
+ if mode == "slice" {
146
+ report := buildSliceReport(session, stringListParam(params, "roles"), start, limit)
147
+ if format == "json" {
148
+ return report, nil
149
+ }
150
+ content := truncateOutput(formatSliceMarkdown(report), path, mode, start, limit, report.FilteredTotal)
151
+ preview := slicePreview(report)
152
+ return sdk.ToolResult{Content: content, Preview: preview}, nil
153
+ }
154
+ report := buildTOCReport(session, start, limit)
155
+ if format == "json" {
156
+ return report, nil
157
+ }
158
+ content := truncateOutput(formatTOCMarkdown(report, width), path, mode, start, limit, report.TotalTurns)
159
+ preview := tocPreview(report)
160
+ return sdk.ToolResult{Content: content, Preview: preview}, nil
161
+ }
162
+
163
+ type sessionFile struct {
164
+ Path string
165
+ Metadata sessionMetadata
166
+ Events []sessionEvent
167
+ Messages []sessionMessage
168
+ LineCount int
169
+ MalformedJSON int
170
+ }
171
+
172
+ type sessionMetadata struct {
173
+ ID string `json:"id,omitempty"`
174
+ Version string `json:"version,omitempty"`
175
+ CWD string `json:"cwd,omitempty"`
176
+ Timestamp string `json:"timestamp,omitempty"`
177
+ }
178
+
179
+ type sessionEvent struct {
180
+ Type string `json:"type"`
181
+ Provider string `json:"provider,omitempty"`
182
+ ModelID string `json:"modelId,omitempty"`
183
+ ThinkingLevel string `json:"thinkingLevel,omitempty"`
184
+ Timestamp string `json:"timestamp,omitempty"`
185
+ }
186
+
187
+ type sessionMessage struct {
188
+ EntryIndex int
189
+ MessageIndex int
190
+ Timestamp string
191
+ Role string
192
+ Texts []string
193
+ Thinking []string
194
+ ToolCalls []toolCall
195
+ ToolName string
196
+ IsError bool
197
+ ErrorMessage string
198
+ Usage usageSummary
199
+ StopReason string
200
+ Model string
201
+ Provider string
202
+ }
203
+
204
+ type toolCall struct {
205
+ ID string `json:"id,omitempty"`
206
+ Name string `json:"name"`
207
+ Summary string `json:"summary,omitempty"`
208
+ // Path is the file argument of a read, write or edit call.
209
+ Path string `json:"path,omitempty"`
210
+ }
211
+
212
+ type usageSummary struct {
213
+ Input int `json:"input,omitempty"`
214
+ Output int `json:"output,omitempty"`
215
+ CacheRead int `json:"cacheRead,omitempty"`
216
+ CacheWrite int `json:"cacheWrite,omitempty"`
217
+ Total int `json:"total,omitempty"`
218
+ Cost float64 `json:"cost,omitempty"`
219
+ }
220
+
221
+ type tocReport struct {
222
+ Mode string `json:"mode"`
223
+ Path string `json:"path"`
224
+ Session sessionMetadata `json:"session"`
225
+ Events []sessionEvent `json:"events,omitempty"`
226
+ LineCount int `json:"lineCount"`
227
+ MalformedJSON int `json:"malformedJson,omitempty"`
228
+ TotalMessages int `json:"totalMessages"`
229
+ TotalTurns int `json:"totalTurns"`
230
+ Start int `json:"start"`
231
+ Limit int `json:"limit"`
232
+ Items []tocItem `json:"items"`
233
+ }
234
+
235
+ type tocItem struct {
236
+ TurnIndex int `json:"turnIndex"`
237
+ EntryIndex int `json:"entryIndex"`
238
+ MessageIndex int `json:"messageIndex"`
239
+ Timestamp string `json:"timestamp,omitempty"`
240
+ User string `json:"user"`
241
+ Tools []string `json:"tools,omitempty"`
242
+ AssistantMessages int `json:"assistantMessages"`
243
+ ToolResults int `json:"toolResults"`
244
+ Errors int `json:"errors,omitempty"`
245
+ Usage usageSummary `json:"usage,omitempty"`
246
+ }
247
+
248
+ type queryReport struct {
249
+ Mode string `json:"mode"`
250
+ Path string `json:"path"`
251
+ Session sessionMetadata `json:"session"`
252
+ Query string `json:"query"`
253
+ Regex bool `json:"regex"`
254
+ CaseSensitive bool `json:"caseSensitive"`
255
+ TotalHits int `json:"totalHits"`
256
+ Start int `json:"start"`
257
+ Limit int `json:"limit"`
258
+ Hits []queryHit `json:"hits"`
259
+ }
260
+
261
+ type queryHit struct {
262
+ TurnIndex int `json:"turnIndex"`
263
+ EntryIndex int `json:"entryIndex"`
264
+ MessageIndex int `json:"messageIndex"`
265
+ Timestamp string `json:"timestamp,omitempty"`
266
+ Role string `json:"role"`
267
+ ToolName string `json:"toolName,omitempty"`
268
+ Excerpt string `json:"excerpt"`
269
+ Score int `json:"score"`
270
+ RetrievalHint string `json:"retrievalHint"`
271
+ }
272
+
273
+ type sliceReport struct {
274
+ Mode string `json:"mode"`
275
+ Path string `json:"path"`
276
+ Session sessionMetadata `json:"session"`
277
+ TotalMessages int `json:"totalMessages"`
278
+ FilteredTotal int `json:"filteredTotal"`
279
+ Start int `json:"start"`
280
+ Limit int `json:"limit"`
281
+ Roles []string `json:"roles,omitempty"`
282
+ Messages []sliceMessage `json:"messages"`
283
+ }
284
+
285
+ type sliceMessage struct {
286
+ EntryIndex int `json:"entryIndex"`
287
+ MessageIndex int `json:"messageIndex"`
288
+ TurnIndex int `json:"turnIndex,omitempty"`
289
+ Timestamp string `json:"timestamp,omitempty"`
290
+ Role string `json:"role"`
291
+ Text []string `json:"text,omitempty"`
292
+ ToolCalls []toolCall `json:"toolCalls,omitempty"`
293
+ ToolName string `json:"toolName,omitempty"`
294
+ IsError bool `json:"isError,omitempty"`
295
+ Usage usageSummary `json:"usage,omitempty"`
296
+ StopReason string `json:"stopReason,omitempty"`
297
+ Model string `json:"model,omitempty"`
298
+ Provider string `json:"provider,omitempty"`
299
+ }
300
+
301
+ func parseSessionFile(path string, maxChars int) (sessionFile, error) {
302
+ clean := filepath.Clean(path)
303
+ file, err := os.Open(clean)
304
+ if err != nil {
305
+ return sessionFile{}, fmt.Errorf("read session %s: %w", clean, err)
306
+ }
307
+ defer file.Close()
308
+
309
+ session := sessionFile{Path: clean}
310
+ scanner := bufio.NewScanner(file)
311
+ // Session entries can contain large tool outputs. Keep parsing, but cap the
312
+ // scanner at 16 MiB so a single pathological line fails clearly.
313
+ scanner.Buffer(make([]byte, 64*1024), 16*1024*1024)
314
+ messageIndex := 0
315
+ for scanner.Scan() {
316
+ session.LineCount++
317
+ // Decode from the scanner's buffer: the line is not copied to a string and then back to bytes.
318
+ line := bytes.TrimSpace(scanner.Bytes())
319
+ if len(line) == 0 {
320
+ continue
321
+ }
322
+ var obj map[string]any
323
+ if err := json.Unmarshal(line, &obj); err != nil {
324
+ session.MalformedJSON++
325
+ continue
326
+ }
327
+ typeName := stringValue(obj["type"])
328
+ switch typeName {
329
+ case "session":
330
+ session.Metadata = sessionMetadata{
331
+ ID: stringValue(obj["id"]),
332
+ Version: stringValue(obj["version"]),
333
+ CWD: stringValue(obj["cwd"]),
334
+ Timestamp: stringValue(obj["timestamp"]),
335
+ }
336
+ case "model_change", "thinking_level_change":
337
+ session.Events = append(session.Events, sessionEvent{
338
+ Type: typeName,
339
+ Provider: stringValue(obj["provider"]),
340
+ ModelID: stringValue(obj["modelId"]),
341
+ ThinkingLevel: stringValue(obj["thinkingLevel"]),
342
+ Timestamp: stringValue(obj["timestamp"]),
343
+ })
344
+ case "message":
345
+ msg, ok := objectValue(obj["message"])
346
+ if !ok {
347
+ continue
348
+ }
349
+ messageIndex++
350
+ session.Messages = append(session.Messages, parseMessage(obj, msg, session.LineCount, messageIndex, maxChars))
351
+ }
352
+ }
353
+ if err := scanner.Err(); err != nil {
354
+ return sessionFile{}, fmt.Errorf("read session %s: %w", clean, err)
355
+ }
356
+ return session, nil
357
+ }
358
+
359
+ func parseMessage(entry, msg map[string]any, entryIndex, messageIndex, maxChars int) sessionMessage {
360
+ m := sessionMessage{
361
+ EntryIndex: entryIndex,
362
+ MessageIndex: messageIndex,
363
+ Timestamp: firstString(entry["timestamp"], msg["timestamp"]),
364
+ Role: stringValue(msg["role"]),
365
+ ToolName: stringValue(msg["toolName"]),
366
+ IsError: boolValue(msg["isError"]),
367
+ StopReason: stringValue(msg["stopReason"]),
368
+ ErrorMessage: stringValue(msg["errorMessage"]),
369
+ Model: stringValue(msg["model"]),
370
+ Provider: stringValue(msg["provider"]),
371
+ Usage: parseUsage(msg["usage"]),
372
+ }
373
+ m.Texts, m.Thinking, m.ToolCalls = parseContent(msg["content"], maxChars)
374
+ if m.Role == "toolResult" && m.ToolName == "" && len(m.ToolCalls) > 0 {
375
+ m.ToolName = m.ToolCalls[0].Name
376
+ }
377
+ return m
378
+ }
379
+
380
+ func parseContent(content any, maxChars int) (texts, thinking []string, tools []toolCall) {
381
+ switch v := content.(type) {
382
+ case string:
383
+ if strings.TrimSpace(v) == "" {
384
+ return nil, nil, nil
385
+ }
386
+ return []string{truncate(v, maxChars)}, nil, nil
387
+ case []any:
388
+ for _, item := range v {
389
+ obj, ok := objectValue(item)
390
+ if !ok {
391
+ continue
392
+ }
393
+ switch stringValue(obj["type"]) {
394
+ case "text":
395
+ text := strings.TrimSpace(stringValue(obj["text"]))
396
+ if text != "" {
397
+ texts = append(texts, truncate(text, maxChars))
398
+ }
399
+ case "thinking":
400
+ text := strings.TrimSpace(stringValue(obj["thinking"]))
401
+ if text != "" {
402
+ thinking = append(thinking, truncate(text, maxChars))
403
+ }
404
+ case "toolCall":
405
+ name := stringValue(obj["name"])
406
+ if name != "" {
407
+ call := toolCall{ID: stringValue(obj["id"]), Name: name}
408
+ call.Summary, call.Path = summarizeToolArgs(name, obj["arguments"])
409
+ tools = append(tools, call)
410
+ }
411
+ }
412
+ }
413
+ }
414
+ return texts, thinking, tools
415
+ }
416
+
417
+ // fileTools are the built-in tools whose "path" argument names a file.
418
+ var fileTools = map[string]bool{"read": true, "write": true, "edit": true}
419
+
420
+ // summarizeToolArgs returns a short label for a tool call and, for file tools,
421
+ // the file it names. Other arguments are never copied into a report.
422
+ func summarizeToolArgs(name string, args any) (summary, path string) {
423
+ obj, ok := objectValue(args)
424
+ if !ok {
425
+ return "", ""
426
+ }
427
+ if fileTools[name] {
428
+ path = firstString(obj["path"], obj["file_path"], obj["filePath"])
429
+ return path, path
430
+ }
431
+ if name == "bash" {
432
+ return clipCell(stringValue(obj["command"]), 120), ""
433
+ }
434
+ return "", ""
435
+ }
436
+
437
+ func parseUsage(value any) usageSummary {
438
+ obj, ok := objectValue(value)
439
+ if !ok {
440
+ return usageSummary{}
441
+ }
442
+ usage := usageSummary{
443
+ Input: intValue(obj["input"]),
444
+ Output: intValue(obj["output"]),
445
+ CacheRead: intValue(obj["cacheRead"]),
446
+ CacheWrite: intValue(obj["cacheWrite"]),
447
+ Total: intValue(obj["totalTokens"]),
448
+ }
449
+ if usage.Total == 0 {
450
+ usage.Total = intValue(obj["total"])
451
+ }
452
+ if cost, ok := objectValue(obj["cost"]); ok {
453
+ usage.Cost = floatValue(cost["total"])
454
+ } else {
455
+ usage.Cost = floatValue(obj["cost"])
456
+ }
457
+ return usage
458
+ }
459
+
460
+ func buildTOCReport(session sessionFile, start, limit int) tocReport {
461
+ items := buildTOCItems(session.Messages)
462
+ end := min(len(items), start+limit)
463
+ if start > len(items) {
464
+ start = len(items)
465
+ end = len(items)
466
+ }
467
+ return tocReport{
468
+ Mode: "toc",
469
+ Path: session.Path,
470
+ Session: session.Metadata,
471
+ Events: session.Events,
472
+ LineCount: session.LineCount,
473
+ MalformedJSON: session.MalformedJSON,
474
+ TotalMessages: len(session.Messages),
475
+ TotalTurns: len(items),
476
+ Start: start,
477
+ Limit: limit,
478
+ Items: items[start:end],
479
+ }
480
+ }
481
+
482
+ func buildTOCItems(messages []sessionMessage) []tocItem {
483
+ var items []tocItem
484
+ var current *tocItem
485
+ for _, msg := range messages {
486
+ if msg.Role == "user" {
487
+ items = append(items, tocItem{
488
+ TurnIndex: len(items) + 1,
489
+ EntryIndex: msg.EntryIndex,
490
+ MessageIndex: msg.MessageIndex,
491
+ Timestamp: msg.Timestamp,
492
+ User: strings.Join(msg.Texts, " "),
493
+ })
494
+ current = &items[len(items)-1]
495
+ continue
496
+ }
497
+ if current == nil {
498
+ continue
499
+ }
500
+ switch msg.Role {
501
+ case "assistant":
502
+ current.AssistantMessages++
503
+ for _, tool := range msg.ToolCalls {
504
+ if tool.Name != "" && !slices.Contains(current.Tools, tool.Name) {
505
+ current.Tools = append(current.Tools, tool.Name)
506
+ }
507
+ }
508
+ current.Usage = addUsage(current.Usage, msg.Usage)
509
+ if strings.EqualFold(msg.StopReason, "error") {
510
+ current.Errors++
511
+ }
512
+ case "toolResult":
513
+ current.ToolResults++
514
+ if msg.ToolName != "" && !slices.Contains(current.Tools, msg.ToolName) {
515
+ current.Tools = append(current.Tools, msg.ToolName)
516
+ }
517
+ if msg.IsError {
518
+ current.Errors++
519
+ }
520
+ }
521
+ }
522
+ for i := range items {
523
+ slices.Sort(items[i].Tools)
524
+ }
525
+ return items
526
+ }
527
+
528
+ func addUsage(a, b usageSummary) usageSummary {
529
+ a.Input += b.Input
530
+ a.Output += b.Output
531
+ a.CacheRead += b.CacheRead
532
+ a.CacheWrite += b.CacheWrite
533
+ a.Total += b.Total
534
+ a.Cost += b.Cost
535
+ return a
536
+ }
537
+
538
+ func buildQueryReport(session sessionFile, query string, regexMode, caseSensitive bool, start, limit, maxChars int) (queryReport, error) {
539
+ query = strings.TrimSpace(query)
540
+ if query == "" {
541
+ return queryReport{}, fmt.Errorf("query is required for mode=query")
542
+ }
543
+ matcher, err := newTextMatcher(query, regexMode, caseSensitive)
544
+ if err != nil {
545
+ return queryReport{}, err
546
+ }
547
+ var hits []queryHit
548
+ turnIndex := 0
549
+ for _, msg := range session.Messages {
550
+ if msg.Role == "user" {
551
+ turnIndex++
552
+ }
553
+ if turnIndex == 0 {
554
+ continue
555
+ }
556
+ for _, text := range msg.Texts {
557
+ if !matcher.matches(text) {
558
+ continue
559
+ }
560
+ hits = append(hits, queryHit{
561
+ TurnIndex: turnIndex,
562
+ EntryIndex: msg.EntryIndex,
563
+ MessageIndex: msg.MessageIndex,
564
+ Timestamp: msg.Timestamp,
565
+ Role: msg.Role,
566
+ ToolName: msg.ToolName,
567
+ Excerpt: matcher.excerpt(text, maxChars),
568
+ Score: matcher.score(text, msg.Role),
569
+ RetrievalHint: fmt.Sprintf("mode=turn turn=%d or mode=slice start=%d limit=1", turnIndex, msg.MessageIndex-1),
570
+ })
571
+ }
572
+ }
573
+ slices.SortFunc(hits, func(a, b queryHit) int {
574
+ if byScore := cmp.Compare(b.Score, a.Score); byScore != 0 {
575
+ return byScore
576
+ }
577
+ if byTurn := cmp.Compare(a.TurnIndex, b.TurnIndex); byTurn != 0 {
578
+ return byTurn
579
+ }
580
+ return cmp.Compare(a.MessageIndex, b.MessageIndex)
581
+ })
582
+ end := min(len(hits), start+limit)
583
+ if start > len(hits) {
584
+ start = len(hits)
585
+ end = len(hits)
586
+ }
587
+ return queryReport{
588
+ Mode: "query",
589
+ Path: session.Path,
590
+ Session: session.Metadata,
591
+ Query: query,
592
+ Regex: regexMode,
593
+ CaseSensitive: caseSensitive,
594
+ TotalHits: len(hits),
595
+ Start: start,
596
+ Limit: limit,
597
+ Hits: hits[start:end],
598
+ }, nil
599
+ }
600
+
601
+ type textMatcher struct {
602
+ query string
603
+ needle string
604
+ regex *regexp.Regexp
605
+ caseSensitive bool
606
+ }
607
+
608
+ func newTextMatcher(query string, regexMode, caseSensitive bool) (textMatcher, error) {
609
+ m := textMatcher{query: query, caseSensitive: caseSensitive}
610
+ if regexMode {
611
+ pattern := query
612
+ if !caseSensitive {
613
+ pattern = "(?i)" + pattern
614
+ }
615
+ re, err := regexp.Compile(pattern)
616
+ if err != nil {
617
+ return textMatcher{}, fmt.Errorf("invalid regex query: %w", err)
618
+ }
619
+ m.regex = re
620
+ return m, nil
621
+ }
622
+ if !caseSensitive {
623
+ // Folding through the regexp engine keeps match offsets valid in the
624
+ // original text (strings.ToLower can change byte lengths).
625
+ m.regex = regexp.MustCompile("(?i)" + regexp.QuoteMeta(query))
626
+ return m, nil
627
+ }
628
+ m.needle = query
629
+ return m, nil
630
+ }
631
+
632
+ func (m textMatcher) matches(text string) bool {
633
+ if m.regex != nil {
634
+ return m.regex.MatchString(text)
635
+ }
636
+ haystack := text
637
+ if !m.caseSensitive {
638
+ haystack = strings.ToLower(text)
639
+ }
640
+ return strings.Contains(haystack, m.needle)
641
+ }
642
+
643
+ func (m textMatcher) score(text, role string) int {
644
+ base := 1
645
+ switch role {
646
+ case "user":
647
+ base = 30
648
+ case "assistant":
649
+ base = 20
650
+ case "toolResult":
651
+ base = 10
652
+ }
653
+ return base + m.count(text)
654
+ }
655
+
656
+ func (m textMatcher) count(text string) int {
657
+ if m.regex != nil {
658
+ return len(m.regex.FindAllStringIndex(text, -1))
659
+ }
660
+ haystack := text
661
+ if !m.caseSensitive {
662
+ haystack = strings.ToLower(text)
663
+ }
664
+ return strings.Count(haystack, m.needle)
665
+ }
666
+
667
+ func (m textMatcher) excerpt(text string, maxChars int) string {
668
+ if maxChars <= 0 {
669
+ maxChars = 1200
670
+ }
671
+ idx := m.firstIndex(text)
672
+ if idx < 0 {
673
+ return truncate(text, maxChars)
674
+ }
675
+ radius := max(80, maxChars/2)
676
+ start := runeBoundary(text, max(0, idx-radius))
677
+ end := runeBoundary(text, min(len(text), idx+len(m.query)+radius))
678
+ prefix := ""
679
+ if start > 0 {
680
+ prefix = "... "
681
+ }
682
+ suffix := ""
683
+ if end < len(text) {
684
+ suffix = " ..."
685
+ }
686
+ return truncate(prefix+text[start:end]+suffix, maxChars)
687
+ }
688
+
689
+ func (m textMatcher) firstIndex(text string) int {
690
+ if m.regex != nil {
691
+ loc := m.regex.FindStringIndex(text)
692
+ if loc == nil {
693
+ return -1
694
+ }
695
+ return loc[0]
696
+ }
697
+ haystack := text
698
+ needle := m.needle
699
+ if !m.caseSensitive {
700
+ haystack = strings.ToLower(text)
701
+ }
702
+ return strings.Index(haystack, needle)
703
+ }
704
+
705
+ func buildSliceReport(session sessionFile, roles []string, start, limit int) sliceReport {
706
+ roleSet := map[string]bool{}
707
+ for _, role := range roles {
708
+ roleSet[role] = true
709
+ }
710
+ var messages []sliceMessage
711
+ turnIndex := 0
712
+ for _, msg := range session.Messages {
713
+ if msg.Role == "user" {
714
+ turnIndex++
715
+ }
716
+ if len(roleSet) > 0 && !roleSet[msg.Role] {
717
+ continue
718
+ }
719
+ messages = append(messages, sliceMessage{
720
+ EntryIndex: msg.EntryIndex,
721
+ MessageIndex: msg.MessageIndex,
722
+ TurnIndex: turnIndex,
723
+ Timestamp: msg.Timestamp,
724
+ Role: msg.Role,
725
+ Text: msg.Texts,
726
+ ToolCalls: msg.ToolCalls,
727
+ ToolName: msg.ToolName,
728
+ IsError: msg.IsError,
729
+ Usage: msg.Usage,
730
+ StopReason: msg.StopReason,
731
+ Model: msg.Model,
732
+ Provider: msg.Provider,
733
+ })
734
+ }
735
+ end := min(len(messages), start+limit)
736
+ if start > len(messages) {
737
+ start = len(messages)
738
+ end = len(messages)
739
+ }
740
+ return sliceReport{
741
+ Mode: "slice",
742
+ Path: session.Path,
743
+ Session: session.Metadata,
744
+ TotalMessages: len(session.Messages),
745
+ FilteredTotal: len(messages),
746
+ Start: start,
747
+ Limit: limit,
748
+ Roles: roles,
749
+ Messages: messages[start:end],
750
+ }
751
+ }
752
+
753
+ // tocPreview builds a short collapsed preview for TOC results.
754
+ func tocPreview(report tocReport) string {
755
+ return fmt.Sprintf("Session %s — %d turns, %d messages (showing %d)",
756
+ shortID(report.Session.ID), report.TotalTurns, report.TotalMessages, len(report.Items))
757
+ }
758
+
759
+ // queryPreview builds a short collapsed preview for query results.
760
+ func queryPreview(report queryReport) string {
761
+ return fmt.Sprintf("Query %q — %d hits (showing %d)",
762
+ report.Query, report.TotalHits, len(report.Hits))
763
+ }
764
+
765
+ // slicePreview builds a short collapsed preview for slice results.
766
+ func slicePreview(report sliceReport) string {
767
+ roles := ""
768
+ if len(report.Roles) > 0 {
769
+ roles = fmt.Sprintf(" (roles: %s)", strings.Join(report.Roles, ", "))
770
+ }
771
+ return fmt.Sprintf("Slice %d–%d of %d messages%s",
772
+ report.Start, report.Start+len(report.Messages)-1, report.FilteredTotal, roles)
773
+ }
774
+
775
+ func formatTOCMarkdown(report tocReport, width int) string {
776
+ var b strings.Builder
777
+ fmt.Fprintf(&b, "# Session TOC\n\n")
778
+ fmt.Fprintf(&b, "- session: %s\n", emptyDash(shortID(report.Session.ID)))
779
+ fmt.Fprintf(&b, "- cwd: %s\n", emptyDash(report.Session.CWD))
780
+ fmt.Fprintf(&b, "- started: %s\n", emptyDash(report.Session.Timestamp))
781
+ fmt.Fprintf(&b, "- turns: %d\n", report.TotalTurns)
782
+ fmt.Fprintf(&b, "- messages: %d\n", report.TotalMessages)
783
+ if report.MalformedJSON > 0 {
784
+ fmt.Fprintf(&b, "- malformed_json_lines: %d\n", report.MalformedJSON)
785
+ }
786
+ // Allocate column widths based on terminal width.
787
+ // Fixed cols: turn(6) + time(10) + tools(~25) + notes(~30) + separators(16) ≈ 87
788
+ userWidth := max(20, width-87)
789
+
790
+ fmt.Fprintf(&b, "\n| turn | time | user | tools | notes |\n")
791
+ fmt.Fprintf(&b, "|---:|---|---|---|---|\n")
792
+ for _, item := range report.Items {
793
+ tools := "-"
794
+ if len(item.Tools) > 0 {
795
+ tools = strings.Join(item.Tools, ", ")
796
+ }
797
+ notes := []string{fmt.Sprintf("assistant:%d", item.AssistantMessages)}
798
+ if item.ToolResults > 0 {
799
+ notes = append(notes, fmt.Sprintf("results:%d", item.ToolResults))
800
+ }
801
+ if item.Errors > 0 {
802
+ notes = append(notes, fmt.Sprintf("errors:%d", item.Errors))
803
+ }
804
+ if item.Usage.Cost > 0 {
805
+ notes = append(notes, fmt.Sprintf("$%.4f", item.Usage.Cost))
806
+ }
807
+ userText := clipCell(item.User, userWidth)
808
+ fmt.Fprintf(&b, "| %d | %s | %s | %s | %s |\n", item.TurnIndex, emptyDash(formatTimestamp(item.Timestamp)), markdownCell(userText), markdownCell(tools), strings.Join(notes, ", "))
809
+ }
810
+ return b.String()
811
+ }
812
+
813
+ func formatSliceMarkdown(report sliceReport) string {
814
+ var b strings.Builder
815
+ fmt.Fprintf(&b, "# Session slice\n\n")
816
+ fmt.Fprintf(&b, "- session: %s\n", emptyDash(shortID(report.Session.ID)))
817
+ fmt.Fprintf(&b, "- messages: %d of %d\n", len(report.Messages), report.FilteredTotal)
818
+ fmt.Fprintf(&b, "- start: %d\n", report.Start)
819
+ if len(report.Roles) > 0 {
820
+ fmt.Fprintf(&b, "- roles: %s\n", strings.Join(report.Roles, ", "))
821
+ }
822
+ for _, msg := range report.Messages {
823
+ fmt.Fprintf(&b, "\n## message %d turn %d %s\n\n", msg.MessageIndex, msg.TurnIndex, msg.Role)
824
+ if msg.ToolName != "" {
825
+ fmt.Fprintf(&b, "- tool: %s\n", msg.ToolName)
826
+ }
827
+ if msg.IsError {
828
+ fmt.Fprintf(&b, "- error: true\n")
829
+ }
830
+ if len(msg.ToolCalls) > 0 {
831
+ tools := make([]string, 0, len(msg.ToolCalls))
832
+ for _, call := range msg.ToolCalls {
833
+ tools = append(tools, call.Name)
834
+ }
835
+ fmt.Fprintf(&b, "- tool_calls: %s\n", strings.Join(tools, ", "))
836
+ }
837
+ for _, text := range msg.Text {
838
+ fmt.Fprintf(&b, "\n%s\n", text)
839
+ }
840
+ }
841
+ return b.String()
842
+ }
843
+
844
+ func formatQueryMarkdown(report queryReport, width int) string {
845
+ var b strings.Builder
846
+ fmt.Fprintf(&b, "# Session query\n\n")
847
+ fmt.Fprintf(&b, "- session: %s\n", emptyDash(shortID(report.Session.ID)))
848
+ fmt.Fprintf(&b, "- query: %s\n", report.Query)
849
+ fmt.Fprintf(&b, "- hits: %d\n", report.TotalHits)
850
+ // Fixed cols: rank(6)+turn(6)+role(12)+tool(12)+score(7)+hint(~60)+seps(21) ≈ 124
851
+ excerptWidth := max(30, width-124)
852
+
853
+ fmt.Fprintf(&b, "\n| rank | turn | role | tool | score | excerpt | hint |\n")
854
+ fmt.Fprintf(&b, "|---:|---:|---|---|---:|---|---|\n")
855
+ for i, hit := range report.Hits {
856
+ tool := hit.ToolName
857
+ if tool == "" {
858
+ tool = "-"
859
+ }
860
+ excerpt := clipCell(hit.Excerpt, excerptWidth)
861
+ fmt.Fprintf(&b, "| %d | %d | %s | %s | %d | %s | `%s` |\n", report.Start+i+1, hit.TurnIndex, markdownCell(hit.Role), markdownCell(tool), hit.Score, markdownCell(excerpt), hit.RetrievalHint)
862
+ }
863
+ if len(report.Hits) == 0 {
864
+ fmt.Fprintf(&b, "\nNo hits.\n")
865
+ }
866
+ return b.String()
867
+ }
868
+
869
+ func truncate(text string, maxLen int) string {
870
+ text = strings.TrimSpace(text)
871
+ if maxLen <= 0 {
872
+ return text
873
+ }
874
+ total := utf8.RuneCountInString(text)
875
+ if total <= maxLen {
876
+ return text
877
+ }
878
+ return cutRunes(text, maxLen) + fmt.Sprintf(" ... [truncated, %d chars total]", total)
879
+ }
880
+
881
+ // cutRunes returns the first n runes of text, never splitting a character.
882
+ func cutRunes(text string, n int) string {
883
+ for i := range text {
884
+ if n == 0 {
885
+ return text[:i]
886
+ }
887
+ n--
888
+ }
889
+ return text
890
+ }
891
+
892
+ // runeBoundary moves i back to the start of the rune that contains it.
893
+ func runeBoundary(text string, i int) int {
894
+ for i > 0 && i < len(text) && !utf8.RuneStart(text[i]) {
895
+ i--
896
+ }
897
+ return i
898
+ }
899
+
900
+ // truncateOutput applies pig's built-in 2000-line / 50KB safety net to the
901
+ // final markdown output string, matching bash/read/grep tool behavior. total is
902
+ // the number of pageable items (0 for modes that are not paged); a continuation
903
+ // hint is added only when the page stops before total or the output was cut.
904
+ func truncateOutput(output, path, mode string, start, limit, total int) string {
905
+ lines := strings.Split(output, "\n")
906
+ totalLines := len(lines)
907
+ totalBytes := len(output)
908
+
909
+ truncated := false
910
+ if totalLines > defaultMaxOutputLines {
911
+ lines = lines[:defaultMaxOutputLines]
912
+ truncated = true
913
+ }
914
+ if joined := strings.Join(lines, "\n"); len(joined) > defaultMaxOutputBytes {
915
+ joined = joined[:runeBoundary(joined, defaultMaxOutputBytes)]
916
+ if last := strings.LastIndex(joined, "\n"); last > 0 {
917
+ joined = joined[:last]
918
+ }
919
+ lines = strings.Split(joined, "\n")
920
+ truncated = true
921
+ }
922
+
923
+ result := strings.Join(lines, "\n")
924
+ hint := ""
925
+ if total > 0 && start+limit < total {
926
+ hint = fmt.Sprintf(`read_session {"path":%q, "mode":%q, "start":%d, "limit":%d}`, path, mode, start+limit, limit)
927
+ }
928
+ switch {
929
+ case truncated && hint != "":
930
+ result += fmt.Sprintf("\n\n[Showing %d of %d lines (%d bytes). Next: %s]", len(lines), totalLines, totalBytes, hint)
931
+ case truncated && total > 0:
932
+ result += fmt.Sprintf("\n\n[Showing %d of %d lines (%d bytes). Lower limit or set maxCharsPerItem to fit.]", len(lines), totalLines, totalBytes)
933
+ case truncated:
934
+ result += fmt.Sprintf("\n\n[Showing %d of %d lines (%d bytes). Narrow it with include or set maxCharsPerItem.]", len(lines), totalLines, totalBytes)
935
+ case hint != "":
936
+ result += fmt.Sprintf("\n\n[Next: %s]", hint)
937
+ }
938
+ return result
939
+ }
940
+
941
+ func markdownCell(text string) string {
942
+ text = strings.ReplaceAll(text, "\n", " ")
943
+ text = strings.ReplaceAll(text, "|", "\\|")
944
+ return text
945
+ }
946
+
947
+ // clipCell truncates a markdown table cell to maxWidth characters.
948
+ func clipCell(text string, maxWidth int) string {
949
+ text = strings.ReplaceAll(text, "\n", " ")
950
+ text = strings.Join(strings.Fields(text), " ") // collapse whitespace
951
+ if maxWidth <= 0 || utf8.RuneCountInString(text) <= maxWidth {
952
+ return text
953
+ }
954
+ if maxWidth <= 4 {
955
+ return cutRunes(text, maxWidth)
956
+ }
957
+ return cutRunes(text, maxWidth-3) + "..."
958
+ }
959
+
960
+ func shortID(id string) string {
961
+ if len(id) <= 12 {
962
+ return id
963
+ }
964
+ return id[:12] + "..."
965
+ }
966
+
967
+ func emptyDash(value string) string {
968
+ if strings.TrimSpace(value) == "" {
969
+ return "-"
970
+ }
971
+ return value
972
+ }
973
+
974
+ func formatTimestamp(ts string) string {
975
+ if ts == "" {
976
+ return "-"
977
+ }
978
+ if parsed, err := time.Parse(time.RFC3339Nano, strings.ReplaceAll(ts, "Z", "+00:00")); err == nil {
979
+ return parsed.Format("15:04:05")
980
+ }
981
+ if f := floatValue(ts); f > 0 {
982
+ return time.UnixMilli(int64(f)).Format("15:04:05")
983
+ }
984
+ if len(ts) > 8 {
985
+ return ts[:8]
986
+ }
987
+ return ts
988
+ }
989
+
990
+ func stringParam(params map[string]any, key, fallback string) string {
991
+ if value, ok := params[key].(string); ok && value != "" {
992
+ return value
993
+ }
994
+ return fallback
995
+ }
996
+
997
+ func intParam(params map[string]any, key string, fallback int) int {
998
+ if value, ok := params[key]; ok {
999
+ if n := intValue(value); n != 0 {
1000
+ return n
1001
+ }
1002
+ }
1003
+ return fallback
1004
+ }
1005
+
1006
+ func boolParam(params map[string]any, key string, fallback bool) bool {
1007
+ if value, ok := params[key]; ok {
1008
+ if typed, ok := value.(bool); ok {
1009
+ return typed
1010
+ }
1011
+ }
1012
+ return fallback
1013
+ }
1014
+
1015
+ func stringListParam(params map[string]any, key string) []string {
1016
+ value, ok := params[key]
1017
+ if !ok {
1018
+ return nil
1019
+ }
1020
+ items, ok := value.([]any)
1021
+ if !ok {
1022
+ return nil
1023
+ }
1024
+ out := make([]string, 0, len(items))
1025
+ for _, item := range items {
1026
+ if s, ok := item.(string); ok && s != "" {
1027
+ out = append(out, s)
1028
+ }
1029
+ }
1030
+ return out
1031
+ }
1032
+
1033
+ func firstString(values ...any) string {
1034
+ for _, value := range values {
1035
+ if s := stringValue(value); s != "" {
1036
+ return s
1037
+ }
1038
+ }
1039
+ return ""
1040
+ }
1041
+
1042
+ func stringValue(value any) string {
1043
+ switch v := value.(type) {
1044
+ case string:
1045
+ return v
1046
+ case json.Number:
1047
+ return v.String()
1048
+ case float64:
1049
+ return fmt.Sprintf("%.0f", v)
1050
+ default:
1051
+ return ""
1052
+ }
1053
+ }
1054
+
1055
+ func boolValue(value any) bool {
1056
+ v, _ := value.(bool)
1057
+ return v
1058
+ }
1059
+
1060
+ func intValue(value any) int {
1061
+ switch v := value.(type) {
1062
+ case int:
1063
+ return v
1064
+ case int64:
1065
+ return int(v)
1066
+ case float64:
1067
+ return int(v)
1068
+ case json.Number:
1069
+ i, _ := v.Int64()
1070
+ return int(i)
1071
+ case string:
1072
+ var i int
1073
+ _, _ = fmt.Sscanf(v, "%d", &i)
1074
+ return i
1075
+ default:
1076
+ return 0
1077
+ }
1078
+ }
1079
+
1080
+ func floatValue(value any) float64 {
1081
+ switch v := value.(type) {
1082
+ case float64:
1083
+ return v
1084
+ case int:
1085
+ return float64(v)
1086
+ case json.Number:
1087
+ f, _ := v.Float64()
1088
+ return f
1089
+ case string:
1090
+ var f float64
1091
+ _, _ = fmt.Sscanf(v, "%f", &f)
1092
+ return f
1093
+ default:
1094
+ return 0
1095
+ }
1096
+ }
1097
+
1098
+ func objectValue(value any) (map[string]any, bool) {
1099
+ obj, ok := value.(map[string]any)
1100
+ return obj, ok
1101
+ }
1102
+
1103
+ // safeWidth returns ctx.Width(), recovering from nil-context panics in tests.
1104
+ func safeWidth(ctx sdk.Context) (w int) {
1105
+ defer func() {
1106
+ if r := recover(); r != nil {
1107
+ w = 0
1108
+ }
1109
+ }()
1110
+ return ctx.Width()
1111
+ }