@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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/synapse +0 -0
  4. package/cmd/rubriceval/main.go +308 -0
  5. package/cmd/synapse/main.go +280 -0
  6. package/cmd/synapse/main_test.go +16 -0
  7. package/go.mod +7 -0
  8. package/go.sum +6 -0
  9. package/internal/adapter/adapter.go +1117 -0
  10. package/internal/adapter/adapter_test.go +518 -0
  11. package/internal/adapter/agy/adapter.go +193 -0
  12. package/internal/adapter/claudecode/adapter.go +415 -0
  13. package/internal/adapter/claudecode/adapter_test.go +260 -0
  14. package/internal/adapter/claudecode/agents.go +387 -0
  15. package/internal/adapter/claudecode/agents_test.go +480 -0
  16. package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
  17. package/internal/adapter/codex/adapter.go +919 -0
  18. package/internal/adapter/codex/adapter_test.go +920 -0
  19. package/internal/adapter/codex/agents.go +401 -0
  20. package/internal/adapter/codex/agents_test.go +610 -0
  21. package/internal/adapter/codex/summary_inputs_test.go +20 -0
  22. package/internal/adapter/pi/adapter.go +483 -0
  23. package/internal/adapter/pi/adapter_test.go +517 -0
  24. package/internal/citymap/builder.go +1124 -0
  25. package/internal/citymap/builder_test.go +818 -0
  26. package/internal/judge/cache.go +180 -0
  27. package/internal/judge/cli.go +240 -0
  28. package/internal/judge/cli_test.go +68 -0
  29. package/internal/judge/fresh_summary_test.go +37 -0
  30. package/internal/judge/input.go +233 -0
  31. package/internal/judge/judge.go +416 -0
  32. package/internal/judge/judge_test.go +288 -0
  33. package/internal/judge/prompt.go +129 -0
  34. package/internal/judge/rubric.go +275 -0
  35. package/internal/judge/rubric_test.go +642 -0
  36. package/internal/model/agent.go +63 -0
  37. package/internal/model/agent_schema_test.go +86 -0
  38. package/internal/model/agent_test.go +64 -0
  39. package/internal/model/model.go +175 -0
  40. package/internal/model/report.go +166 -0
  41. package/internal/model/stats.go +151 -0
  42. package/internal/model/stats_test.go +89 -0
  43. package/internal/model/trace_schema_test.go +67 -0
  44. package/internal/server/analyze.go +286 -0
  45. package/internal/server/analyze_test.go +297 -0
  46. package/internal/server/codex_index_test.go +40 -0
  47. package/internal/server/hardening_test.go +147 -0
  48. package/internal/server/reportindex.go +91 -0
  49. package/internal/server/reportindex_test.go +116 -0
  50. package/internal/server/server.go +1099 -0
  51. package/internal/server/server_test.go +1389 -0
  52. package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
  53. package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
  54. package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
  55. package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
  56. package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
  57. package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
  58. package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
  59. package/internal/server/static/assets/index-C_adLrJr.js +3 -0
  60. package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
  61. package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  62. package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  63. package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
  64. package/internal/server/static/index.html +26 -0
  65. package/internal/server/tracestore.go +173 -0
  66. package/internal/server/tracestore_test.go +51 -0
  67. package/internal/textutil/truncate.go +30 -0
  68. package/internal/textutil/truncate_test.go +39 -0
  69. package/package.json +35 -0
  70. package/web/e2e/agent-lens.spec.ts +688 -0
  71. package/web/index.html +23 -0
  72. package/web/package-lock.json +1933 -0
  73. package/web/package.json +33 -0
  74. package/web/playwright.config.ts +24 -0
  75. package/web/src/App.tsx +876 -0
  76. package/web/src/api/client.ts +74 -0
  77. package/web/src/main.tsx +12 -0
  78. package/web/src/playback/recorder.ts +160 -0
  79. package/web/src/playback/reducer.ts +91 -0
  80. package/web/src/scene/CityScene.tsx +638 -0
  81. package/web/src/scene/TreeScene.tsx +656 -0
  82. package/web/src/scene/dirLabels.ts +145 -0
  83. package/web/src/scene/sceneUtils.ts +144 -0
  84. package/web/src/scene/textures.ts +60 -0
  85. package/web/src/scene/trail.ts +79 -0
  86. package/web/src/scene/treeLayout.ts +169 -0
  87. package/web/src/state/filters.ts +40 -0
  88. package/web/src/state/store.ts +83 -0
  89. package/web/src/styles.css +2565 -0
  90. package/web/src/types.ts +315 -0
  91. package/web/src/ui/AgentsPanel.tsx +376 -0
  92. package/web/src/ui/Dock.tsx +104 -0
  93. package/web/src/ui/Hud.tsx +335 -0
  94. package/web/src/ui/Inspector.tsx +107 -0
  95. package/web/src/ui/LogoMark.tsx +38 -0
  96. package/web/src/ui/ReportPanel.tsx +491 -0
  97. package/web/src/ui/SessionRail.tsx +316 -0
  98. package/web/src/ui/Timeline.tsx +458 -0
  99. package/web/src/ui/ViewPanel.tsx +45 -0
  100. package/web/src/ui/shortcuts.ts +4 -0
  101. package/web/tsconfig.json +21 -0
  102. 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
+ }