@hybridlabor-api/bdb-synapse 1.0.5 → 1.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/README.md CHANGED
@@ -1,13 +1,27 @@
1
1
  # <img src="assets/logo.svg" alt="" width="30" /> BDB Synapse
2
2
 
3
+ 🌐 **Language / Sprache / Idioma**: **English**
3
4
 
4
5
  [![CI](https://github.com/hybridlabor-api/bdb-synapse/actions/workflows/ci.yml/badge.svg)](https://github.com/hybridlabor-api/bdb-synapse/actions)
5
6
  [![NPM Version](https://img.shields.io/npm/v/@hybridlabor-api/bdb-synapse.svg)](https://www.npmjs.com/package/@hybridlabor-api/bdb-synapse)
7
+ [![Version](https://img.shields.io/badge/version-1.0.5-blue.svg)](https://github.com/hybridlabor-api/bdb-synapse)
8
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
9
 
7
10
  A visualization tool that replays coding-agent sessions on a 3D map of your codebase.
8
11
 
9
12
  **BDB Synapse** is an extended fork of the excellent [cosmtrek/mindwalk](https://github.com/cosmtrek/mindwalk) (MIT License), created to support the BDB ecosystem of agents. It adds support for the Antigravity (agy) CLI alongside the existing supported agents.
10
13
 
14
+ ---
15
+
16
+ ## 📚 Documentation Wiki
17
+
18
+ Comprehensive developer manuals and design specifications are maintained in the project's **OpenWiki** directory:
19
+ - 📐 **[System Architecture](.openwiki/ARCHITECTURE.md):** Go adapters, 3D citymap algorithms, and React/Three.js frontend structure.
20
+ - 📋 **[Development Conventions](.openwiki/CONVENTIONS.md):** Go/TypeScript standards, schema contracts, and contribution rules.
21
+ - 📜 **[Changelog](.openwiki/CHANGELOG.md):** Version highlights and release history.
22
+
23
+ ---
24
+
11
25
  ## The problem
12
26
 
13
27
  A session log records what an agent did, but not how it understood the task:
package/go.mod CHANGED
@@ -1,7 +1,19 @@
1
1
  module github.com/hybridlabor-api/bdb-synapse
2
2
 
3
- go 1.25
3
+ go 1.25.0
4
4
 
5
5
  require github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
6
6
 
7
- require golang.org/x/text v0.14.0 // indirect
7
+ require (
8
+ github.com/dustin/go-humanize v1.0.1 // indirect
9
+ github.com/google/uuid v1.6.0 // indirect
10
+ github.com/mattn/go-isatty v0.0.24 // indirect
11
+ github.com/ncruces/go-strftime v1.0.0 // indirect
12
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
13
+ golang.org/x/sys v0.47.0 // indirect
14
+ golang.org/x/text v0.14.0 // indirect
15
+ modernc.org/libc v1.74.4 // indirect
16
+ modernc.org/mathutil v1.7.1 // indirect
17
+ modernc.org/memory v1.11.0 // indirect
18
+ modernc.org/sqlite v1.57.0 // indirect
19
+ )
package/go.sum CHANGED
@@ -1,6 +1,26 @@
1
1
  github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
2
2
  github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
3
+ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
4
+ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
5
+ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
6
+ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7
+ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
8
+ github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
9
+ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
10
+ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
11
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
12
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
3
13
  github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
4
14
  github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
15
+ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
16
+ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
5
17
  golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
6
18
  golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
19
+ modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
20
+ modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
21
+ modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
22
+ modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
23
+ modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
24
+ modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
25
+ modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
26
+ modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
@@ -1,10 +1,16 @@
1
1
  package agy
2
2
 
3
3
  import (
4
+ "bytes"
4
5
  "encoding/json"
5
6
  "fmt"
6
7
  "os"
7
8
  "path/filepath"
9
+ "regexp"
10
+ "sort"
11
+ "strings"
12
+ "sync"
13
+ "time"
8
14
 
9
15
  "github.com/hybridlabor-api/bdb-synapse/internal/adapter"
10
16
  "github.com/hybridlabor-api/bdb-synapse/internal/model"
@@ -31,11 +37,72 @@ func (a Adapter) SessionDir() string {
31
37
  return DefaultDir()
32
38
  }
33
39
 
40
+ var conversationIDRegex = regexp.MustCompile(`["\\]*conversationId["\\]*:\s*["\\]*([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})`)
41
+
42
+ var (
43
+ childIDsMu sync.Mutex
44
+ childIDsCache = make(map[string]map[string]bool)
45
+ childIDsTime = make(map[string]time.Time)
46
+ )
47
+
48
+ func scanChildIDs(dir string) map[string]bool {
49
+ childIDs := make(map[string]bool)
50
+ entries, err := os.ReadDir(dir)
51
+ if err != nil {
52
+ return childIDs
53
+ }
54
+ for _, entry := range entries {
55
+ if !entry.IsDir() {
56
+ continue
57
+ }
58
+ sid := entry.Name()
59
+ path := filepath.Join(dir, sid, ".system_generated", "logs", "transcript.jsonl")
60
+ f, err := os.Open(path)
61
+ if err != nil {
62
+ continue
63
+ }
64
+ adapter.ReadJSONLines(f, func(data []byte) {
65
+ if bytes.Contains(data, []byte("conversationId")) {
66
+ matches := conversationIDRegex.FindAllSubmatch(data, -1)
67
+ for _, m := range matches {
68
+ if len(m) > 1 {
69
+ cid := string(m[1])
70
+ if cid != sid {
71
+ childIDs[cid] = true
72
+ }
73
+ }
74
+ }
75
+ }
76
+ })
77
+ f.Close()
78
+ }
79
+ return childIDs
80
+ }
81
+
82
+ func getChildIDs(dir string) map[string]bool {
83
+ childIDsMu.Lock()
84
+ defer childIDsMu.Unlock()
85
+
86
+ if ids, ok := childIDsCache[dir]; ok {
87
+ if time.Since(childIDsTime[dir]) < 5*time.Second {
88
+ return ids
89
+ }
90
+ }
91
+
92
+ ids := scanChildIDs(dir)
93
+ childIDsCache[dir] = ids
94
+ childIDsTime[dir] = time.Now()
95
+ return ids
96
+ }
97
+
34
98
  func (a Adapter) ListSessions() ([]model.SessionMeta, error) {
35
99
  dir := a.SessionDir()
36
100
  if info, err := os.Stat(dir); err != nil || !info.IsDir() {
37
101
  return nil, nil
38
102
  }
103
+
104
+ childIDs := getChildIDs(dir)
105
+
39
106
  var metas []model.SessionMeta
40
107
  entries, err := os.ReadDir(dir)
41
108
  if err != nil {
@@ -45,13 +112,20 @@ func (a Adapter) ListSessions() ([]model.SessionMeta, error) {
45
112
  if !entry.IsDir() {
46
113
  continue
47
114
  }
48
- path := filepath.Join(dir, entry.Name(), ".system_generated", "logs", "transcript.jsonl")
115
+ sid := entry.Name()
116
+ path := filepath.Join(dir, sid, ".system_generated", "logs", "transcript.jsonl")
49
117
  if _, err := os.Stat(path); err == nil {
50
118
  if meta, err := a.Summarize(path); err == nil {
119
+ if childIDs[sid] {
120
+ meta.Auxiliary = true
121
+ }
51
122
  metas = append(metas, meta)
52
123
  }
53
124
  }
54
125
  }
126
+ sort.Slice(metas, func(i, j int) bool {
127
+ return metas[i].EndedAt > metas[j].EndedAt
128
+ })
55
129
  return metas, nil
56
130
  }
57
131
 
@@ -60,16 +134,130 @@ type ToolCall struct {
60
134
  Args map[string]string `json:"args"`
61
135
  }
62
136
 
137
+ type StepState struct {
138
+ Cwd string `json:"cwd"`
139
+ WorkspaceUris []string `json:"workspace_uris"`
140
+ }
141
+
63
142
  type Step struct {
64
- StepIndex int `json:"step_index"`
65
- Source string `json:"source"`
66
- Type string `json:"type"`
67
- Status string `json:"status"`
68
- CreatedAt string `json:"created_at"`
69
- Content string `json:"content"`
70
- Thinking string `json:"thinking"`
71
- ToolCalls []ToolCall `json:"tool_calls"`
72
- ExitCode *int `json:"exit_code"`
143
+ StepIndex int `json:"step_index"`
144
+ Source string `json:"source"`
145
+ Type string `json:"type"`
146
+ Status string `json:"status"`
147
+ CreatedAt string `json:"created_at"`
148
+ Content string `json:"content"`
149
+ Thinking string `json:"thinking"`
150
+ ToolCalls []ToolCall `json:"tool_calls"`
151
+ ExitCode *int `json:"exit_code"`
152
+ Cwd string `json:"cwd"`
153
+ WorkspaceUris []string `json:"workspace_uris"`
154
+ State StepState `json:"state"`
155
+ }
156
+
157
+ func cleanWorkspacePath(p string) string {
158
+ if p == "" {
159
+ return ""
160
+ }
161
+ p = strings.Trim(p, "\"'\t \n\r")
162
+ p = strings.TrimPrefix(p, "file://")
163
+ p = filepath.Clean(p)
164
+ if p == "." || strings.Contains(p, "/.gemini/") {
165
+ return ""
166
+ }
167
+ return p
168
+ }
169
+
170
+ func extractWorkspaceFromToolCalls(toolCalls []ToolCall) string {
171
+ for _, tc := range toolCalls {
172
+ for _, k := range []string{"Cwd", "SearchDirectory", "DirectoryPath"} {
173
+ if v, ok := tc.Args[k]; ok {
174
+ if p := cleanWorkspacePath(v); p != "" && strings.HasPrefix(p, "/") {
175
+ return p
176
+ }
177
+ }
178
+ }
179
+ }
180
+ for _, tc := range toolCalls {
181
+ for _, k := range []string{"AbsolutePath", "TargetFile", "SearchPath"} {
182
+ if v, ok := tc.Args[k]; ok {
183
+ if p := cleanWorkspacePath(v); p != "" && strings.HasPrefix(p, "/") {
184
+ return filepath.Dir(p)
185
+ }
186
+ }
187
+ }
188
+ }
189
+ return ""
190
+ }
191
+
192
+ func (s Step) GetWorkspace() string {
193
+ if len(s.State.WorkspaceUris) > 0 && s.State.WorkspaceUris[0] != "" {
194
+ if p := cleanWorkspacePath(s.State.WorkspaceUris[0]); p != "" {
195
+ return p
196
+ }
197
+ }
198
+ if s.State.Cwd != "" {
199
+ if p := cleanWorkspacePath(s.State.Cwd); p != "" {
200
+ return p
201
+ }
202
+ }
203
+ if len(s.WorkspaceUris) > 0 && s.WorkspaceUris[0] != "" {
204
+ if p := cleanWorkspacePath(s.WorkspaceUris[0]); p != "" {
205
+ return p
206
+ }
207
+ }
208
+ if s.Cwd != "" {
209
+ if p := cleanWorkspacePath(s.Cwd); p != "" {
210
+ return p
211
+ }
212
+ }
213
+ if len(s.ToolCalls) > 0 {
214
+ if p := extractWorkspaceFromToolCalls(s.ToolCalls); p != "" {
215
+ return p
216
+ }
217
+ }
218
+ return ""
219
+ }
220
+
221
+ func truncateTitle(content string, length int) string {
222
+ runes := []rune(content)
223
+ if len(runes) > length {
224
+ return string(runes[:length-3]) + "..."
225
+ }
226
+ return content
227
+ }
228
+
229
+ var (
230
+ metadataRegex = regexp.MustCompile(`(?s)<ADDITIONAL_METADATA>.*?</ADDITIONAL_METADATA>`)
231
+ settingsRegex = regexp.MustCompile(`(?s)<USER_SETTINGS_CHANGE>.*?</USER_SETTINGS_CHANGE>`)
232
+ xmlTagRegex = regexp.MustCompile(`(?s)<[^>]+>`)
233
+ )
234
+
235
+ func cleanPromptTitle(raw string) string {
236
+ cleaned := metadataRegex.ReplaceAllString(raw, "")
237
+ cleaned = settingsRegex.ReplaceAllString(cleaned, "")
238
+ cleaned = xmlTagRegex.ReplaceAllString(cleaned, "")
239
+ lines := strings.Split(cleaned, "\n")
240
+ var validLines []string
241
+ for _, l := range lines {
242
+ l = strings.TrimSpace(l)
243
+ if l == "" {
244
+ continue
245
+ }
246
+ if (strings.HasPrefix(l, "/var/folders") || strings.HasPrefix(l, "/Users/")) &&
247
+ (strings.HasSuffix(l, ".png") || strings.HasSuffix(l, ".jpg") || strings.HasSuffix(l, ".jpeg") || strings.HasSuffix(l, ".webp") || strings.Contains(l, "/TemporaryItems/")) {
248
+ continue
249
+ }
250
+ validLines = append(validLines, l)
251
+ }
252
+ cleaned = strings.Join(validLines, " ")
253
+ cleaned = strings.TrimSpace(cleaned)
254
+ if cleaned == "" {
255
+ cleaned = strings.TrimSpace(xmlTagRegex.ReplaceAllString(raw, ""))
256
+ }
257
+ if cleaned == "" {
258
+ return "Antigravity Session"
259
+ }
260
+ return truncateTitle(cleaned, 60)
73
261
  }
74
262
 
75
263
  func (a Adapter) SummaryInputs(path string) []string {
@@ -85,15 +273,25 @@ func (a Adapter) Summarize(path string) (model.SessionMeta, error) {
85
273
 
86
274
  id := filepath.Base(filepath.Dir(filepath.Dir(filepath.Dir(path))))
87
275
  meta := model.SessionMeta{
276
+ Key: adapter.SessionKey(a.Harness(), path),
88
277
  ID: id,
89
278
  Harness: "agy",
90
279
  Path: path,
91
280
  Title: id,
92
281
  }
93
282
 
283
+ var titleSet bool
284
+
94
285
  adapter.ReadJSONLines(f, func(data []byte) {
95
286
  var step Step
96
287
  if json.Unmarshal(data, &step) == nil {
288
+ if meta.Cwd == "" {
289
+ meta.Cwd = step.GetWorkspace()
290
+ }
291
+ if !titleSet && step.Source == "USER_EXPLICIT" && step.Type == "USER_INPUT" && step.Content != "" {
292
+ meta.Title = cleanPromptTitle(step.Content)
293
+ titleSet = true
294
+ }
97
295
  if meta.StartedAt == "" && step.CreatedAt != "" {
98
296
  meta.StartedAt = step.CreatedAt
99
297
  }
@@ -108,6 +306,19 @@ func (a Adapter) Summarize(path string) (model.SessionMeta, error) {
108
306
  }
109
307
  }
110
308
  })
309
+
310
+ if meta.Cwd == "" {
311
+ if home, err := os.UserHomeDir(); err == nil && home != "" {
312
+ meta.Cwd = home
313
+ }
314
+ }
315
+
316
+ brainDir := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(path))))
317
+ childIDs := getChildIDs(brainDir)
318
+ if childIDs[id] || meta.UserTurns == 0 {
319
+ meta.Auxiliary = true
320
+ }
321
+
111
322
  return meta, nil
112
323
  }
113
324
 
@@ -131,12 +342,22 @@ func (a Adapter) Parse(path string) (*model.Trace, error) {
131
342
  Marks: []model.Mark{},
132
343
  }
133
344
 
345
+ var titleSet bool
346
+
134
347
  adapter.ReadJSONLines(f, func(data []byte) {
135
348
  var step Step
136
349
  if json.Unmarshal(data, &step) != nil {
137
350
  return
138
351
  }
139
352
 
353
+ if trace.Session.Cwd == "" {
354
+ trace.Session.Cwd = step.GetWorkspace()
355
+ }
356
+ if !titleSet && step.Source == "USER_EXPLICIT" && step.Type == "USER_INPUT" && step.Content != "" {
357
+ trace.Session.Title = cleanPromptTitle(step.Content)
358
+ titleSet = true
359
+ }
360
+
140
361
  if trace.Session.StartedAt == "" && step.CreatedAt != "" {
141
362
  trace.Session.StartedAt = step.CreatedAt
142
363
  }
@@ -184,6 +405,12 @@ func (a Adapter) Parse(path string) (*model.Trace, error) {
184
405
  }
185
406
  })
186
407
 
408
+ if trace.Session.Cwd == "" {
409
+ if home, err := os.UserHomeDir(); err == nil && home != "" {
410
+ trace.Session.Cwd = home
411
+ }
412
+ }
413
+
187
414
  for i := range trace.Events {
188
415
  trace.Events[i].Seq = i
189
416
  }
@@ -0,0 +1,304 @@
1
+ package opencode
2
+
3
+ import (
4
+ "database/sql"
5
+ "encoding/json"
6
+ "os"
7
+ "path/filepath"
8
+ "sort"
9
+ "time"
10
+
11
+ "github.com/hybridlabor-api/bdb-synapse/internal/adapter"
12
+ "github.com/hybridlabor-api/bdb-synapse/internal/model"
13
+ _ "modernc.org/sqlite"
14
+ )
15
+
16
+ type Adapter struct {
17
+ Dir string
18
+ }
19
+
20
+ func DefaultDir() string {
21
+ home, err := os.UserHomeDir()
22
+ if err != nil {
23
+ return ""
24
+ }
25
+ return filepath.Join(home, ".local", "share", "opencode")
26
+ }
27
+
28
+ func (a Adapter) Harness() string { return "opencode" }
29
+
30
+ func (a Adapter) SessionDir() string {
31
+ if a.Dir != "" {
32
+ return a.Dir
33
+ }
34
+ return DefaultDir()
35
+ }
36
+
37
+ func (a Adapter) dbPath() string {
38
+ return filepath.Join(a.SessionDir(), "opencode.db")
39
+ }
40
+
41
+ func (a Adapter) openDB() (*sql.DB, error) {
42
+ dbPath := a.dbPath()
43
+ if _, err := os.Stat(dbPath); err != nil {
44
+ return nil, err
45
+ }
46
+ return sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
47
+ }
48
+
49
+ func (a Adapter) ListSessions() ([]model.SessionMeta, error) {
50
+ db, err := a.openDB()
51
+ if err != nil {
52
+ return nil, nil
53
+ }
54
+ defer db.Close()
55
+
56
+ rows, err := db.Query(`
57
+ SELECT id, title, directory, parent_id, time_created, time_updated
58
+ FROM session
59
+ ORDER BY time_updated DESC
60
+ `)
61
+ if err != nil {
62
+ return nil, err
63
+ }
64
+ defer rows.Close()
65
+
66
+ var metas []model.SessionMeta
67
+ for rows.Next() {
68
+ var (
69
+ id string
70
+ title string
71
+ directory string
72
+ parentID sql.NullString
73
+ timeCreated int64
74
+ timeUpdated int64
75
+ )
76
+ if err := rows.Scan(&id, &title, &directory, &parentID, &timeCreated, &timeUpdated); err != nil {
77
+ continue
78
+ }
79
+
80
+ startedAt := time.UnixMilli(timeCreated).UTC().Format(time.RFC3339)
81
+ endedAt := time.UnixMilli(timeUpdated).UTC().Format(time.RFC3339)
82
+ sessionPath := filepath.Join(a.SessionDir(), id)
83
+
84
+ meta := model.SessionMeta{
85
+ Key: adapter.SessionKey(a.Harness(), sessionPath),
86
+ ID: id,
87
+ Harness: "opencode",
88
+ Path: sessionPath,
89
+ Title: title,
90
+ Cwd: directory,
91
+ StartedAt: startedAt,
92
+ EndedAt: endedAt,
93
+ }
94
+
95
+ if parentID.Valid && parentID.String != "" {
96
+ meta.Auxiliary = true
97
+ }
98
+
99
+ var eventCount, userTurns int
100
+ _ = db.QueryRow(`
101
+ SELECT
102
+ COUNT(CASE WHEN data LIKE '%"type":"tool"%' THEN 1 END),
103
+ COUNT(CASE WHEN data LIKE '%"type":"text"%' THEN 1 END)
104
+ FROM part
105
+ WHERE session_id = ?
106
+ `, id).Scan(&eventCount, &userTurns)
107
+
108
+ meta.EventCount = eventCount
109
+ meta.UserTurns = userTurns
110
+
111
+ metas = append(metas, meta)
112
+ }
113
+
114
+ sort.Slice(metas, func(i, j int) bool {
115
+ return metas[i].EndedAt > metas[j].EndedAt
116
+ })
117
+ return metas, nil
118
+ }
119
+
120
+ func (a Adapter) SummaryInputs(path string) []string {
121
+ return []string{a.dbPath()}
122
+ }
123
+
124
+ func (a Adapter) Summarize(path string) (model.SessionMeta, error) {
125
+ id := filepath.Base(path)
126
+ db, err := a.openDB()
127
+ if err != nil {
128
+ return model.SessionMeta{}, err
129
+ }
130
+ defer db.Close()
131
+
132
+ var (
133
+ title string
134
+ directory string
135
+ parentID sql.NullString
136
+ timeCreated int64
137
+ timeUpdated int64
138
+ )
139
+ err = db.QueryRow(`
140
+ SELECT title, directory, parent_id, time_created, time_updated
141
+ FROM session
142
+ WHERE id = ?
143
+ `, id).Scan(&title, &directory, &parentID, &timeCreated, &timeUpdated)
144
+ if err != nil {
145
+ return model.SessionMeta{}, err
146
+ }
147
+
148
+ startedAt := time.UnixMilli(timeCreated).UTC().Format(time.RFC3339)
149
+ endedAt := time.UnixMilli(timeUpdated).UTC().Format(time.RFC3339)
150
+
151
+ meta := model.SessionMeta{
152
+ Key: adapter.SessionKey(a.Harness(), path),
153
+ ID: id,
154
+ Harness: "opencode",
155
+ Path: path,
156
+ Title: title,
157
+ Cwd: directory,
158
+ StartedAt: startedAt,
159
+ EndedAt: endedAt,
160
+ }
161
+
162
+ if parentID.Valid && parentID.String != "" {
163
+ meta.Auxiliary = true
164
+ }
165
+
166
+ var eventCount, userTurns int
167
+ _ = db.QueryRow(`
168
+ SELECT
169
+ COUNT(CASE WHEN data LIKE '%"type":"tool"%' THEN 1 END),
170
+ COUNT(CASE WHEN data LIKE '%"type":"text"%' THEN 1 END)
171
+ FROM part
172
+ WHERE session_id = ?
173
+ `, id).Scan(&eventCount, &userTurns)
174
+
175
+ meta.EventCount = eventCount
176
+ meta.UserTurns = userTurns
177
+ return meta, nil
178
+ }
179
+
180
+ type toolPart struct {
181
+ Type string `json:"type"`
182
+ Tool string `json:"tool"`
183
+ CallID string `json:"callID"`
184
+ State struct {
185
+ Status string `json:"status"`
186
+ Input map[string]any `json:"input"`
187
+ Output any `json:"output"`
188
+ } `json:"state"`
189
+ Title string `json:"title"`
190
+ Time struct {
191
+ Start int64 `json:"start"`
192
+ End int64 `json:"end"`
193
+ } `json:"time"`
194
+ }
195
+
196
+ type textPart struct {
197
+ Type string `json:"type"`
198
+ Text string `json:"text"`
199
+ }
200
+
201
+ func (a Adapter) Parse(path string) (*model.Trace, error) {
202
+ id := filepath.Base(path)
203
+ db, err := a.openDB()
204
+ if err != nil {
205
+ return nil, err
206
+ }
207
+ defer db.Close()
208
+
209
+ var (
210
+ title string
211
+ directory string
212
+ timeCreated int64
213
+ timeUpdated int64
214
+ )
215
+ err = db.QueryRow(`
216
+ SELECT title, directory, time_created, time_updated
217
+ FROM session
218
+ WHERE id = ?
219
+ `, id).Scan(&title, &directory, &timeCreated, &timeUpdated)
220
+ if err != nil {
221
+ return nil, err
222
+ }
223
+
224
+ startedAt := time.UnixMilli(timeCreated).UTC().Format(time.RFC3339)
225
+ endedAt := time.UnixMilli(timeUpdated).UTC().Format(time.RFC3339)
226
+
227
+ trace := &model.Trace{
228
+ Version: 1,
229
+ Session: model.TraceSession{
230
+ ID: id,
231
+ Harness: "opencode",
232
+ Path: path,
233
+ Title: title,
234
+ Cwd: directory,
235
+ StartedAt: startedAt,
236
+ EndedAt: endedAt,
237
+ },
238
+ Events: []model.Event{},
239
+ Marks: []model.Mark{},
240
+ }
241
+
242
+ rows, err := db.Query(`
243
+ SELECT data, time_created
244
+ FROM part
245
+ WHERE session_id = ?
246
+ ORDER BY time_created ASC, id ASC
247
+ `, id)
248
+ if err != nil {
249
+ return trace, nil
250
+ }
251
+ defer rows.Close()
252
+
253
+ for rows.Next() {
254
+ var rawData string
255
+ var partTime int64
256
+ if err := rows.Scan(&rawData, &partTime); err != nil {
257
+ continue
258
+ }
259
+
260
+ var tp toolPart
261
+ if err := json.Unmarshal([]byte(rawData), &tp); err == nil && tp.Type == "tool" {
262
+ ts := time.UnixMilli(partTime).UTC().Format(time.RFC3339)
263
+ call := adapter.ToolCall{
264
+ ID: tp.CallID,
265
+ Name: tp.Tool,
266
+ Input: tp.State.Input,
267
+ Timestamp: ts,
268
+ }
269
+ outStr := ""
270
+ if s, ok := tp.State.Output.(string); ok {
271
+ outStr = s
272
+ } else if tp.State.Output != nil {
273
+ b, _ := json.Marshal(tp.State.Output)
274
+ outStr = string(b)
275
+ }
276
+ result := adapter.ToolResult{
277
+ Content: outStr,
278
+ OutcomeKnown: tp.State.Status != "",
279
+ IsError: tp.State.Status == "error",
280
+ }
281
+ event := adapter.BuildEvent(trace, call, result)
282
+ trace.Events = append(trace.Events, event)
283
+ continue
284
+ }
285
+
286
+ var txt textPart
287
+ if err := json.Unmarshal([]byte(rawData), &txt); err == nil && txt.Type == "text" && txt.Text != "" {
288
+ if !adapter.InjectedUserMessage(txt.Text) {
289
+ trace.Marks = append(trace.Marks, model.Mark{
290
+ Seq: len(trace.Events),
291
+ Type: "user-message",
292
+ Note: adapter.UserMessageNote(txt.Text),
293
+ })
294
+ }
295
+ }
296
+ }
297
+
298
+ for i := range trace.Events {
299
+ trace.Events[i].Seq = i
300
+ }
301
+ trace.Session.EventCount = len(trace.Events)
302
+ trace.Stats = model.ComputeStats(trace, 0, model.ObservabilityExact)
303
+ return trace, nil
304
+ }