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