@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 +14 -0
- package/go.mod +14 -2
- package/go.sum +20 -0
- package/internal/adapter/agy/adapter.go +237 -10
- package/internal/adapter/opencode/adapter.go +304 -0
- package/internal/server/codex_index_test.go +1 -1
- package/internal/server/server.go +29 -3
- package/internal/server/server_test.go +13 -13
- package/internal/server/static/assets/index-ndrpq0ER.js +3 -0
- package/internal/server/static/assets/index-ndrpq0ER.js.map +1 -0
- package/internal/server/static/assets/react-gcHzaSmV.js.map +1 -0
- package/internal/server/static/assets/three-DnGjZfD1.js.map +1 -0
- package/internal/server/static/index.html +2 -2
- package/package.json +1 -1
- package/web/src/state/store.ts +1 -1
- package/internal/server/static/assets/index-C_adLrJr.js +0 -3
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
|
[](https://github.com/hybridlabor-api/bdb-synapse/actions)
|
|
5
6
|
[](https://www.npmjs.com/package/@hybridlabor-api/bdb-synapse)
|
|
7
|
+
[](https://github.com/hybridlabor-api/bdb-synapse)
|
|
8
|
+
[](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
|
|
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
|
-
|
|
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
|
|
65
|
-
Source
|
|
66
|
-
Type
|
|
67
|
-
Status
|
|
68
|
-
CreatedAt
|
|
69
|
-
Content
|
|
70
|
-
Thinking
|
|
71
|
-
ToolCalls
|
|
72
|
-
ExitCode
|
|
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
|
+
}
|