@hybridlabor-api/bdb-synapse 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/synapse +0 -0
- package/cmd/rubriceval/main.go +308 -0
- package/cmd/synapse/main.go +280 -0
- package/cmd/synapse/main_test.go +16 -0
- package/go.mod +7 -0
- package/go.sum +6 -0
- package/internal/adapter/adapter.go +1117 -0
- package/internal/adapter/adapter_test.go +518 -0
- package/internal/adapter/agy/adapter.go +193 -0
- package/internal/adapter/claudecode/adapter.go +415 -0
- package/internal/adapter/claudecode/adapter_test.go +260 -0
- package/internal/adapter/claudecode/agents.go +387 -0
- package/internal/adapter/claudecode/agents_test.go +480 -0
- package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
- package/internal/adapter/codex/adapter.go +919 -0
- package/internal/adapter/codex/adapter_test.go +920 -0
- package/internal/adapter/codex/agents.go +401 -0
- package/internal/adapter/codex/agents_test.go +610 -0
- package/internal/adapter/codex/summary_inputs_test.go +20 -0
- package/internal/adapter/pi/adapter.go +483 -0
- package/internal/adapter/pi/adapter_test.go +517 -0
- package/internal/citymap/builder.go +1124 -0
- package/internal/citymap/builder_test.go +818 -0
- package/internal/judge/cache.go +180 -0
- package/internal/judge/cli.go +240 -0
- package/internal/judge/cli_test.go +68 -0
- package/internal/judge/fresh_summary_test.go +37 -0
- package/internal/judge/input.go +233 -0
- package/internal/judge/judge.go +416 -0
- package/internal/judge/judge_test.go +288 -0
- package/internal/judge/prompt.go +129 -0
- package/internal/judge/rubric.go +275 -0
- package/internal/judge/rubric_test.go +642 -0
- package/internal/model/agent.go +63 -0
- package/internal/model/agent_schema_test.go +86 -0
- package/internal/model/agent_test.go +64 -0
- package/internal/model/model.go +175 -0
- package/internal/model/report.go +166 -0
- package/internal/model/stats.go +151 -0
- package/internal/model/stats_test.go +89 -0
- package/internal/model/trace_schema_test.go +67 -0
- package/internal/server/analyze.go +286 -0
- package/internal/server/analyze_test.go +297 -0
- package/internal/server/codex_index_test.go +40 -0
- package/internal/server/hardening_test.go +147 -0
- package/internal/server/reportindex.go +91 -0
- package/internal/server/reportindex_test.go +116 -0
- package/internal/server/server.go +1099 -0
- package/internal/server/server_test.go +1389 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
- package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
- package/internal/server/static/assets/index-C_adLrJr.js +3 -0
- package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
- package/internal/server/static/index.html +26 -0
- package/internal/server/tracestore.go +173 -0
- package/internal/server/tracestore_test.go +51 -0
- package/internal/textutil/truncate.go +30 -0
- package/internal/textutil/truncate_test.go +39 -0
- package/package.json +35 -0
- package/web/e2e/agent-lens.spec.ts +688 -0
- package/web/index.html +23 -0
- package/web/package-lock.json +1933 -0
- package/web/package.json +33 -0
- package/web/playwright.config.ts +24 -0
- package/web/src/App.tsx +876 -0
- package/web/src/api/client.ts +74 -0
- package/web/src/main.tsx +12 -0
- package/web/src/playback/recorder.ts +160 -0
- package/web/src/playback/reducer.ts +91 -0
- package/web/src/scene/CityScene.tsx +638 -0
- package/web/src/scene/TreeScene.tsx +656 -0
- package/web/src/scene/dirLabels.ts +145 -0
- package/web/src/scene/sceneUtils.ts +144 -0
- package/web/src/scene/textures.ts +60 -0
- package/web/src/scene/trail.ts +79 -0
- package/web/src/scene/treeLayout.ts +169 -0
- package/web/src/state/filters.ts +40 -0
- package/web/src/state/store.ts +83 -0
- package/web/src/styles.css +2565 -0
- package/web/src/types.ts +315 -0
- package/web/src/ui/AgentsPanel.tsx +376 -0
- package/web/src/ui/Dock.tsx +104 -0
- package/web/src/ui/Hud.tsx +335 -0
- package/web/src/ui/Inspector.tsx +107 -0
- package/web/src/ui/LogoMark.tsx +38 -0
- package/web/src/ui/ReportPanel.tsx +491 -0
- package/web/src/ui/SessionRail.tsx +316 -0
- package/web/src/ui/Timeline.tsx +458 -0
- package/web/src/ui/ViewPanel.tsx +45 -0
- package/web/src/ui/shortcuts.ts +4 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +28 -0
|
@@ -0,0 +1,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
|
+
}
|