@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,919 @@
|
|
|
1
|
+
package codex
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"regexp"
|
|
9
|
+
"sort"
|
|
10
|
+
"strings"
|
|
11
|
+
"sync"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/adapter"
|
|
15
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/judge"
|
|
16
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/model"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
type Adapter struct {
|
|
20
|
+
Dir string
|
|
21
|
+
IndexPath string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func DefaultDir() string {
|
|
25
|
+
home, err := os.UserHomeDir()
|
|
26
|
+
if err != nil {
|
|
27
|
+
return ""
|
|
28
|
+
}
|
|
29
|
+
return filepath.Join(home, ".codex", "sessions")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func DefaultIndexPath() string {
|
|
33
|
+
home, err := os.UserHomeDir()
|
|
34
|
+
if err != nil {
|
|
35
|
+
return ""
|
|
36
|
+
}
|
|
37
|
+
return filepath.Join(home, ".codex", "session_index.jsonl")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func (a Adapter) Harness() string {
|
|
41
|
+
return "codex"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func (a Adapter) SessionDir() string {
|
|
45
|
+
if a.Dir != "" {
|
|
46
|
+
return a.Dir
|
|
47
|
+
}
|
|
48
|
+
return DefaultDir()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func (a Adapter) ListSessions() ([]model.SessionMeta, error) {
|
|
52
|
+
dir := a.SessionDir()
|
|
53
|
+
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
|
54
|
+
return nil, nil
|
|
55
|
+
}
|
|
56
|
+
var metas []model.SessionMeta
|
|
57
|
+
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error {
|
|
58
|
+
if walkErr != nil {
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
if entry.IsDir() {
|
|
62
|
+
return nil
|
|
63
|
+
}
|
|
64
|
+
if filepath.Ext(path) != ".jsonl" {
|
|
65
|
+
return nil
|
|
66
|
+
}
|
|
67
|
+
meta, err := a.Summarize(path)
|
|
68
|
+
if err == nil && !meta.Auxiliary {
|
|
69
|
+
metas = append(metas, meta)
|
|
70
|
+
}
|
|
71
|
+
return nil
|
|
72
|
+
})
|
|
73
|
+
if err != nil {
|
|
74
|
+
return nil, err
|
|
75
|
+
}
|
|
76
|
+
sort.Slice(metas, func(i, j int) bool {
|
|
77
|
+
return metas[i].EndedAt > metas[j].EndedAt
|
|
78
|
+
})
|
|
79
|
+
return metas, nil
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// SummaryInputs reports the sidecar files Summarize reads for a session:
|
|
83
|
+
// titles come from a session_index.jsonl resolved next to the sessions
|
|
84
|
+
// directory or at the default location, so an index change — a Codex thread
|
|
85
|
+
// rename — must invalidate cached summaries. Candidates are declared even
|
|
86
|
+
// while missing (unlike indexPath, which stat-gates), so creating the index
|
|
87
|
+
// later invalidates too.
|
|
88
|
+
func (a Adapter) SummaryInputs(_ string) []string {
|
|
89
|
+
if a.IndexPath != "" {
|
|
90
|
+
return []string{a.IndexPath}
|
|
91
|
+
}
|
|
92
|
+
var inputs []string
|
|
93
|
+
if dir := filepath.Clean(a.SessionDir()); dir != "" && dir != "." {
|
|
94
|
+
inputs = append(inputs, filepath.Join(filepath.Dir(dir), "session_index.jsonl"))
|
|
95
|
+
}
|
|
96
|
+
if candidate := DefaultIndexPath(); candidate != "" && (len(inputs) == 0 || inputs[0] != candidate) {
|
|
97
|
+
inputs = append(inputs, candidate)
|
|
98
|
+
}
|
|
99
|
+
return inputs
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func (a Adapter) Summarize(path string) (model.SessionMeta, error) {
|
|
103
|
+
f, err := os.Open(path)
|
|
104
|
+
if err != nil {
|
|
105
|
+
return model.SessionMeta{}, err
|
|
106
|
+
}
|
|
107
|
+
defer f.Close()
|
|
108
|
+
|
|
109
|
+
id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
110
|
+
meta := model.SessionMeta{Key: adapter.SessionKey(a.Harness(), path), ID: id, Harness: a.Harness(), Path: path}
|
|
111
|
+
recognized := false
|
|
112
|
+
sawSessionMeta := false
|
|
113
|
+
seenCalls := map[string]bool{}
|
|
114
|
+
err = adapter.ReadJSONLines(f, func(data []byte) {
|
|
115
|
+
var line rawLine
|
|
116
|
+
if json.Unmarshal(data, &line) != nil {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if line.Timestamp != "" {
|
|
120
|
+
if meta.StartedAt == "" {
|
|
121
|
+
meta.StartedAt = line.Timestamp
|
|
122
|
+
}
|
|
123
|
+
meta.EndedAt = line.Timestamp
|
|
124
|
+
}
|
|
125
|
+
switch line.Type {
|
|
126
|
+
case "session_meta":
|
|
127
|
+
recognized = true
|
|
128
|
+
var payload sessionMetaPayload
|
|
129
|
+
if json.Unmarshal(line.Payload, &payload) == nil {
|
|
130
|
+
firstSessionMeta := !sawSessionMeta
|
|
131
|
+
if !sawSessionMeta {
|
|
132
|
+
sawSessionMeta = true
|
|
133
|
+
meta.Auxiliary = payload.isSubagent()
|
|
134
|
+
}
|
|
135
|
+
applySessionMeta(&meta, payload, firstSessionMeta)
|
|
136
|
+
}
|
|
137
|
+
case "turn_context":
|
|
138
|
+
recognized = true
|
|
139
|
+
var payload turnContextPayload
|
|
140
|
+
if json.Unmarshal(line.Payload, &payload) == nil {
|
|
141
|
+
if payload.Cwd != "" && meta.Cwd == "" {
|
|
142
|
+
meta.Cwd = payload.Cwd
|
|
143
|
+
}
|
|
144
|
+
if payload.Model != "" && meta.Model == "" {
|
|
145
|
+
meta.Model = payload.Model
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
case "response_item":
|
|
149
|
+
recognized = true
|
|
150
|
+
var payload responseItemHeader
|
|
151
|
+
if json.Unmarshal(line.Payload, &payload) == nil {
|
|
152
|
+
if callID, ok := canonicalCallID(payload.Type, payload.ID, payload.CallID, payload.Name); ok && !seenCalls[callID] {
|
|
153
|
+
seenCalls[callID] = true
|
|
154
|
+
meta.EventCount++
|
|
155
|
+
}
|
|
156
|
+
// only message items pay the full decode; the count must
|
|
157
|
+
// mirror Parse's user-message mark filter
|
|
158
|
+
if payload.Type == "message" {
|
|
159
|
+
var msg responseItemPayload
|
|
160
|
+
if json.Unmarshal(line.Payload, &msg) == nil && msg.Role == "user" && msg.Content.HasText() {
|
|
161
|
+
if !adapter.InjectedUserMessage(msg.Content.Text()) {
|
|
162
|
+
meta.UserTurns++
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
case "event_msg":
|
|
168
|
+
recognized = true
|
|
169
|
+
case "message":
|
|
170
|
+
// Codex message lines carry the role at the top level; pi
|
|
171
|
+
// sessions also use type "message" but nest theirs, so a
|
|
172
|
+
// roleless line must not claim the file for this adapter.
|
|
173
|
+
if line.Role == "" {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
recognized = true
|
|
177
|
+
if line.Role == "user" && line.Content.HasText() {
|
|
178
|
+
if !adapter.InjectedUserMessage(line.Content.Text()) {
|
|
179
|
+
meta.UserTurns++
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
case "":
|
|
183
|
+
if line.ID != "" {
|
|
184
|
+
recognized = true
|
|
185
|
+
meta.ID = line.ID
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
if judge.IsWorkDir(meta.Cwd) {
|
|
190
|
+
// Sessions recorded in the judge workdir are synapse's own judge
|
|
191
|
+
// runs (codex cannot disable session persistence), not user coding
|
|
192
|
+
// sessions; auxiliary keeps them out of every listing.
|
|
193
|
+
meta.Auxiliary = true
|
|
194
|
+
}
|
|
195
|
+
if meta.Title == "" {
|
|
196
|
+
meta.Title = a.titleFor(meta.ID)
|
|
197
|
+
}
|
|
198
|
+
if meta.Title == "" {
|
|
199
|
+
meta.Title = filepath.Base(path)
|
|
200
|
+
}
|
|
201
|
+
if !recognized {
|
|
202
|
+
return model.SessionMeta{}, fmt.Errorf("not a Codex session: %s", path)
|
|
203
|
+
}
|
|
204
|
+
return meta, err
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func (a Adapter) Parse(path string) (*model.Trace, error) {
|
|
208
|
+
f, err := os.Open(path)
|
|
209
|
+
if err != nil {
|
|
210
|
+
return nil, err
|
|
211
|
+
}
|
|
212
|
+
defer f.Close()
|
|
213
|
+
|
|
214
|
+
id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
215
|
+
trace := &model.Trace{
|
|
216
|
+
Version: 1,
|
|
217
|
+
Session: model.TraceSession{
|
|
218
|
+
ID: id,
|
|
219
|
+
Harness: a.Harness(),
|
|
220
|
+
Path: path,
|
|
221
|
+
},
|
|
222
|
+
Events: []model.Event{},
|
|
223
|
+
Marks: []model.Mark{},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
recognized := false
|
|
227
|
+
sawSessionMeta := false
|
|
228
|
+
calls := map[string]adapter.ToolCall{}
|
|
229
|
+
callOrder := []string{}
|
|
230
|
+
results := map[string]adapter.ToolResult{}
|
|
231
|
+
directPatches := map[string]bool{}
|
|
232
|
+
patchResults := map[string]patchApplyEndPayload{}
|
|
233
|
+
err = adapter.ReadJSONLines(f, func(data []byte) {
|
|
234
|
+
var line rawLine
|
|
235
|
+
if json.Unmarshal(data, &line) != nil {
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
applyLineTime(trace, line.Timestamp)
|
|
239
|
+
switch line.Type {
|
|
240
|
+
case "session_meta":
|
|
241
|
+
recognized = true
|
|
242
|
+
var payload sessionMetaPayload
|
|
243
|
+
if json.Unmarshal(line.Payload, &payload) == nil {
|
|
244
|
+
firstSessionMeta := !sawSessionMeta
|
|
245
|
+
sawSessionMeta = true
|
|
246
|
+
applyTraceSessionMeta(trace, payload, firstSessionMeta)
|
|
247
|
+
}
|
|
248
|
+
case "turn_context":
|
|
249
|
+
recognized = true
|
|
250
|
+
var payload turnContextPayload
|
|
251
|
+
if json.Unmarshal(line.Payload, &payload) == nil {
|
|
252
|
+
if payload.Cwd != "" && trace.Session.Cwd == "" {
|
|
253
|
+
trace.Session.Cwd = payload.Cwd
|
|
254
|
+
}
|
|
255
|
+
if payload.Model != "" && trace.Session.Model == "" {
|
|
256
|
+
trace.Session.Model = payload.Model
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
case "response_item":
|
|
260
|
+
recognized = true
|
|
261
|
+
var payload responseItemPayload
|
|
262
|
+
if json.Unmarshal(line.Payload, &payload) != nil {
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if call, source, ok := decodeCall(payload, line.Timestamp); ok {
|
|
266
|
+
if _, exists := calls[call.ID]; exists {
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
if call.Name == "spawn_agent" {
|
|
270
|
+
trace.Marks = append(trace.Marks, model.Mark{Seq: len(callOrder), Type: "subagent", Note: call.Name})
|
|
271
|
+
}
|
|
272
|
+
calls[call.ID] = call
|
|
273
|
+
callOrder = append(callOrder, call.ID)
|
|
274
|
+
directPatches[call.ID] = source == "custom_tool_call" && call.Name == "apply_patch"
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
if callID, result, ok := decodeOutput(payload); ok {
|
|
278
|
+
call, exists := calls[callID]
|
|
279
|
+
if !exists {
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
if _, exists := results[callID]; exists {
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
result.IsError, result.OutcomeKnown = toolOutputStatus(call.Name, result.Content)
|
|
286
|
+
results[callID] = result
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
if payload.Type == "message" {
|
|
290
|
+
if payload.Role == "user" && payload.Content.HasText() {
|
|
291
|
+
if text := payload.Content.Text(); !adapter.InjectedUserMessage(text) {
|
|
292
|
+
trace.Marks = append(trace.Marks, model.Mark{
|
|
293
|
+
Seq: len(callOrder),
|
|
294
|
+
Type: "user-message",
|
|
295
|
+
Note: adapter.UserMessageNote(text),
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
case "message":
|
|
301
|
+
// Mirrors Summarize: a roleless "message" line is another
|
|
302
|
+
// source's shape (pi nests the payload), not a Codex line.
|
|
303
|
+
if line.Role == "" {
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
recognized = true
|
|
307
|
+
if line.Role == "user" && line.Content.HasText() {
|
|
308
|
+
if text := line.Content.Text(); !adapter.InjectedUserMessage(text) {
|
|
309
|
+
trace.Marks = append(trace.Marks, model.Mark{
|
|
310
|
+
Seq: len(callOrder),
|
|
311
|
+
Type: "user-message",
|
|
312
|
+
Note: adapter.UserMessageNote(text),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
case "event_msg":
|
|
317
|
+
recognized = true
|
|
318
|
+
var payload patchApplyEndPayload
|
|
319
|
+
if json.Unmarshal(line.Payload, &payload) != nil {
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
payload.Success = exactBoolPointer(line.Payload, "success")
|
|
323
|
+
if payload.Type == "context_compacted" {
|
|
324
|
+
trace.Marks = append(trace.Marks, model.Mark{Seq: len(callOrder), Type: "compaction"})
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
if payload.Type != "patch_apply_end" || payload.CallID == "" {
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
if !directPatches[payload.CallID] {
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
if _, exists := patchResults[payload.CallID]; !exists {
|
|
334
|
+
patchResults[payload.CallID] = payload
|
|
335
|
+
}
|
|
336
|
+
case "":
|
|
337
|
+
if line.ID != "" {
|
|
338
|
+
recognized = true
|
|
339
|
+
trace.Session.ID = line.ID
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
for _, id := range callOrder {
|
|
344
|
+
call := calls[id]
|
|
345
|
+
result := results[id]
|
|
346
|
+
if patchResult, ok := patchResults[id]; ok {
|
|
347
|
+
call.Input = applyPatchChanges(call.Input, patchResult.Changes)
|
|
348
|
+
if patchResult.Success != nil {
|
|
349
|
+
result.IsError = !*patchResult.Success
|
|
350
|
+
result.OutcomeKnown = true
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
trace.Events = append(trace.Events, adapter.BuildEvent(trace, call, result))
|
|
354
|
+
}
|
|
355
|
+
for i := range trace.Events {
|
|
356
|
+
trace.Events[i].Seq = i
|
|
357
|
+
}
|
|
358
|
+
if trace.Session.Title == "" {
|
|
359
|
+
trace.Session.Title = a.titleFor(trace.Session.ID)
|
|
360
|
+
}
|
|
361
|
+
if trace.Session.Title == "" {
|
|
362
|
+
trace.Session.Title = filepath.Base(path)
|
|
363
|
+
}
|
|
364
|
+
trace.Session.EventCount = len(trace.Events)
|
|
365
|
+
// Codex logs carry no structural error flag; failures are inferred from
|
|
366
|
+
// output text, and inner commands of the js exec runner surface only what
|
|
367
|
+
// the script chooses to print.
|
|
368
|
+
trace.Stats = model.ComputeStats(trace, 0, model.ObservabilityEstimated)
|
|
369
|
+
if !recognized {
|
|
370
|
+
return nil, fmt.Errorf("not a Codex session: %s", path)
|
|
371
|
+
}
|
|
372
|
+
return trace, err
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
type rawLine struct {
|
|
376
|
+
Type string `json:"type"`
|
|
377
|
+
Timestamp string `json:"timestamp"`
|
|
378
|
+
Payload json.RawMessage `json:"payload"`
|
|
379
|
+
ID string `json:"id"`
|
|
380
|
+
Role string `json:"role"`
|
|
381
|
+
Content codexContentList `json:"content"`
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
type sessionMetaPayload struct {
|
|
385
|
+
ID string `json:"id"`
|
|
386
|
+
SessionID string `json:"session_id"`
|
|
387
|
+
ParentThreadID string `json:"parent_thread_id"`
|
|
388
|
+
AgentPath string `json:"agent_path"`
|
|
389
|
+
AgentNickname string `json:"agent_nickname"`
|
|
390
|
+
AgentRole string `json:"agent_role"`
|
|
391
|
+
Timestamp string `json:"timestamp"`
|
|
392
|
+
Cwd string `json:"cwd"`
|
|
393
|
+
ThreadSource string `json:"thread_source"`
|
|
394
|
+
Source json.RawMessage `json:"source"`
|
|
395
|
+
Git struct {
|
|
396
|
+
Branch string `json:"branch"`
|
|
397
|
+
CommitHash string `json:"commit_hash"`
|
|
398
|
+
} `json:"git"`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
func (p sessionMetaPayload) agentMeta() *model.AgentSessionMeta {
|
|
402
|
+
if !p.isSubagent() {
|
|
403
|
+
return nil
|
|
404
|
+
}
|
|
405
|
+
agent := &model.AgentSessionMeta{
|
|
406
|
+
SourceID: p.ID,
|
|
407
|
+
RootSessionID: p.SessionID,
|
|
408
|
+
ParentSessionID: p.ParentThreadID,
|
|
409
|
+
AgentPath: p.AgentPath,
|
|
410
|
+
Label: p.AgentNickname,
|
|
411
|
+
Role: p.AgentRole,
|
|
412
|
+
}
|
|
413
|
+
var source struct {
|
|
414
|
+
Subagent json.RawMessage `json:"subagent"`
|
|
415
|
+
}
|
|
416
|
+
var subagent struct {
|
|
417
|
+
ThreadSpawn struct {
|
|
418
|
+
ParentThreadID string `json:"parent_thread_id"`
|
|
419
|
+
Depth int `json:"depth"`
|
|
420
|
+
AgentPath string `json:"agent_path"`
|
|
421
|
+
AgentNickname string `json:"agent_nickname"`
|
|
422
|
+
AgentRole string `json:"agent_role"`
|
|
423
|
+
} `json:"thread_spawn"`
|
|
424
|
+
}
|
|
425
|
+
if json.Unmarshal(p.Source, &source) == nil && json.Unmarshal(source.Subagent, &subagent) == nil {
|
|
426
|
+
agent.Depth = subagent.ThreadSpawn.Depth
|
|
427
|
+
if agent.AgentPath == "" {
|
|
428
|
+
agent.AgentPath = subagent.ThreadSpawn.AgentPath
|
|
429
|
+
}
|
|
430
|
+
if agent.ParentSessionID == "" {
|
|
431
|
+
agent.ParentSessionID = subagent.ThreadSpawn.ParentThreadID
|
|
432
|
+
}
|
|
433
|
+
if agent.Label == "" {
|
|
434
|
+
agent.Label = subagent.ThreadSpawn.AgentNickname
|
|
435
|
+
}
|
|
436
|
+
if agent.Role == "" {
|
|
437
|
+
agent.Role = subagent.ThreadSpawn.AgentRole
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return agent
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
func (p sessionMetaPayload) isSubagent() bool {
|
|
444
|
+
if p.ThreadSource == "subagent" {
|
|
445
|
+
return true
|
|
446
|
+
}
|
|
447
|
+
if len(p.Source) == 0 {
|
|
448
|
+
return false
|
|
449
|
+
}
|
|
450
|
+
var source struct {
|
|
451
|
+
Subagent json.RawMessage `json:"subagent"`
|
|
452
|
+
}
|
|
453
|
+
if json.Unmarshal(p.Source, &source) != nil {
|
|
454
|
+
return false
|
|
455
|
+
}
|
|
456
|
+
subagent := strings.TrimSpace(string(source.Subagent))
|
|
457
|
+
return subagent != "" && subagent != "null"
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
type turnContextPayload struct {
|
|
461
|
+
Cwd string `json:"cwd"`
|
|
462
|
+
Model string `json:"model"`
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
type responseItemPayload struct {
|
|
466
|
+
Type string `json:"type"`
|
|
467
|
+
ID string `json:"id"`
|
|
468
|
+
Name string `json:"name"`
|
|
469
|
+
Arguments json.RawMessage `json:"arguments"`
|
|
470
|
+
Input json.RawMessage `json:"input"`
|
|
471
|
+
CallID string `json:"call_id"`
|
|
472
|
+
Output any `json:"output"`
|
|
473
|
+
Role string `json:"role"`
|
|
474
|
+
Content codexContentList `json:"content"`
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// responseItemHeader keeps session-list scans allocation-light. Summarize only
|
|
478
|
+
// needs call identity, so decoding large arguments, outputs, and patch bodies
|
|
479
|
+
// here would add substantial cold-start cost without changing the count.
|
|
480
|
+
type responseItemHeader struct {
|
|
481
|
+
Type string `json:"type"`
|
|
482
|
+
ID string `json:"id"`
|
|
483
|
+
Name string `json:"name"`
|
|
484
|
+
CallID string `json:"call_id"`
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
type patchApplyEndPayload struct {
|
|
488
|
+
Type string `json:"type"`
|
|
489
|
+
CallID string `json:"call_id"`
|
|
490
|
+
Success *bool `json:"success"`
|
|
491
|
+
Changes map[string]patchApplyChange `json:"changes"`
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
type patchApplyChange struct {
|
|
495
|
+
Type string `json:"type"`
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
type codexContentList struct {
|
|
499
|
+
Items []codexContentItem
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
func (c *codexContentList) UnmarshalJSON(data []byte) error {
|
|
503
|
+
if len(data) == 0 || string(data) == "null" {
|
|
504
|
+
return nil
|
|
505
|
+
}
|
|
506
|
+
if data[0] == '"' {
|
|
507
|
+
var s string
|
|
508
|
+
if err := json.Unmarshal(data, &s); err != nil {
|
|
509
|
+
return err
|
|
510
|
+
}
|
|
511
|
+
c.Items = []codexContentItem{{Type: "text", Text: s}}
|
|
512
|
+
return nil
|
|
513
|
+
}
|
|
514
|
+
var items []codexContentItem
|
|
515
|
+
if err := json.Unmarshal(data, &items); err != nil {
|
|
516
|
+
return err
|
|
517
|
+
}
|
|
518
|
+
c.Items = items
|
|
519
|
+
return nil
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
func (c codexContentList) Text() string {
|
|
523
|
+
var parts []string
|
|
524
|
+
for _, item := range c.Items {
|
|
525
|
+
if strings.TrimSpace(item.Text) != "" {
|
|
526
|
+
parts = append(parts, strings.TrimSpace(item.Text))
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return strings.Join(parts, "\n")
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
func (c codexContentList) HasText() bool {
|
|
533
|
+
for _, item := range c.Items {
|
|
534
|
+
if strings.TrimSpace(item.Text) != "" {
|
|
535
|
+
return true
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return false
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
type codexContentItem struct {
|
|
542
|
+
Type string `json:"type"`
|
|
543
|
+
Text string `json:"text"`
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
func applySessionMeta(meta *model.SessionMeta, payload sessionMetaPayload, setIdentity bool) {
|
|
547
|
+
if setIdentity {
|
|
548
|
+
if payload.ID != "" {
|
|
549
|
+
meta.ID = payload.ID
|
|
550
|
+
} else if payload.SessionID != "" {
|
|
551
|
+
meta.ID = payload.SessionID
|
|
552
|
+
}
|
|
553
|
+
meta.Agent = payload.agentMeta()
|
|
554
|
+
}
|
|
555
|
+
if payload.Cwd != "" && meta.Cwd == "" {
|
|
556
|
+
meta.Cwd = payload.Cwd
|
|
557
|
+
}
|
|
558
|
+
if payload.Git.Branch != "" && meta.GitBranch == "" {
|
|
559
|
+
meta.GitBranch = payload.Git.Branch
|
|
560
|
+
}
|
|
561
|
+
if payload.Timestamp != "" {
|
|
562
|
+
if meta.StartedAt == "" {
|
|
563
|
+
meta.StartedAt = payload.Timestamp
|
|
564
|
+
}
|
|
565
|
+
if meta.EndedAt == "" {
|
|
566
|
+
meta.EndedAt = payload.Timestamp
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
func applyTraceSessionMeta(trace *model.Trace, payload sessionMetaPayload, setIdentity bool) {
|
|
572
|
+
if setIdentity {
|
|
573
|
+
if payload.ID != "" {
|
|
574
|
+
trace.Session.ID = payload.ID
|
|
575
|
+
} else if payload.SessionID != "" {
|
|
576
|
+
trace.Session.ID = payload.SessionID
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if payload.Cwd != "" && trace.Session.Cwd == "" {
|
|
580
|
+
trace.Session.Cwd = payload.Cwd
|
|
581
|
+
}
|
|
582
|
+
if payload.Git.CommitHash != "" && trace.Session.Commit == "" {
|
|
583
|
+
trace.Session.Commit = payload.Git.CommitHash
|
|
584
|
+
}
|
|
585
|
+
if payload.Timestamp != "" && trace.Session.StartedAt == "" {
|
|
586
|
+
trace.Session.StartedAt = payload.Timestamp
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
func applyLineTime(trace *model.Trace, ts string) {
|
|
591
|
+
if ts == "" {
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
if trace.Session.StartedAt == "" {
|
|
595
|
+
trace.Session.StartedAt = ts
|
|
596
|
+
}
|
|
597
|
+
trace.Session.EndedAt = ts
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
func decodeCall(payload responseItemPayload, timestamp string) (adapter.ToolCall, string, bool) {
|
|
601
|
+
callID, ok := canonicalCallID(payload.Type, payload.ID, payload.CallID, payload.Name)
|
|
602
|
+
if !ok {
|
|
603
|
+
return adapter.ToolCall{}, "", false
|
|
604
|
+
}
|
|
605
|
+
var raw json.RawMessage
|
|
606
|
+
switch payload.Type {
|
|
607
|
+
case "function_call":
|
|
608
|
+
raw = payload.Arguments
|
|
609
|
+
case "custom_tool_call":
|
|
610
|
+
raw = payload.Input
|
|
611
|
+
}
|
|
612
|
+
return adapter.ToolCall{
|
|
613
|
+
ID: callID,
|
|
614
|
+
Name: payload.Name,
|
|
615
|
+
Input: parseInput(raw),
|
|
616
|
+
Timestamp: timestamp,
|
|
617
|
+
}, payload.Type, true
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
func canonicalCallID(callType, id, callID, name string) (string, bool) {
|
|
621
|
+
switch callType {
|
|
622
|
+
case "function_call", "custom_tool_call":
|
|
623
|
+
default:
|
|
624
|
+
return "", false
|
|
625
|
+
}
|
|
626
|
+
if callID == "" {
|
|
627
|
+
callID = id
|
|
628
|
+
}
|
|
629
|
+
return callID, callID != "" && name != ""
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
func decodeOutput(payload responseItemPayload) (string, adapter.ToolResult, bool) {
|
|
633
|
+
switch payload.Type {
|
|
634
|
+
case "function_call_output", "custom_tool_call_output":
|
|
635
|
+
default:
|
|
636
|
+
return "", adapter.ToolResult{}, false
|
|
637
|
+
}
|
|
638
|
+
if payload.CallID == "" {
|
|
639
|
+
return "", adapter.ToolResult{}, false
|
|
640
|
+
}
|
|
641
|
+
output := adapter.ContentToString(payload.Output)
|
|
642
|
+
return payload.CallID, adapter.ToolResult{
|
|
643
|
+
Content: output,
|
|
644
|
+
}, true
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
func parseInput(raw json.RawMessage) map[string]any {
|
|
648
|
+
input := map[string]any{}
|
|
649
|
+
if len(raw) == 0 || string(raw) == "null" {
|
|
650
|
+
return input
|
|
651
|
+
}
|
|
652
|
+
var value any
|
|
653
|
+
if json.Unmarshal(raw, &value) != nil {
|
|
654
|
+
input["_raw"] = string(raw)
|
|
655
|
+
return input
|
|
656
|
+
}
|
|
657
|
+
switch value := value.(type) {
|
|
658
|
+
case map[string]any:
|
|
659
|
+
return value
|
|
660
|
+
case string:
|
|
661
|
+
return parseInputText(value)
|
|
662
|
+
default:
|
|
663
|
+
encoded, _ := json.Marshal(value)
|
|
664
|
+
input["_raw"] = string(encoded)
|
|
665
|
+
return input
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
func parseInputText(text string) map[string]any {
|
|
670
|
+
input := map[string]any{}
|
|
671
|
+
trimmed := strings.TrimSpace(text)
|
|
672
|
+
if trimmed == "" {
|
|
673
|
+
return input
|
|
674
|
+
}
|
|
675
|
+
if json.Unmarshal([]byte(trimmed), &input) == nil {
|
|
676
|
+
return input
|
|
677
|
+
}
|
|
678
|
+
var nested string
|
|
679
|
+
if json.Unmarshal([]byte(trimmed), &nested) == nil && nested != text {
|
|
680
|
+
return parseInputText(nested)
|
|
681
|
+
}
|
|
682
|
+
input["_raw"] = text
|
|
683
|
+
return input
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
func applyPatchChanges(input map[string]any, changes map[string]patchApplyChange) map[string]any {
|
|
687
|
+
if len(changes) == 0 {
|
|
688
|
+
return input
|
|
689
|
+
}
|
|
690
|
+
merged := make(map[string]any, len(input)+1)
|
|
691
|
+
for key, value := range input {
|
|
692
|
+
merged[key] = value
|
|
693
|
+
}
|
|
694
|
+
patch := ""
|
|
695
|
+
for _, key := range []string{"patch", "input", "_raw"} {
|
|
696
|
+
if value, ok := input[key].(string); ok {
|
|
697
|
+
patch = value
|
|
698
|
+
break
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if patch != "" && !strings.HasSuffix(patch, "\n") {
|
|
702
|
+
patch += "\n"
|
|
703
|
+
}
|
|
704
|
+
paths := make([]string, 0, len(changes))
|
|
705
|
+
for path := range changes {
|
|
706
|
+
paths = append(paths, path)
|
|
707
|
+
}
|
|
708
|
+
sort.Strings(paths)
|
|
709
|
+
for _, path := range paths {
|
|
710
|
+
operation := "Update"
|
|
711
|
+
switch strings.ToLower(changes[path].Type) {
|
|
712
|
+
case "add":
|
|
713
|
+
operation = "Add"
|
|
714
|
+
case "delete":
|
|
715
|
+
operation = "Delete"
|
|
716
|
+
}
|
|
717
|
+
patch += fmt.Sprintf("*** %s File: %s\n", operation, path)
|
|
718
|
+
}
|
|
719
|
+
merged["patch"] = patch
|
|
720
|
+
return merged
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
var exitCodeRe = regexp.MustCompile(`(?im)^(?:Process exited with code|Exit code:)\s*([0-9]+)\s*$`)
|
|
724
|
+
|
|
725
|
+
func commandOutputStatus(output string) (failed bool, known bool) {
|
|
726
|
+
trimmed := strings.TrimSpace(output)
|
|
727
|
+
var envelope map[string]json.RawMessage
|
|
728
|
+
if json.Unmarshal([]byte(trimmed), &envelope) == nil {
|
|
729
|
+
if _, ok := exactString(envelope, "output"); ok {
|
|
730
|
+
if exitCode, ok := exactInt(envelope, "exit_code"); ok {
|
|
731
|
+
return exitCode != 0, true
|
|
732
|
+
}
|
|
733
|
+
if raw, ok := envelope["metadata"]; ok {
|
|
734
|
+
var metadata map[string]json.RawMessage
|
|
735
|
+
if json.Unmarshal(raw, &metadata) == nil {
|
|
736
|
+
if exitCode, ok := exactInt(metadata, "exit_code"); ok {
|
|
737
|
+
return exitCode != 0, true
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if _, ok := exactString(envelope, "message"); ok {
|
|
743
|
+
if timedOut, ok := exactBool(envelope, "timed_out"); ok && timedOut {
|
|
744
|
+
return true, true
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
firstLine := trimmed
|
|
749
|
+
if newline := strings.IndexByte(firstLine, '\n'); newline >= 0 {
|
|
750
|
+
firstLine = firstLine[:newline]
|
|
751
|
+
}
|
|
752
|
+
status := strings.ToLower(strings.TrimSpace(firstLine))
|
|
753
|
+
if strings.HasPrefix(status, "script running with cell id ") {
|
|
754
|
+
return false, false
|
|
755
|
+
}
|
|
756
|
+
switch status {
|
|
757
|
+
case "script completed":
|
|
758
|
+
return false, true
|
|
759
|
+
case "script failed":
|
|
760
|
+
return true, true
|
|
761
|
+
case "script running":
|
|
762
|
+
return false, false
|
|
763
|
+
}
|
|
764
|
+
header := trimmed
|
|
765
|
+
for _, marker := range []string{"\nOutput:\n", "\nFinal output:\n"} {
|
|
766
|
+
if index := strings.Index(header, marker); index >= 0 {
|
|
767
|
+
header = header[:index]
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
for _, line := range strings.Split(header, "\n") {
|
|
771
|
+
if strings.EqualFold(strings.TrimSpace(line), "aborted by user") {
|
|
772
|
+
return true, true
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
match := exitCodeRe.FindStringSubmatch(header)
|
|
776
|
+
if len(match) == 2 {
|
|
777
|
+
return match[1] != "0", true
|
|
778
|
+
}
|
|
779
|
+
return false, false
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
func toolOutputStatus(tool, output string) (failed bool, known bool) {
|
|
783
|
+
switch tool {
|
|
784
|
+
case "exec", "exec_command", "write_stdin", "wait":
|
|
785
|
+
return commandOutputStatus(output)
|
|
786
|
+
case "apply_patch":
|
|
787
|
+
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(output)), "apply_patch verification failed") {
|
|
788
|
+
return true, true
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return false, false
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
func exactBoolPointer(data json.RawMessage, key string) *bool {
|
|
795
|
+
var fields map[string]json.RawMessage
|
|
796
|
+
if json.Unmarshal(data, &fields) != nil {
|
|
797
|
+
return nil
|
|
798
|
+
}
|
|
799
|
+
value, ok := exactBool(fields, key)
|
|
800
|
+
if !ok {
|
|
801
|
+
return nil
|
|
802
|
+
}
|
|
803
|
+
return &value
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
func exactBool(fields map[string]json.RawMessage, key string) (bool, bool) {
|
|
807
|
+
raw, ok := fields[key]
|
|
808
|
+
if !ok || strings.TrimSpace(string(raw)) == "null" {
|
|
809
|
+
return false, false
|
|
810
|
+
}
|
|
811
|
+
var value bool
|
|
812
|
+
if json.Unmarshal(raw, &value) != nil {
|
|
813
|
+
return false, false
|
|
814
|
+
}
|
|
815
|
+
return value, true
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
func exactInt(fields map[string]json.RawMessage, key string) (int, bool) {
|
|
819
|
+
raw, ok := fields[key]
|
|
820
|
+
if !ok || strings.TrimSpace(string(raw)) == "null" {
|
|
821
|
+
return 0, false
|
|
822
|
+
}
|
|
823
|
+
var value int
|
|
824
|
+
if json.Unmarshal(raw, &value) != nil {
|
|
825
|
+
return 0, false
|
|
826
|
+
}
|
|
827
|
+
return value, true
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
func exactString(fields map[string]json.RawMessage, key string) (string, bool) {
|
|
831
|
+
raw, ok := fields[key]
|
|
832
|
+
if !ok || strings.TrimSpace(string(raw)) == "null" {
|
|
833
|
+
return "", false
|
|
834
|
+
}
|
|
835
|
+
var value string
|
|
836
|
+
if json.Unmarshal(raw, &value) != nil {
|
|
837
|
+
return "", false
|
|
838
|
+
}
|
|
839
|
+
return value, true
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
func (a Adapter) titleFor(id string) string {
|
|
843
|
+
if id == "" {
|
|
844
|
+
return ""
|
|
845
|
+
}
|
|
846
|
+
index := a.indexPath()
|
|
847
|
+
if index == "" {
|
|
848
|
+
return ""
|
|
849
|
+
}
|
|
850
|
+
return loadTitleIndex(index)[id]
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// titleIndexCache holds the parsed session index; summarizing a whole
|
|
854
|
+
// sessions directory looks up a title per file, so re-reading the index
|
|
855
|
+
// each time would make the scan quadratic
|
|
856
|
+
var titleIndexCache struct {
|
|
857
|
+
mu sync.Mutex
|
|
858
|
+
path string
|
|
859
|
+
size int64
|
|
860
|
+
modTime time.Time
|
|
861
|
+
titles map[string]string
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
func loadTitleIndex(path string) map[string]string {
|
|
865
|
+
info, err := os.Stat(path)
|
|
866
|
+
if err != nil {
|
|
867
|
+
return nil
|
|
868
|
+
}
|
|
869
|
+
titleIndexCache.mu.Lock()
|
|
870
|
+
defer titleIndexCache.mu.Unlock()
|
|
871
|
+
if titleIndexCache.path == path && titleIndexCache.size == info.Size() && titleIndexCache.modTime.Equal(info.ModTime()) {
|
|
872
|
+
return titleIndexCache.titles
|
|
873
|
+
}
|
|
874
|
+
f, err := os.Open(path)
|
|
875
|
+
if err != nil {
|
|
876
|
+
return nil
|
|
877
|
+
}
|
|
878
|
+
defer f.Close()
|
|
879
|
+
|
|
880
|
+
titles := map[string]string{}
|
|
881
|
+
_ = adapter.ReadJSONLines(f, func(data []byte) {
|
|
882
|
+
var row struct {
|
|
883
|
+
ID string `json:"id"`
|
|
884
|
+
ThreadName string `json:"thread_name"`
|
|
885
|
+
}
|
|
886
|
+
if json.Unmarshal(data, &row) == nil && row.ID != "" && row.ThreadName != "" {
|
|
887
|
+
titles[row.ID] = row.ThreadName
|
|
888
|
+
}
|
|
889
|
+
})
|
|
890
|
+
titleIndexCache.path = path
|
|
891
|
+
titleIndexCache.size = info.Size()
|
|
892
|
+
titleIndexCache.modTime = info.ModTime()
|
|
893
|
+
titleIndexCache.titles = titles
|
|
894
|
+
return titles
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
func (a Adapter) indexPath() string {
|
|
898
|
+
if a.IndexPath != "" {
|
|
899
|
+
return a.IndexPath
|
|
900
|
+
}
|
|
901
|
+
if a.Dir != "" {
|
|
902
|
+
dir := filepath.Clean(a.SessionDir())
|
|
903
|
+
candidate := filepath.Join(filepath.Dir(dir), "session_index.jsonl")
|
|
904
|
+
if _, err := os.Stat(candidate); err == nil {
|
|
905
|
+
return candidate
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if candidate := DefaultIndexPath(); candidate != "" {
|
|
909
|
+
if _, err := os.Stat(candidate); err == nil {
|
|
910
|
+
return candidate
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
dir := filepath.Clean(a.SessionDir())
|
|
914
|
+
candidate := filepath.Join(filepath.Dir(dir), "session_index.jsonl")
|
|
915
|
+
if _, err := os.Stat(candidate); err == nil {
|
|
916
|
+
return candidate
|
|
917
|
+
}
|
|
918
|
+
return ""
|
|
919
|
+
}
|