@hybridlabor-api/bdb-synapse 1.0.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/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/synapse +0 -0
- package/cmd/rubriceval/main.go +308 -0
- package/cmd/synapse/main.go +280 -0
- package/cmd/synapse/main_test.go +16 -0
- package/go.mod +7 -0
- package/go.sum +6 -0
- package/internal/adapter/adapter.go +1117 -0
- package/internal/adapter/adapter_test.go +518 -0
- package/internal/adapter/agy/adapter.go +193 -0
- package/internal/adapter/claudecode/adapter.go +415 -0
- package/internal/adapter/claudecode/adapter_test.go +260 -0
- package/internal/adapter/claudecode/agents.go +387 -0
- package/internal/adapter/claudecode/agents_test.go +480 -0
- package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
- package/internal/adapter/codex/adapter.go +919 -0
- package/internal/adapter/codex/adapter_test.go +920 -0
- package/internal/adapter/codex/agents.go +401 -0
- package/internal/adapter/codex/agents_test.go +610 -0
- package/internal/adapter/codex/summary_inputs_test.go +20 -0
- package/internal/adapter/pi/adapter.go +483 -0
- package/internal/adapter/pi/adapter_test.go +517 -0
- package/internal/citymap/builder.go +1124 -0
- package/internal/citymap/builder_test.go +818 -0
- package/internal/judge/cache.go +180 -0
- package/internal/judge/cli.go +240 -0
- package/internal/judge/cli_test.go +68 -0
- package/internal/judge/fresh_summary_test.go +37 -0
- package/internal/judge/input.go +233 -0
- package/internal/judge/judge.go +416 -0
- package/internal/judge/judge_test.go +288 -0
- package/internal/judge/prompt.go +129 -0
- package/internal/judge/rubric.go +275 -0
- package/internal/judge/rubric_test.go +642 -0
- package/internal/model/agent.go +63 -0
- package/internal/model/agent_schema_test.go +86 -0
- package/internal/model/agent_test.go +64 -0
- package/internal/model/model.go +175 -0
- package/internal/model/report.go +166 -0
- package/internal/model/stats.go +151 -0
- package/internal/model/stats_test.go +89 -0
- package/internal/model/trace_schema_test.go +67 -0
- package/internal/server/analyze.go +286 -0
- package/internal/server/analyze_test.go +297 -0
- package/internal/server/codex_index_test.go +40 -0
- package/internal/server/hardening_test.go +147 -0
- package/internal/server/reportindex.go +91 -0
- package/internal/server/reportindex_test.go +116 -0
- package/internal/server/server.go +1099 -0
- package/internal/server/server_test.go +1389 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
- package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
- package/internal/server/static/assets/index-C_adLrJr.js +3 -0
- package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
- package/internal/server/static/index.html +26 -0
- package/internal/server/tracestore.go +173 -0
- package/internal/server/tracestore_test.go +51 -0
- package/internal/textutil/truncate.go +30 -0
- package/internal/textutil/truncate_test.go +39 -0
- package/package.json +35 -0
- package/web/e2e/agent-lens.spec.ts +688 -0
- package/web/index.html +23 -0
- package/web/package-lock.json +1933 -0
- package/web/package.json +33 -0
- package/web/playwright.config.ts +24 -0
- package/web/src/App.tsx +876 -0
- package/web/src/api/client.ts +74 -0
- package/web/src/main.tsx +12 -0
- package/web/src/playback/recorder.ts +160 -0
- package/web/src/playback/reducer.ts +91 -0
- package/web/src/scene/CityScene.tsx +638 -0
- package/web/src/scene/TreeScene.tsx +656 -0
- package/web/src/scene/dirLabels.ts +145 -0
- package/web/src/scene/sceneUtils.ts +144 -0
- package/web/src/scene/textures.ts +60 -0
- package/web/src/scene/trail.ts +79 -0
- package/web/src/scene/treeLayout.ts +169 -0
- package/web/src/state/filters.ts +40 -0
- package/web/src/state/store.ts +83 -0
- package/web/src/styles.css +2565 -0
- package/web/src/types.ts +315 -0
- package/web/src/ui/AgentsPanel.tsx +376 -0
- package/web/src/ui/Dock.tsx +104 -0
- package/web/src/ui/Hud.tsx +335 -0
- package/web/src/ui/Inspector.tsx +107 -0
- package/web/src/ui/LogoMark.tsx +38 -0
- package/web/src/ui/ReportPanel.tsx +491 -0
- package/web/src/ui/SessionRail.tsx +316 -0
- package/web/src/ui/Timeline.tsx +458 -0
- package/web/src/ui/ViewPanel.tsx +45 -0
- package/web/src/ui/shortcuts.ts +4 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +28 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
|
|
11
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/adapter"
|
|
12
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/model"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
type Adapter struct {
|
|
16
|
+
Dir string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func DefaultDir() string {
|
|
20
|
+
home, err := os.UserHomeDir()
|
|
21
|
+
if err != nil {
|
|
22
|
+
return ""
|
|
23
|
+
}
|
|
24
|
+
return filepath.Join(home, ".pi", "agent", "sessions")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (a Adapter) Harness() string {
|
|
28
|
+
return "pi"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func (a Adapter) SessionDir() string {
|
|
32
|
+
if a.Dir != "" {
|
|
33
|
+
return a.Dir
|
|
34
|
+
}
|
|
35
|
+
return DefaultDir()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func (a Adapter) ListSessions() ([]model.SessionMeta, error) {
|
|
39
|
+
dir := a.SessionDir()
|
|
40
|
+
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
|
41
|
+
return nil, nil
|
|
42
|
+
}
|
|
43
|
+
var metas []model.SessionMeta
|
|
44
|
+
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error {
|
|
45
|
+
if walkErr != nil {
|
|
46
|
+
return nil
|
|
47
|
+
}
|
|
48
|
+
if entry.IsDir() || filepath.Ext(path) != ".jsonl" {
|
|
49
|
+
return nil
|
|
50
|
+
}
|
|
51
|
+
meta, err := a.Summarize(path)
|
|
52
|
+
if err == nil {
|
|
53
|
+
metas = append(metas, meta)
|
|
54
|
+
}
|
|
55
|
+
return nil
|
|
56
|
+
})
|
|
57
|
+
if err != nil {
|
|
58
|
+
return nil, err
|
|
59
|
+
}
|
|
60
|
+
sort.Slice(metas, func(i, j int) bool {
|
|
61
|
+
return metas[i].EndedAt > metas[j].EndedAt
|
|
62
|
+
})
|
|
63
|
+
return metas, nil
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func (a Adapter) Summarize(path string) (model.SessionMeta, error) {
|
|
67
|
+
header, entries, recognized, err := readSession(path)
|
|
68
|
+
if err != nil && !recognized {
|
|
69
|
+
return model.SessionMeta{}, err
|
|
70
|
+
}
|
|
71
|
+
if !recognized {
|
|
72
|
+
return model.SessionMeta{}, fmt.Errorf("not a pi session: %s", path)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
76
|
+
if header.ID != "" {
|
|
77
|
+
id = header.ID
|
|
78
|
+
}
|
|
79
|
+
meta := model.SessionMeta{
|
|
80
|
+
Key: adapter.SessionKey(a.Harness(), path),
|
|
81
|
+
ID: id,
|
|
82
|
+
Harness: a.Harness(),
|
|
83
|
+
Path: path,
|
|
84
|
+
Cwd: header.Cwd,
|
|
85
|
+
StartedAt: header.Timestamp,
|
|
86
|
+
EndedAt: header.Timestamp,
|
|
87
|
+
}
|
|
88
|
+
// Counting on the same trunk projection Parse plays back keeps
|
|
89
|
+
// EventCount and UserTurns equal to the trace's — the report badge
|
|
90
|
+
// compares the two, and a whole-file count would mark every branched
|
|
91
|
+
// session's fresh report stale.
|
|
92
|
+
firstUserText := ""
|
|
93
|
+
for _, entry := range linearize(entries) {
|
|
94
|
+
if entry.Timestamp != "" {
|
|
95
|
+
if meta.StartedAt == "" {
|
|
96
|
+
meta.StartedAt = entry.Timestamp
|
|
97
|
+
}
|
|
98
|
+
meta.EndedAt = entry.Timestamp
|
|
99
|
+
}
|
|
100
|
+
switch entry.Type {
|
|
101
|
+
case "model_change":
|
|
102
|
+
if entry.ModelID != "" {
|
|
103
|
+
meta.Model = entry.ModelID
|
|
104
|
+
}
|
|
105
|
+
case "message":
|
|
106
|
+
msg, err := decodePiMessage(entry.Message)
|
|
107
|
+
if err != nil {
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
switch msg.Role {
|
|
111
|
+
case "assistant":
|
|
112
|
+
if msg.Model != "" && meta.Model == "" {
|
|
113
|
+
meta.Model = msg.Model
|
|
114
|
+
}
|
|
115
|
+
meta.EventCount += countToolCalls(msg.Content)
|
|
116
|
+
case "user":
|
|
117
|
+
// mirrors Parse's user-message mark filter so the badge's
|
|
118
|
+
// staleness check counts the same turns the report will
|
|
119
|
+
text := contentText(msg.Content)
|
|
120
|
+
if !adapter.InjectedUserMessage(text) {
|
|
121
|
+
meta.UserTurns++
|
|
122
|
+
if firstUserText == "" {
|
|
123
|
+
firstUserText = text
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
case "bashExecution":
|
|
127
|
+
meta.EventCount++
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
meta.Title = sessionTitle(entries, firstUserText, path)
|
|
132
|
+
return meta, err
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func (a Adapter) Parse(path string) (*model.Trace, error) {
|
|
136
|
+
header, entries, recognized, err := readSession(path)
|
|
137
|
+
if err != nil && !recognized {
|
|
138
|
+
return nil, err
|
|
139
|
+
}
|
|
140
|
+
if !recognized {
|
|
141
|
+
return nil, fmt.Errorf("not a pi session: %s", path)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
145
|
+
if header.ID != "" {
|
|
146
|
+
id = header.ID
|
|
147
|
+
}
|
|
148
|
+
trace := &model.Trace{
|
|
149
|
+
Version: 1,
|
|
150
|
+
Session: model.TraceSession{
|
|
151
|
+
ID: id,
|
|
152
|
+
Harness: a.Harness(),
|
|
153
|
+
Path: path,
|
|
154
|
+
Cwd: header.Cwd,
|
|
155
|
+
StartedAt: header.Timestamp,
|
|
156
|
+
EndedAt: header.Timestamp,
|
|
157
|
+
},
|
|
158
|
+
Events: []model.Event{},
|
|
159
|
+
Marks: []model.Mark{},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
pending := map[string]adapter.ToolCall{}
|
|
163
|
+
pendingOrder := []string{}
|
|
164
|
+
firstUserText := ""
|
|
165
|
+
for _, entry := range linearize(entries) {
|
|
166
|
+
if entry.Timestamp != "" {
|
|
167
|
+
if trace.Session.StartedAt == "" {
|
|
168
|
+
trace.Session.StartedAt = entry.Timestamp
|
|
169
|
+
}
|
|
170
|
+
trace.Session.EndedAt = entry.Timestamp
|
|
171
|
+
}
|
|
172
|
+
switch entry.Type {
|
|
173
|
+
case "model_change":
|
|
174
|
+
// A model_change records a deliberate user switch, so it wins
|
|
175
|
+
// over the per-message model that assistant turns carry.
|
|
176
|
+
if entry.ModelID != "" {
|
|
177
|
+
trace.Session.Model = entry.ModelID
|
|
178
|
+
}
|
|
179
|
+
case "compaction":
|
|
180
|
+
trace.Marks = append(trace.Marks, model.Mark{Seq: len(trace.Events), Type: "compaction"})
|
|
181
|
+
case "branch_summary":
|
|
182
|
+
// Switching branches folds the abandoned path into a summary,
|
|
183
|
+
// which reads like a compaction on the surviving trunk.
|
|
184
|
+
trace.Marks = append(trace.Marks, model.Mark{
|
|
185
|
+
Seq: len(trace.Events),
|
|
186
|
+
Type: "compaction",
|
|
187
|
+
Note: adapter.UserMessageNote("branch: " + entry.Summary),
|
|
188
|
+
})
|
|
189
|
+
case "custom_message":
|
|
190
|
+
// Extension-injected context. A user-message mark would count
|
|
191
|
+
// it as a user turn in ComputeStats, so it is dropped the same
|
|
192
|
+
// way injected user messages are.
|
|
193
|
+
case "message":
|
|
194
|
+
msg, err := decodePiMessage(entry.Message)
|
|
195
|
+
if err != nil {
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
switch msg.Role {
|
|
199
|
+
case "user":
|
|
200
|
+
text := contentText(msg.Content)
|
|
201
|
+
if !adapter.InjectedUserMessage(text) {
|
|
202
|
+
trace.Marks = append(trace.Marks, model.Mark{
|
|
203
|
+
Seq: len(trace.Events),
|
|
204
|
+
Type: "user-message",
|
|
205
|
+
Note: adapter.UserMessageNote(text),
|
|
206
|
+
})
|
|
207
|
+
if firstUserText == "" {
|
|
208
|
+
firstUserText = text
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
case "assistant":
|
|
212
|
+
if msg.Model != "" && trace.Session.Model == "" {
|
|
213
|
+
trace.Session.Model = msg.Model
|
|
214
|
+
}
|
|
215
|
+
for _, block := range contentBlocks(msg.Content) {
|
|
216
|
+
if block.Type != "toolCall" || block.ID == "" {
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
call := adapter.ToolCall{
|
|
220
|
+
ID: block.ID,
|
|
221
|
+
Name: block.Name,
|
|
222
|
+
Input: block.Arguments,
|
|
223
|
+
Timestamp: entry.Timestamp,
|
|
224
|
+
}
|
|
225
|
+
if _, exists := pending[call.ID]; !exists {
|
|
226
|
+
pendingOrder = append(pendingOrder, call.ID)
|
|
227
|
+
}
|
|
228
|
+
pending[call.ID] = call
|
|
229
|
+
}
|
|
230
|
+
case "toolResult":
|
|
231
|
+
call, ok := pending[msg.ToolCallID]
|
|
232
|
+
if !ok {
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
delete(pending, msg.ToolCallID)
|
|
236
|
+
isError := msg.IsError != nil && *msg.IsError
|
|
237
|
+
trace.Events = append(trace.Events, adapter.BuildEvent(trace, call, adapter.ToolResult{
|
|
238
|
+
Content: contentText(msg.Content),
|
|
239
|
+
IsError: isError,
|
|
240
|
+
OutcomeKnown: msg.IsError != nil,
|
|
241
|
+
}))
|
|
242
|
+
case "bashExecution":
|
|
243
|
+
// A `!` command the user ran from the TUI still touches the
|
|
244
|
+
// repository, so it plays back as a regular bash event.
|
|
245
|
+
call := adapter.ToolCall{
|
|
246
|
+
Name: "bash",
|
|
247
|
+
Input: map[string]any{"command": msg.Command},
|
|
248
|
+
Timestamp: entry.Timestamp,
|
|
249
|
+
}
|
|
250
|
+
trace.Events = append(trace.Events, adapter.BuildEvent(trace, call, adapter.ToolResult{
|
|
251
|
+
Content: msg.Output,
|
|
252
|
+
IsError: msg.ExitCode != nil && *msg.ExitCode != 0,
|
|
253
|
+
OutcomeKnown: msg.ExitCode != nil,
|
|
254
|
+
}))
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for _, id := range pendingOrder {
|
|
259
|
+
if call, ok := pending[id]; ok {
|
|
260
|
+
trace.Events = append(trace.Events, adapter.BuildEvent(trace, call, adapter.ToolResult{}))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
trace.Session.Title = sessionTitle(entries, firstUserText, path)
|
|
264
|
+
trace.Session.EventCount = len(trace.Events)
|
|
265
|
+
// pi tool results carry an isError flag set by the harness.
|
|
266
|
+
trace.Stats = model.ComputeStats(trace, 0, model.ObservabilityExact)
|
|
267
|
+
return trace, err
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// linearize picks the playback path through a pi session tree. The file is
|
|
271
|
+
// append-only, so the last entry is the current leaf; walking parentId back
|
|
272
|
+
// to the root keeps the trunk the user ended on and drops abandoned
|
|
273
|
+
// branches. v1 files carry no entry IDs and are already linear.
|
|
274
|
+
func linearize(entries []rawEntry) []rawEntry {
|
|
275
|
+
leaf := -1
|
|
276
|
+
index := map[string]int{}
|
|
277
|
+
for i, entry := range entries {
|
|
278
|
+
if entry.ID != "" {
|
|
279
|
+
leaf = i
|
|
280
|
+
index[entry.ID] = i
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if leaf < 0 {
|
|
284
|
+
return entries
|
|
285
|
+
}
|
|
286
|
+
var path []int
|
|
287
|
+
visited := map[int]bool{}
|
|
288
|
+
cur := leaf
|
|
289
|
+
for {
|
|
290
|
+
if visited[cur] {
|
|
291
|
+
break
|
|
292
|
+
}
|
|
293
|
+
visited[cur] = true
|
|
294
|
+
path = append(path, cur)
|
|
295
|
+
parent := entries[cur].ParentID
|
|
296
|
+
if parent == "" {
|
|
297
|
+
break
|
|
298
|
+
}
|
|
299
|
+
next, ok := index[parent]
|
|
300
|
+
if !ok {
|
|
301
|
+
break
|
|
302
|
+
}
|
|
303
|
+
cur = next
|
|
304
|
+
}
|
|
305
|
+
ordered := make([]rawEntry, 0, len(path))
|
|
306
|
+
for i := len(path) - 1; i >= 0; i-- {
|
|
307
|
+
ordered = append(ordered, entries[path[i]])
|
|
308
|
+
}
|
|
309
|
+
return ordered
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
type rawEntry struct {
|
|
313
|
+
Type string `json:"type"`
|
|
314
|
+
ID string `json:"id"`
|
|
315
|
+
ParentID string `json:"parentId"`
|
|
316
|
+
Timestamp string `json:"timestamp"`
|
|
317
|
+
Cwd string `json:"cwd"`
|
|
318
|
+
Message json.RawMessage `json:"message"`
|
|
319
|
+
ModelID string `json:"modelId"`
|
|
320
|
+
Summary string `json:"summary"`
|
|
321
|
+
Name string `json:"name"`
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
type piMessage struct {
|
|
325
|
+
Role string `json:"role"`
|
|
326
|
+
Content json.RawMessage `json:"content"`
|
|
327
|
+
Model string `json:"model"`
|
|
328
|
+
ToolCallID string `json:"toolCallId"`
|
|
329
|
+
IsError *bool `json:"isError"`
|
|
330
|
+
Command string `json:"command"`
|
|
331
|
+
Output string `json:"output"`
|
|
332
|
+
ExitCode *int `json:"exitCode"`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
func decodePiMessage(data json.RawMessage) (piMessage, error) {
|
|
336
|
+
var msg piMessage
|
|
337
|
+
if err := json.Unmarshal(data, &msg); err != nil {
|
|
338
|
+
return piMessage{}, err
|
|
339
|
+
}
|
|
340
|
+
var fields map[string]json.RawMessage
|
|
341
|
+
if err := json.Unmarshal(data, &fields); err != nil {
|
|
342
|
+
return piMessage{}, err
|
|
343
|
+
}
|
|
344
|
+
msg.IsError = nil
|
|
345
|
+
if raw, ok := fields["isError"]; ok && strings.TrimSpace(string(raw)) != "null" {
|
|
346
|
+
var value bool
|
|
347
|
+
if err := json.Unmarshal(raw, &value); err != nil {
|
|
348
|
+
return piMessage{}, err
|
|
349
|
+
}
|
|
350
|
+
msg.IsError = &value
|
|
351
|
+
}
|
|
352
|
+
msg.ExitCode = nil
|
|
353
|
+
if raw, ok := fields["exitCode"]; ok && strings.TrimSpace(string(raw)) != "null" {
|
|
354
|
+
var value int
|
|
355
|
+
if err := json.Unmarshal(raw, &value); err != nil {
|
|
356
|
+
return piMessage{}, err
|
|
357
|
+
}
|
|
358
|
+
msg.ExitCode = &value
|
|
359
|
+
}
|
|
360
|
+
return msg, nil
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
type contentBlock struct {
|
|
364
|
+
Type string `json:"type"`
|
|
365
|
+
Text string `json:"text"`
|
|
366
|
+
ID string `json:"id"`
|
|
367
|
+
Name string `json:"name"`
|
|
368
|
+
Arguments map[string]any `json:"arguments"`
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// isPiHeader matches pi's own header signature: type "session" plus an id of
|
|
372
|
+
// JSON string type — an empty string id is still valid, and legacy sessions
|
|
373
|
+
// may lack a cwd, so neither may tighten the check. The raw id bytes stand in
|
|
374
|
+
// for JavaScript's `typeof id === "string"`, which a decoded Go string cannot
|
|
375
|
+
// express (missing, null, and "" all decode to ""). Claude Code lines never
|
|
376
|
+
// use type "session" and Codex wraps its metadata in a "session_meta"
|
|
377
|
+
// envelope with no top-level id, so this cannot claim other sources' files.
|
|
378
|
+
func isPiHeader(data []byte) bool {
|
|
379
|
+
var probe struct {
|
|
380
|
+
Type string `json:"type"`
|
|
381
|
+
ID json.RawMessage `json:"id"`
|
|
382
|
+
}
|
|
383
|
+
if json.Unmarshal(data, &probe) != nil {
|
|
384
|
+
return false
|
|
385
|
+
}
|
|
386
|
+
return probe.Type == "session" && len(probe.ID) > 0 && probe.ID[0] == '"'
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// readSession loads the header and every tree entry of a pi session file.
|
|
390
|
+
// Like pi's loader, blank and malformed lines are skipped without consuming
|
|
391
|
+
// the header slot: the first line that parses as JSON must be the header, or
|
|
392
|
+
// the file is not a pi session. Entries are only collected once the header
|
|
393
|
+
// is accepted.
|
|
394
|
+
func readSession(path string) (header rawEntry, entries []rawEntry, recognized bool, err error) {
|
|
395
|
+
f, err := os.Open(path)
|
|
396
|
+
if err != nil {
|
|
397
|
+
return rawEntry{}, nil, false, err
|
|
398
|
+
}
|
|
399
|
+
defer f.Close()
|
|
400
|
+
|
|
401
|
+
sawEntry := false
|
|
402
|
+
err = adapter.ReadJSONLines(f, func(data []byte) {
|
|
403
|
+
var entry rawEntry
|
|
404
|
+
if json.Unmarshal(data, &entry) != nil {
|
|
405
|
+
if !json.Valid(data) {
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
// Valid JSON in a shape rawEntry cannot hold still counts as
|
|
409
|
+
// the first parsed entry for pi — and it is not a header.
|
|
410
|
+
sawEntry = true
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
if !sawEntry {
|
|
414
|
+
sawEntry = true
|
|
415
|
+
if isPiHeader(data) {
|
|
416
|
+
header = entry
|
|
417
|
+
recognized = true
|
|
418
|
+
}
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
if recognized && entry.Type != "" {
|
|
422
|
+
entries = append(entries, entry)
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
return header, entries, recognized, err
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// sessionTitle mirrors pi's getSessionName: the latest session_info entry
|
|
429
|
+
// anywhere in the file wins, and an empty name explicitly clears the title.
|
|
430
|
+
// Without one, the trunk's first user message names the session, then the
|
|
431
|
+
// file name.
|
|
432
|
+
func sessionTitle(entries []rawEntry, firstUserText, path string) string {
|
|
433
|
+
for i := len(entries) - 1; i >= 0; i-- {
|
|
434
|
+
if entries[i].Type != "session_info" {
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
if entries[i].Name != "" {
|
|
438
|
+
return entries[i].Name
|
|
439
|
+
}
|
|
440
|
+
break
|
|
441
|
+
}
|
|
442
|
+
if firstUserText != "" {
|
|
443
|
+
return adapter.AgentInstructionPreview(firstUserText)
|
|
444
|
+
}
|
|
445
|
+
return filepath.Base(path)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
func contentBlocks(raw json.RawMessage) []contentBlock {
|
|
449
|
+
if len(raw) == 0 {
|
|
450
|
+
return nil
|
|
451
|
+
}
|
|
452
|
+
var blocks []contentBlock
|
|
453
|
+
if json.Unmarshal(raw, &blocks) == nil {
|
|
454
|
+
return blocks
|
|
455
|
+
}
|
|
456
|
+
var s string
|
|
457
|
+
if json.Unmarshal(raw, &s) == nil && s != "" {
|
|
458
|
+
return []contentBlock{{Type: "text", Text: s}}
|
|
459
|
+
}
|
|
460
|
+
return nil
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
func contentText(raw json.RawMessage) string {
|
|
464
|
+
var parts []string
|
|
465
|
+
for _, block := range contentBlocks(raw) {
|
|
466
|
+
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
|
|
467
|
+
parts = append(parts, strings.TrimSpace(block.Text))
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return strings.Join(parts, "\n")
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// countToolCalls counts the calls Parse will turn into events: toolCall
|
|
474
|
+
// blocks without an id are skipped there too.
|
|
475
|
+
func countToolCalls(raw json.RawMessage) int {
|
|
476
|
+
count := 0
|
|
477
|
+
for _, block := range contentBlocks(raw) {
|
|
478
|
+
if block.Type == "toolCall" && block.ID != "" {
|
|
479
|
+
count++
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return count
|
|
483
|
+
}
|