@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,1117 @@
1
+ package adapter
2
+
3
+ import (
4
+ "bufio"
5
+ "bytes"
6
+ "crypto/sha256"
7
+ "encoding/json"
8
+ "fmt"
9
+ "io"
10
+ "os"
11
+ "path/filepath"
12
+ "regexp"
13
+ "sort"
14
+ "strings"
15
+
16
+ "github.com/hybridlabor-api/bdb-synapse/internal/model"
17
+ "github.com/hybridlabor-api/bdb-synapse/internal/textutil"
18
+ )
19
+
20
+ type Source interface {
21
+ Harness() string
22
+ SessionDir() string
23
+ ListSessions() ([]model.SessionMeta, error)
24
+ Summarize(path string) (model.SessionMeta, error)
25
+ Parse(path string) (*model.Trace, error)
26
+ }
27
+
28
+ type AgentGraphSource interface {
29
+ AgentGraphInputs(root model.SessionMeta, catalog []model.SessionMeta) ([]string, error)
30
+ BuildAgentGraph(root model.SessionMeta, catalog []model.SessionMeta) (*model.AgentGraph, error)
31
+ }
32
+
33
+ // SummarySidecarSource lists companion files whose contents feed Summarize
34
+ // for a session path. Which files a harness keeps beside its session logs is
35
+ // adapter knowledge; callers only fingerprint the returned paths to decide
36
+ // whether a cached summary is still valid.
37
+ type SummarySidecarSource interface {
38
+ SummaryInputs(path string) []string
39
+ }
40
+
41
+ type ToolCall struct {
42
+ ID string
43
+ Name string
44
+ Input map[string]any
45
+ Timestamp string
46
+ }
47
+
48
+ type ToolResult struct {
49
+ Content string
50
+ IsError bool
51
+ OutcomeKnown bool
52
+ }
53
+
54
+ // SessionKey identifies one session file independently of the harness-level
55
+ // session ID. Codex resume rollouts can share an ID across multiple files, so
56
+ // IDs are display metadata rather than safe routing or cache keys.
57
+ func SessionKey(harness, path string) string {
58
+ if abs, err := filepath.Abs(path); err == nil {
59
+ path = abs
60
+ }
61
+ path = filepath.Clean(path)
62
+ sum := sha256.Sum256([]byte(harness + "\x00" + path))
63
+ return fmt.Sprintf("%s-%x", harness, sum[:12])
64
+ }
65
+
66
+ func AgentNodeID(harness, rootSessionKey, actorIdentity string) string {
67
+ sum := sha256.Sum256([]byte(harness + "\x00" + rootSessionKey + "\x00" + actorIdentity))
68
+ return fmt.Sprintf("agt_%x", sum[:12])
69
+ }
70
+
71
+ func AgentInstructionPreview(text string) string {
72
+ text = strings.Join(strings.Fields(text), " ")
73
+ return textutil.TruncateRunes(text, 240, "…")
74
+ }
75
+
76
+ // OrderAgentNodesPreorder keeps every parent's complete subtree contiguous.
77
+ // Adapter-specific graph builders provide parent links and launch sequence;
78
+ // this helper owns only their shared deterministic presentation order.
79
+ func OrderAgentNodesPreorder(nodes []model.AgentNode) []model.AgentNode {
80
+ known := make(map[string]bool, len(nodes))
81
+ for _, node := range nodes {
82
+ known[node.ID] = true
83
+ }
84
+
85
+ children := make(map[string][]model.AgentNode)
86
+ roots := make([]model.AgentNode, 0, 1)
87
+ for _, node := range nodes {
88
+ if node.Kind == model.AgentKindMain || node.ParentID == "" || !known[node.ParentID] {
89
+ roots = append(roots, node)
90
+ continue
91
+ }
92
+ children[node.ParentID] = append(children[node.ParentID], node)
93
+ }
94
+ sortAgentNodeSiblings(roots)
95
+ for parentID := range children {
96
+ sortAgentNodeSiblings(children[parentID])
97
+ }
98
+
99
+ ordered := make([]model.AgentNode, 0, len(nodes))
100
+ visited := make(map[string]bool, len(nodes))
101
+ var visit func(model.AgentNode)
102
+ visit = func(node model.AgentNode) {
103
+ if visited[node.ID] {
104
+ return
105
+ }
106
+ visited[node.ID] = true
107
+ ordered = append(ordered, node)
108
+ for _, child := range children[node.ID] {
109
+ visit(child)
110
+ }
111
+ }
112
+ for _, root := range roots {
113
+ visit(root)
114
+ }
115
+
116
+ remaining := append([]model.AgentNode(nil), nodes...)
117
+ sortAgentNodeSiblings(remaining)
118
+ for _, node := range remaining {
119
+ visit(node)
120
+ }
121
+ return ordered
122
+ }
123
+
124
+ func sortAgentNodeSiblings(nodes []model.AgentNode) {
125
+ sort.Slice(nodes, func(i, j int) bool {
126
+ left, right := nodes[i], nodes[j]
127
+ if left.Kind == model.AgentKindMain || right.Kind == model.AgentKindMain {
128
+ return left.Kind == model.AgentKindMain && right.Kind != model.AgentKindMain
129
+ }
130
+ if (left.LaunchSeq == nil) != (right.LaunchSeq == nil) {
131
+ return left.LaunchSeq != nil
132
+ }
133
+ if left.LaunchSeq != nil && *left.LaunchSeq != *right.LaunchSeq {
134
+ return *left.LaunchSeq < *right.LaunchSeq
135
+ }
136
+ if left.Label != right.Label {
137
+ return left.Label < right.Label
138
+ }
139
+ return left.ID < right.ID
140
+ })
141
+ }
142
+
143
+ // userMessageNoteLimit bounds the text stored on user-message marks; the note
144
+ // exists so downstream analysis can see the task wording, not to mirror the
145
+ // full transcript.
146
+ const userMessageNoteLimit = 2000
147
+
148
+ // UserMessageNote normalizes user-message text for Mark.Note: trimmed and
149
+ // truncated to a fixed rune budget, ellipsis marker included — the schema
150
+ // promises the note never exceeds userMessageNoteLimit runes in total.
151
+ func UserMessageNote(text string) string {
152
+ text = strings.TrimSpace(text)
153
+ return textutil.TruncateRunes(text, userMessageNoteLimit, "…")
154
+ }
155
+
156
+ // InjectedUserMessage recognizes harness-injected text recorded as a user
157
+ // message but not written by the user. Adapters drop these before they
158
+ // become user-message marks — they would inflate user-turn stats, clutter
159
+ // the timeline, and pose as the task in judge input.
160
+ //
161
+ // Injected wrappers observed in real logs (<system-reminder>,
162
+ // <command-name>, <local-command-caveat>, <environment_context>,
163
+ // <recommended_plugins>, <turn_aborted>, …) are complete markup envelopes:
164
+ // the message starts with a tag and ends with one. That shape — rather than
165
+ // a tag whitelist that new harness versions would outgrow, or a bare "<"
166
+ // prefix that would swallow real tasks pasting HTML/JSX followed by a
167
+ // question — is what marks a message as injected. Codex's AGENTS.md
168
+ // instructions are the one non-markup injection.
169
+ func InjectedUserMessage(text string) bool {
170
+ text = strings.TrimSpace(text)
171
+ if strings.HasPrefix(text, "# AGENTS.md instructions") {
172
+ return true
173
+ }
174
+ return strings.HasPrefix(text, "<") && strings.HasSuffix(text, ">")
175
+ }
176
+
177
+ func ReadJSONLines(r io.Reader, visit func([]byte)) error {
178
+ reader := bufio.NewReaderSize(r, 64*1024)
179
+ for {
180
+ line, err := reader.ReadBytes('\n')
181
+ if len(line) > 0 {
182
+ line = bytes.TrimRight(line, "\r\n")
183
+ if len(line) > 0 {
184
+ visit(line)
185
+ }
186
+ }
187
+ if err != nil {
188
+ if err == io.EOF {
189
+ return nil
190
+ }
191
+ return err
192
+ }
193
+ }
194
+ }
195
+
196
+ func BuildEvent(trace *model.Trace, call ToolCall, result ToolResult) model.Event {
197
+ action := actionFor(call.Name, call.Input, result.Content)
198
+ targets, outside := targetsFor(trace.Session.Cwd, call.Name, call.Input, result.Content)
199
+ if targets == nil {
200
+ targets = []model.Target{}
201
+ }
202
+ return model.Event{
203
+ Seq: len(trace.Events),
204
+ Timestamp: call.Timestamp,
205
+ Tool: call.Name,
206
+ Action: action,
207
+ Targets: targets,
208
+ Outside: outside,
209
+ ResultBytes: len(result.Content),
210
+ IsError: result.IsError,
211
+ OutcomeKnown: result.OutcomeKnown,
212
+ Summary: SummarizeTool(call.Name, call.Input, targets, outside, result.IsError),
213
+ }
214
+ }
215
+
216
+ func ContentToString(v any) string {
217
+ switch x := v.(type) {
218
+ case nil:
219
+ return ""
220
+ case string:
221
+ return x
222
+ case []any:
223
+ parts := make([]string, 0, len(x))
224
+ for _, item := range x {
225
+ if m, ok := item.(map[string]any); ok {
226
+ if text, ok := m["text"].(string); ok {
227
+ parts = append(parts, text)
228
+ }
229
+ if text, ok := m["content"].(string); ok {
230
+ parts = append(parts, text)
231
+ }
232
+ }
233
+ }
234
+ return strings.Join(parts, "\n")
235
+ default:
236
+ b, _ := json.Marshal(v)
237
+ return string(b)
238
+ }
239
+ }
240
+
241
+ func IntFromAny(v any) int {
242
+ switch x := v.(type) {
243
+ case float64:
244
+ return int(x)
245
+ case int:
246
+ return x
247
+ default:
248
+ return 0
249
+ }
250
+ }
251
+
252
+ func actionFor(tool string, input map[string]any, result string) string {
253
+ switch tool {
254
+ case "Read", "read", "view_file":
255
+ return "read"
256
+ case "Write", "Edit", "MultiEdit", "NotebookEdit", "apply_patch", "write", "edit", "write_to_file", "replace_file_content", "multi_replace_file_content":
257
+ return "edit"
258
+ case "Grep", "Glob", "LS", "view_image", "grep", "find", "ls", "grep_search", "list_dir":
259
+ return "search"
260
+ case "Bash", "bash", "exec_command", "write_stdin", "js", "js_repl", "run_command":
261
+ command := firstString(input, "command", "cmd", "code", "chars", "script", "_raw", "CommandLine")
262
+ if verifyCommand(command) {
263
+ return "verify"
264
+ }
265
+ if searchCommand(command) {
266
+ return "search"
267
+ }
268
+ if readCommand(command) {
269
+ return "read"
270
+ }
271
+ return "exec"
272
+ case "exec":
273
+ if len(execPatchPaths(input)) > 0 {
274
+ return "edit"
275
+ }
276
+ commands := execCommands(input)
277
+ if len(commands) == 0 || !execHasOnlyStaticCommands(input, len(commands)) {
278
+ return "exec"
279
+ }
280
+ allVerify, allSearch, allRead := true, true, true
281
+ for _, command := range commands {
282
+ if !verifyCommand(command.command) {
283
+ allVerify = false
284
+ }
285
+ if !searchCommand(command.command) {
286
+ allSearch = false
287
+ }
288
+ if !readCommand(command.command) {
289
+ allRead = false
290
+ }
291
+ }
292
+ if allVerify {
293
+ return "verify"
294
+ }
295
+ if allSearch {
296
+ return "search"
297
+ }
298
+ if allRead {
299
+ return "read"
300
+ }
301
+ return "exec"
302
+ default:
303
+ _ = result
304
+ return "other"
305
+ }
306
+ }
307
+
308
+ func targetsFor(cwd, tool string, input map[string]any, result string) ([]model.Target, []model.OutsideTouch) {
309
+ var targets []model.Target
310
+ var outside []model.OutsideTouch
311
+ add := func(path, touch string, weak bool, lines [][2]int, base string) {
312
+ rel, out, ok := normalizePath(cwd, base, path)
313
+ if !ok {
314
+ return
315
+ }
316
+ if out != nil {
317
+ outside = append(outside, *out)
318
+ return
319
+ }
320
+ if weak && !repoPathExists(cwd, rel) {
321
+ return
322
+ }
323
+ for i := range targets {
324
+ if targets[i].Path == rel {
325
+ if model.RankTouch(touch) > model.RankTouch(targets[i].Touch) {
326
+ targets[i].Touch = touch
327
+ }
328
+ targets[i].Lines = append(targets[i].Lines, lines...)
329
+ return
330
+ }
331
+ }
332
+ targets = append(targets, model.Target{Path: rel, Touch: touch, Lines: lines, Weak: weak})
333
+ }
334
+
335
+ switch tool {
336
+ case "Read", "read", "view_file":
337
+ if path, ok := input["file_path"].(string); ok {
338
+ add(path, "read", false, readLines(input), "")
339
+ }
340
+ if path, ok := input["path"].(string); ok {
341
+ add(path, "read", false, readLines(input), "")
342
+ }
343
+ if path, ok := input["AbsolutePath"].(string); ok {
344
+ add(path, "read", false, readLines(input), "")
345
+ }
346
+ case "Write", "Edit", "MultiEdit", "NotebookEdit", "write", "edit", "write_to_file", "replace_file_content", "multi_replace_file_content":
347
+ if path, ok := input["file_path"].(string); ok {
348
+ add(path, "edit", false, nil, "")
349
+ }
350
+ if path, ok := input["notebook_path"].(string); ok {
351
+ add(path, "edit", false, nil, "")
352
+ }
353
+ if path, ok := input["path"].(string); ok {
354
+ add(path, "edit", false, nil, "")
355
+ }
356
+ if path, ok := input["TargetFile"].(string); ok {
357
+ add(path, "edit", false, nil, "")
358
+ }
359
+ case "Grep", "grep", "grep_search":
360
+ for _, hit := range parsePathHits(result) {
361
+ add(hit.path, "hit", false, hit.lines, "")
362
+ }
363
+ if len(targets) == 0 {
364
+ if path, ok := input["path"].(string); ok {
365
+ add(path, "hit", true, nil, "")
366
+ }
367
+ if path, ok := input["SearchPath"].(string); ok {
368
+ add(path, "hit", true, nil, "")
369
+ }
370
+ }
371
+ case "Glob", "LS", "find", "ls", "list_dir":
372
+ for _, hit := range parsePathHits(result) {
373
+ add(hit.path, "hit", false, nil, "")
374
+ }
375
+ if len(targets) == 0 {
376
+ if path, ok := input["path"].(string); ok {
377
+ add(path, "hit", true, nil, "")
378
+ }
379
+ if path, ok := input["DirectoryPath"].(string); ok {
380
+ add(path, "hit", true, nil, "")
381
+ }
382
+ }
383
+ case "Bash", "bash", "run_command":
384
+ command := firstString(input, "command", "CommandLine")
385
+ for _, path := range commandReadPaths(command) {
386
+ add(path, "read", true, nil, "")
387
+ }
388
+ for _, path := range extractCommandPaths(command) {
389
+ add(path, "hit", true, nil, "")
390
+ }
391
+ for _, path := range extractPaths(command + "\n" + result) {
392
+ add(path, "hit", true, nil, "")
393
+ }
394
+ case "exec_command":
395
+ command := firstString(input, "cmd", "command")
396
+ base := firstString(input, "workdir")
397
+ for _, path := range commandReadPaths(command) {
398
+ add(path, "read", true, nil, base)
399
+ }
400
+ for _, path := range extractCommandPaths(command) {
401
+ add(path, "hit", true, nil, base)
402
+ }
403
+ for _, path := range extractPaths(command + "\n" + result) {
404
+ add(path, "hit", true, nil, base)
405
+ }
406
+ for _, hit := range parsePathHits(result) {
407
+ add(hit.path, "hit", true, hit.lines, base)
408
+ }
409
+ case "exec":
410
+ for _, command := range execCommands(input) {
411
+ for _, path := range commandReadPaths(command.command) {
412
+ add(path, "read", true, nil, command.workdir)
413
+ }
414
+ for _, path := range extractCommandPaths(command.command) {
415
+ add(path, "hit", true, nil, command.workdir)
416
+ }
417
+ for _, path := range extractPaths(command.command) {
418
+ add(path, "hit", true, nil, command.workdir)
419
+ }
420
+ }
421
+ // The aggregate result does not retain which inner command produced each
422
+ // line, so resolve result-only paths against the session cwd rather than
423
+ // guessing a workdir.
424
+ for _, path := range extractPaths(result) {
425
+ add(path, "hit", true, nil, "")
426
+ }
427
+ for _, hit := range parsePathHits(result) {
428
+ add(hit.path, "hit", true, hit.lines, "")
429
+ }
430
+ for _, path := range execPatchPaths(input) {
431
+ add(path, "edit", false, nil, "")
432
+ }
433
+ case "apply_patch":
434
+ patch := firstString(input, "patch", "input", "_raw")
435
+ for _, path := range parsePatchPaths(patch) {
436
+ add(path, "edit", false, nil, "")
437
+ }
438
+ case "view_image":
439
+ if path := firstString(input, "path"); path != "" {
440
+ add(path, "read", false, nil, "")
441
+ }
442
+ case "js", "js_repl":
443
+ code := firstString(input, "code", "script", "_raw")
444
+ for _, path := range extractPaths(code + "\n" + result) {
445
+ add(path, "hit", true, nil, "")
446
+ }
447
+ }
448
+ return targets, outside
449
+ }
450
+
451
+ func firstString(input map[string]any, keys ...string) string {
452
+ for _, key := range keys {
453
+ if value, ok := input[key].(string); ok {
454
+ return value
455
+ }
456
+ }
457
+ return ""
458
+ }
459
+
460
+ type execCommand struct {
461
+ command string
462
+ workdir string
463
+ }
464
+
465
+ // execStringFieldRe recognizes only JSON-compatible double-quoted string
466
+ // literals assigned to cmd or workdir. It intentionally does not attempt to
467
+ // parse arbitrary JavaScript expressions.
468
+ var execStringFieldRe = regexp.MustCompile(`(?:^|[[:space:],{])(?:"(cmd|workdir)"|(cmd|workdir))\s*:\s*("(?:\\.|[^"\\])*")`)
469
+ var execPatchAssignmentRe = regexp.MustCompile(`(?m)^[\t ]*(?:const|let|var)[\t ]+patch[\t ]*=[\t ]*("(?:\\.|[^"\\])*")[\t ]*;`)
470
+
471
+ func execSource(input map[string]any) string {
472
+ for _, key := range []string{"_raw", "code", "script"} {
473
+ if candidate, ok := input[key].(string); ok && candidate != "" {
474
+ return candidate
475
+ }
476
+ }
477
+ return ""
478
+ }
479
+
480
+ func execHasOnlyStaticCommands(input map[string]any, commandCount int) bool {
481
+ source := execSource(input)
482
+ if source == "" {
483
+ return firstString(input, "cmd", "command") != ""
484
+ }
485
+ tools := execToolNames(source)
486
+ if len(tools) != commandCount {
487
+ return false
488
+ }
489
+ for _, tool := range tools {
490
+ if tool != "exec_command" {
491
+ return false
492
+ }
493
+ }
494
+ return true
495
+ }
496
+
497
+ func execCommands(input map[string]any) []execCommand {
498
+ source := execSource(input)
499
+ if source == "" {
500
+ if command := firstString(input, "cmd", "command"); command != "" {
501
+ return []execCommand{{command: command, workdir: firstString(input, "workdir")}}
502
+ }
503
+ return nil
504
+ }
505
+
506
+ arguments := execCommandArguments(source)
507
+ commands := make([]execCommand, 0, len(arguments))
508
+ for _, argument := range arguments {
509
+ if command, ok := parseStaticExecCommand(argument); ok {
510
+ commands = append(commands, command)
511
+ }
512
+ }
513
+ return commands
514
+ }
515
+
516
+ func execPatchPaths(input map[string]any) []string {
517
+ source := execSource(input)
518
+ if source == "" {
519
+ return nil
520
+ }
521
+ match := execPatchAssignmentRe.FindStringSubmatch(source)
522
+ if len(match) != 2 {
523
+ return nil
524
+ }
525
+ var patch string
526
+ if json.Unmarshal([]byte(match[1]), &patch) != nil {
527
+ return nil
528
+ }
529
+ for _, argument := range execToolArguments(source, "apply_patch") {
530
+ if strings.TrimSpace(argument) == "patch" {
531
+ return parsePatchPaths(patch)
532
+ }
533
+ }
534
+ return nil
535
+ }
536
+
537
+ func parseStaticExecCommand(argument string) (execCommand, bool) {
538
+ var command execCommand
539
+ ambiguousWorkdir := false
540
+ for _, match := range execStringFieldRe.FindAllStringSubmatchIndex(argument, -1) {
541
+ keyStart, keyEnd := match[2], match[3]
542
+ if keyStart < 0 {
543
+ keyStart, keyEnd = match[4], match[5]
544
+ }
545
+ key := argument[keyStart:keyEnd]
546
+
547
+ var value string
548
+ if err := json.Unmarshal([]byte(argument[match[6]:match[7]]), &value); err != nil {
549
+ continue
550
+ }
551
+
552
+ if key == "cmd" {
553
+ if command.command != "" {
554
+ return execCommand{}, false
555
+ }
556
+ command.command = value
557
+ continue
558
+ }
559
+
560
+ if command.workdir != "" {
561
+ ambiguousWorkdir = true
562
+ command.workdir = ""
563
+ continue
564
+ }
565
+ if !ambiguousWorkdir {
566
+ command.workdir = value
567
+ }
568
+ }
569
+ return command, command.command != ""
570
+ }
571
+
572
+ func execCommandArguments(source string) []string {
573
+ return execToolArguments(source, "exec_command")
574
+ }
575
+
576
+ func execToolArguments(source, tool string) []string {
577
+ call := "tools." + tool
578
+ var arguments []string
579
+ for i := 0; i < len(source); {
580
+ if next, ok := skipJSIgnored(source, i); ok {
581
+ i = next
582
+ continue
583
+ }
584
+ if !strings.HasPrefix(source[i:], call) || (i > 0 && isJSIdentifierByte(source[i-1])) {
585
+ i++
586
+ continue
587
+ }
588
+ open := i + len(call)
589
+ for open < len(source) && isJSSpace(source[open]) {
590
+ open++
591
+ }
592
+ if open >= len(source) || source[open] != '(' {
593
+ i++
594
+ continue
595
+ }
596
+ close, ok := matchingJSParen(source, open)
597
+ if !ok {
598
+ break
599
+ }
600
+ arguments = append(arguments, source[open+1:close])
601
+ i = close + 1
602
+ }
603
+ return arguments
604
+ }
605
+
606
+ func execToolNames(source string) []string {
607
+ const prefix = "tools."
608
+ var names []string
609
+ for i := 0; i < len(source); {
610
+ if next, ok := skipJSIgnored(source, i); ok {
611
+ i = next
612
+ continue
613
+ }
614
+ if !strings.HasPrefix(source[i:], prefix) || (i > 0 && isJSIdentifierByte(source[i-1])) {
615
+ i++
616
+ continue
617
+ }
618
+ nameStart := i + len(prefix)
619
+ nameEnd := nameStart
620
+ for nameEnd < len(source) && isJSIdentifierByte(source[nameEnd]) {
621
+ nameEnd++
622
+ }
623
+ open := nameEnd
624
+ for open < len(source) && isJSSpace(source[open]) {
625
+ open++
626
+ }
627
+ if nameEnd == nameStart || open >= len(source) || source[open] != '(' {
628
+ i++
629
+ continue
630
+ }
631
+ names = append(names, source[nameStart:nameEnd])
632
+ i = open + 1
633
+ }
634
+ return names
635
+ }
636
+
637
+ func matchingJSParen(source string, open int) (int, bool) {
638
+ depth := 1
639
+ for i := open + 1; i < len(source); {
640
+ if next, ok := skipJSIgnored(source, i); ok {
641
+ i = next
642
+ continue
643
+ }
644
+ switch source[i] {
645
+ case '(':
646
+ depth++
647
+ case ')':
648
+ depth--
649
+ if depth == 0 {
650
+ return i, true
651
+ }
652
+ }
653
+ i++
654
+ }
655
+ return 0, false
656
+ }
657
+
658
+ func skipJSIgnored(source string, start int) (int, bool) {
659
+ if start >= len(source) {
660
+ return start, false
661
+ }
662
+ if quote := source[start]; quote == '\'' || quote == '"' || quote == '`' {
663
+ for i := start + 1; i < len(source); i++ {
664
+ if source[i] == '\\' {
665
+ i++
666
+ continue
667
+ }
668
+ if source[i] == quote {
669
+ return i + 1, true
670
+ }
671
+ }
672
+ return len(source), true
673
+ }
674
+ if source[start] != '/' || start+1 >= len(source) {
675
+ return start, false
676
+ }
677
+ switch source[start+1] {
678
+ case '/':
679
+ if end := strings.IndexByte(source[start+2:], '\n'); end >= 0 {
680
+ return start + 2 + end + 1, true
681
+ }
682
+ return len(source), true
683
+ case '*':
684
+ if end := strings.Index(source[start+2:], "*/"); end >= 0 {
685
+ return start + 2 + end + 2, true
686
+ }
687
+ return len(source), true
688
+ default:
689
+ return start, false
690
+ }
691
+ }
692
+
693
+ func isJSSpace(b byte) bool {
694
+ return b == ' ' || b == '\t' || b == '\r' || b == '\n'
695
+ }
696
+
697
+ func isJSIdentifierByte(b byte) bool {
698
+ return b == '_' || b == '$' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9'
699
+ }
700
+
701
+ func repoPathExists(cwd, rel string) bool {
702
+ if cwd == "" || rel == "" {
703
+ return false
704
+ }
705
+ abs := filepath.Join(cwd, filepath.FromSlash(rel))
706
+ _, err := os.Stat(abs)
707
+ return err == nil
708
+ }
709
+
710
+ func readLines(input map[string]any) [][2]int {
711
+ offset := IntFromAny(input["offset"])
712
+ limit := IntFromAny(input["limit"])
713
+ if offset <= 0 {
714
+ return nil
715
+ }
716
+ if limit <= 0 {
717
+ return [][2]int{{offset, offset}}
718
+ }
719
+ return [][2]int{{offset, offset + limit - 1}}
720
+ }
721
+
722
+ func normalizePath(cwd, base, path string) (string, *model.OutsideTouch, bool) {
723
+ path = strings.TrimSpace(strings.Trim(path, `"'`))
724
+ if path == "" || strings.Contains(path, "\n") {
725
+ return "", nil, false
726
+ }
727
+ if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
728
+ return "", nil, false
729
+ }
730
+ if !filepath.IsAbs(path) {
731
+ clean := filepath.Clean(path)
732
+ if clean == "." || strings.HasPrefix(clean, "..") {
733
+ return "", nil, false
734
+ }
735
+ if base != "" && filepath.IsAbs(base) {
736
+ abs := filepath.Clean(filepath.Join(base, clean))
737
+ if cwd != "" {
738
+ root := filepath.Clean(cwd)
739
+ if rel, err := filepath.Rel(root, abs); err == nil && !strings.HasPrefix(rel, "..") && rel != "." {
740
+ return filepath.ToSlash(rel), nil, true
741
+ }
742
+ }
743
+ return "", &model.OutsideTouch{Scope: outsideScope(abs), Path: abs}, true
744
+ }
745
+ return filepath.ToSlash(clean), nil, true
746
+ }
747
+ abs := filepath.Clean(path)
748
+ if cwd != "" {
749
+ root := filepath.Clean(cwd)
750
+ if rel, err := filepath.Rel(root, abs); err == nil && !strings.HasPrefix(rel, "..") && rel != "." {
751
+ return filepath.ToSlash(rel), nil, true
752
+ }
753
+ }
754
+ return "", &model.OutsideTouch{Scope: outsideScope(abs), Path: abs}, true
755
+ }
756
+
757
+ func outsideScope(path string) string {
758
+ home, _ := os.UserHomeDir()
759
+ if home != "" {
760
+ if rel, err := filepath.Rel(home, path); err == nil && !strings.HasPrefix(rel, "..") {
761
+ return "home"
762
+ }
763
+ }
764
+ if strings.HasPrefix(path, os.TempDir()) || strings.HasPrefix(path, "/tmp") {
765
+ return "tmp"
766
+ }
767
+ return "other"
768
+ }
769
+
770
+ type pathHit struct {
771
+ path string
772
+ lines [][2]int
773
+ }
774
+
775
+ var pathLineRe = regexp.MustCompile(`(?:^|[\s"'([])([A-Za-z0-9_./@+-]*[A-Za-z0-9_/@+-]\.[A-Za-z0-9][A-Za-z0-9._-]*):([0-9]+)`)
776
+ var pathOnlyRe = regexp.MustCompile(`(?:^|[\s"'([])([./~A-Za-z0-9_@+-]*[/][A-Za-z0-9_./~@+-]*\.[A-Za-z0-9][A-Za-z0-9._-]*)(?:$|[\s"',)\]:;])`)
777
+ var commandPathRe = regexp.MustCompile(`(?:^|[\s"'=])([./~A-Za-z0-9_@+-]+\.[A-Za-z0-9][A-Za-z0-9._-]*)(?:$|[\s"',)\]:;])`)
778
+ var patchFileRe = regexp.MustCompile(`(?m)^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$`)
779
+
780
+ func parsePathHits(text string) []pathHit {
781
+ byPath := map[string][][2]int{}
782
+ for _, m := range pathLineRe.FindAllStringSubmatch(text, -1) {
783
+ line := 0
784
+ fmt.Sscanf(m[2], "%d", &line)
785
+ if line > 0 {
786
+ if path, ok := cleanExtractedPath(m[1], true); ok {
787
+ byPath[path] = append(byPath[path], [2]int{line, line})
788
+ }
789
+ }
790
+ }
791
+ for _, p := range extractPaths(text) {
792
+ if _, ok := byPath[p]; !ok {
793
+ byPath[p] = nil
794
+ }
795
+ }
796
+ out := make([]pathHit, 0, len(byPath))
797
+ for path, lines := range byPath {
798
+ out = append(out, pathHit{path: path, lines: lines})
799
+ }
800
+ sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path })
801
+ return out
802
+ }
803
+
804
+ func extractPaths(text string) []string {
805
+ matches := pathOnlyRe.FindAllStringSubmatch(text, -1)
806
+ seen := map[string]bool{}
807
+ paths := make([]string, 0, len(matches))
808
+ for _, m := range matches {
809
+ path, ok := cleanExtractedPath(m[1], false)
810
+ if !ok {
811
+ continue
812
+ }
813
+ if path == "" || seen[path] || strings.Contains(path, "://") {
814
+ continue
815
+ }
816
+ seen[path] = true
817
+ paths = append(paths, path)
818
+ }
819
+ sort.Strings(paths)
820
+ return paths
821
+ }
822
+
823
+ func extractCommandPaths(command string) []string {
824
+ matches := commandPathRe.FindAllStringSubmatch(command, -1)
825
+ seen := map[string]bool{}
826
+ paths := make([]string, 0, len(matches))
827
+ for _, m := range matches {
828
+ path, ok := cleanExtractedPath(m[1], true)
829
+ if !ok || seen[path] {
830
+ continue
831
+ }
832
+ seen[path] = true
833
+ paths = append(paths, path)
834
+ }
835
+ sort.Strings(paths)
836
+ return paths
837
+ }
838
+
839
+ func parsePatchPaths(patch string) []string {
840
+ matches := patchFileRe.FindAllStringSubmatch(patch, -1)
841
+ seen := map[string]bool{}
842
+ paths := make([]string, 0, len(matches))
843
+ for _, m := range matches {
844
+ raw := m[1]
845
+ if raw == "" {
846
+ raw = m[2]
847
+ }
848
+ path, ok := cleanExtractedPath(raw, true)
849
+ if !ok || seen[path] {
850
+ continue
851
+ }
852
+ seen[path] = true
853
+ paths = append(paths, path)
854
+ }
855
+ sort.Strings(paths)
856
+ return paths
857
+ }
858
+
859
+ func cleanExtractedPath(path string, allowTopLevel bool) (string, bool) {
860
+ path = strings.TrimSpace(strings.Trim(path, `"' ,;:()[]{}`))
861
+ path = strings.TrimPrefix(path, "a/")
862
+ path = strings.TrimPrefix(path, "b/")
863
+ path = strings.TrimPrefix(path, "./")
864
+ if path == "" || strings.Contains(path, "://") || strings.ContainsAny(path, "\n\r\t") {
865
+ return "", false
866
+ }
867
+ if strings.HasPrefix(path, "--") || strings.HasPrefix(path, "++") {
868
+ return "", false
869
+ }
870
+ if !allowTopLevel && !strings.Contains(path, "/") {
871
+ return "", false
872
+ }
873
+ return path, true
874
+ }
875
+
876
+ var envAssignRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`)
877
+
878
+ var searchPrograms = map[string]bool{
879
+ "grep": true, "rg": true, "ag": true, "find": true, "fd": true,
880
+ "ls": true, "tree": true,
881
+ }
882
+
883
+ var readOnlyPrograms = map[string]bool{
884
+ "cd": true, "cat": true, "head": true, "tail": true, "wc": true,
885
+ "sort": true, "uniq": true, "cut": true, "awk": true, "echo": true,
886
+ "which": true, "file": true, "stat": true, "du": true, "pwd": true,
887
+ "dirname": true, "basename": true, "true": true,
888
+ }
889
+
890
+ // searchCommand reports whether a shell command only inspects the tree the
891
+ // way Grep/Glob/LS would: every pipeline segment runs a read-only program and
892
+ // at least one segment actually searches or lists. Conservative by design —
893
+ // anything unrecognized stays "exec".
894
+ func searchCommand(command string) bool {
895
+ cleaned := strings.NewReplacer("2>&1", " ", "2>/dev/null", " ", ">/dev/null", " ", "> /dev/null", " ").Replace(command)
896
+ searched := false
897
+ segments := strings.FieldsFunc(cleaned, func(r rune) bool {
898
+ return r == '|' || r == ';' || r == '&' || r == '\n'
899
+ })
900
+ for _, segment := range segments {
901
+ if strings.ContainsRune(segment, '>') {
902
+ return false
903
+ }
904
+ fields := strings.Fields(segment)
905
+ for len(fields) > 0 && envAssignRe.MatchString(fields[0]) {
906
+ fields = fields[1:]
907
+ }
908
+ if len(fields) == 0 {
909
+ continue
910
+ }
911
+ program := strings.ToLower(filepath.Base(fields[0]))
912
+ if program == "git" && len(fields) > 1 && (fields[1] == "grep" || fields[1] == "ls-files") {
913
+ searched = true
914
+ continue
915
+ }
916
+ if searchPrograms[program] {
917
+ // find can mutate through -exec/-delete; keep those out of "search"
918
+ if strings.Contains(segment, "-exec") || strings.Contains(segment, "-delete") {
919
+ return false
920
+ }
921
+ searched = true
922
+ continue
923
+ }
924
+ if !readOnlyPrograms[program] {
925
+ return false
926
+ }
927
+ }
928
+ return searched
929
+ }
930
+
931
+ var readPrograms = map[string]bool{
932
+ "cat": true, "head": true, "tail": true, "nl": true, "sed": true,
933
+ }
934
+
935
+ // readCommand reports whether a shell command only pages file contents the
936
+ // way Read would: every pipeline segment runs a read-only program and at
937
+ // least one segment names a file to read. Conservative by design — anything
938
+ // unrecognized stays "exec".
939
+ func readCommand(command string) bool {
940
+ if len(commandReadPaths(command)) == 0 {
941
+ return false
942
+ }
943
+ cleaned := strings.NewReplacer("2>&1", " ", "2>/dev/null", " ", ">/dev/null", " ", "> /dev/null", " ").Replace(command)
944
+ segments := strings.FieldsFunc(cleaned, func(r rune) bool {
945
+ return r == '|' || r == ';' || r == '&' || r == '\n'
946
+ })
947
+ for _, segment := range segments {
948
+ if strings.ContainsRune(segment, '>') {
949
+ return false
950
+ }
951
+ fields := strings.Fields(segment)
952
+ for len(fields) > 0 && envAssignRe.MatchString(fields[0]) {
953
+ fields = fields[1:]
954
+ }
955
+ if len(fields) == 0 {
956
+ continue
957
+ }
958
+ program := strings.ToLower(filepath.Base(fields[0]))
959
+ if program == "sed" && !sedReadsOnly(fields[1:]) {
960
+ return false
961
+ }
962
+ if !readOnlyPrograms[program] && !readPrograms[program] {
963
+ return false
964
+ }
965
+ }
966
+ return true
967
+ }
968
+
969
+ // commandReadPaths returns the file arguments of pager-style segments —
970
+ // cat/head/tail/nl and the `sed -n '…p' file` idiom — the shell equivalents
971
+ // of opening a file to read it.
972
+ func commandReadPaths(command string) []string {
973
+ seen := map[string]bool{}
974
+ var paths []string
975
+ segments := strings.FieldsFunc(command, func(r rune) bool {
976
+ return r == '|' || r == ';' || r == '&' || r == '\n'
977
+ })
978
+ for _, segment := range segments {
979
+ if strings.ContainsRune(segment, '>') {
980
+ continue
981
+ }
982
+ fields := strings.Fields(segment)
983
+ for len(fields) > 0 && envAssignRe.MatchString(fields[0]) {
984
+ fields = fields[1:]
985
+ }
986
+ if len(fields) == 0 {
987
+ continue
988
+ }
989
+ program := strings.ToLower(filepath.Base(fields[0]))
990
+ if !readPrograms[program] {
991
+ continue
992
+ }
993
+ args := fields[1:]
994
+ scriptArgs := 0
995
+ if program == "sed" {
996
+ // only the read idiom: sed -n '…p' file — anything else can rewrite
997
+ if !sedReadsOnly(args) {
998
+ continue
999
+ }
1000
+ scriptArgs = 1
1001
+ }
1002
+ expectValue := false
1003
+ for _, arg := range args {
1004
+ if expectValue {
1005
+ expectValue = false
1006
+ continue
1007
+ }
1008
+ if strings.HasPrefix(arg, "-") {
1009
+ expectValue = flagTakesValue(program, arg)
1010
+ continue
1011
+ }
1012
+ if scriptArgs > 0 {
1013
+ scriptArgs--
1014
+ continue
1015
+ }
1016
+ // globs, redirections, and substitutions are not literal file paths
1017
+ if strings.ContainsAny(arg, "<>*?$`") {
1018
+ continue
1019
+ }
1020
+ path, ok := cleanExtractedPath(arg, true)
1021
+ if !ok || seen[path] {
1022
+ continue
1023
+ }
1024
+ seen[path] = true
1025
+ paths = append(paths, path)
1026
+ }
1027
+ }
1028
+ sort.Strings(paths)
1029
+ return paths
1030
+ }
1031
+
1032
+ func sedReadsOnly(args []string) bool {
1033
+ hasN := false
1034
+ for _, arg := range args {
1035
+ if arg == "-n" {
1036
+ hasN = true
1037
+ }
1038
+ if strings.HasPrefix(arg, "-i") {
1039
+ return false
1040
+ }
1041
+ }
1042
+ return hasN
1043
+ }
1044
+
1045
+ func flagTakesValue(program, flag string) bool {
1046
+ switch program {
1047
+ case "head", "tail":
1048
+ return flag == "-n" || flag == "-c"
1049
+ case "sed":
1050
+ return flag == "-e" || flag == "-f"
1051
+ }
1052
+ return false
1053
+ }
1054
+
1055
+ func verifyCommand(command string) bool {
1056
+ c := strings.ToLower(command)
1057
+ patterns := []string{"go test", "go vet", "npm test", "npm run build", "pnpm test", "pnpm build", "pytest", "make test", "cargo test", "swift test"}
1058
+ for _, pattern := range patterns {
1059
+ if strings.Contains(c, pattern) {
1060
+ return true
1061
+ }
1062
+ }
1063
+ return false
1064
+ }
1065
+
1066
+ const toolSummaryVerbLimit = 96
1067
+
1068
+ // summarizeExecWrapper makes Codex orchestration events readable without
1069
+ // evaluating their free-form JavaScript. Static commands use the same parser
1070
+ // as action/target inference; dynamic calls expose only the nested tool name.
1071
+ func summarizeExecWrapper(input map[string]any) string {
1072
+ commands := execCommands(input)
1073
+ source := execSource(input)
1074
+ nestedTools := execToolNames(source)
1075
+ if len(commands) == 0 && len(nestedTools) == 0 {
1076
+ return ""
1077
+ }
1078
+
1079
+ primary := ""
1080
+ if len(commands) > 0 {
1081
+ primary = commands[0].command
1082
+ } else {
1083
+ primary = nestedTools[0]
1084
+ }
1085
+ additionalCalls := len(commands) - 1
1086
+ if len(nestedTools) > 0 {
1087
+ additionalCalls = len(nestedTools) - 1
1088
+ }
1089
+
1090
+ suffix := ""
1091
+ if additionalCalls == 1 {
1092
+ suffix = " (+1 more tool call)"
1093
+ } else if additionalCalls > 1 {
1094
+ suffix = fmt.Sprintf(" (+%d more tool calls)", additionalCalls)
1095
+ }
1096
+ commandLimit := toolSummaryVerbLimit - len([]rune(suffix))
1097
+ return textutil.TruncateRunes(primary, commandLimit, "...") + suffix
1098
+ }
1099
+
1100
+ func SummarizeTool(tool string, input map[string]any, targets []model.Target, outside []model.OutsideTouch, isError bool) string {
1101
+ verb := tool
1102
+ if desc, ok := input["description"].(string); ok && desc != "" {
1103
+ verb = desc
1104
+ }
1105
+ if command := firstString(input, "command", "cmd"); command != "" {
1106
+ verb = textutil.TruncateRunes(command, toolSummaryVerbLimit, "...")
1107
+ } else if tool == "exec" {
1108
+ if summary := summarizeExecWrapper(input); summary != "" {
1109
+ verb = summary
1110
+ }
1111
+ }
1112
+ status := ""
1113
+ if isError {
1114
+ status = " error"
1115
+ }
1116
+ return fmt.Sprintf("%s -> %d targets, %d outside%s", verb, len(targets), len(outside), status)
1117
+ }