From e6e8482613dcabb4266e7cf2094844bc232480f6 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:44:45 +0900 Subject: [PATCH] refactor: remove hardcoded dir skip list from project dir extraction Replaced assumption-based logic (skipping "projects", "repos", "src") with dynamic extraction: exec uses basename of cd target, file tools use the first path component after workspace. No conventions assumed. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 75 +++++++++++++++++------------------------- pkg/agent/loop_test.go | 39 +++++++++++++--------- 2 files changed, 53 insertions(+), 61 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 8035d939e..c340111ab 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -956,49 +956,10 @@ var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`) // argument (e.g. "-A 20") are kept because removing them would lose context. var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`) -// projectDirFromPath extracts the project directory name from an absolute path -// by stripping the workspace prefix and returning the first meaningful component. -// Generic directory names like "projects" or "repos" are skipped. -// Returns "" if the path doesn't extend beyond workspace. -func projectDirFromPath(absPath, workspace string) string { - absPath = strings.TrimRight(absPath, "/\\") - if absPath == "" { - return "" - } - if workspace == "" { - if idx := strings.LastIndex(absPath, "/"); idx >= 0 { - return absPath[idx+1:] - } - return absPath - } - ws := strings.TrimRight(workspace, "/\\") - rest := strings.TrimPrefix(absPath, ws) - if rest == absPath { - // workspace not a prefix — fall back to last component - if idx := strings.LastIndex(absPath, "/"); idx >= 0 { - return absPath[idx+1:] - } - return absPath - } - rest = strings.TrimLeft(rest, "/\\") - if rest == "" { - return "" - } - // Take first path component, skipping generic directory names - parts := strings.SplitN(rest, "/", 3) - if len(parts) >= 2 { - first := strings.ToLower(parts[0]) - if first == "projects" || first == "repos" || first == "src" { - return parts[1] - } - } - return parts[0] -} - -// extractProjectDir extracts the project directory name from a tool call. -// For exec: parses the "cd && ..." prefix. -// For file tools: uses the file path directly. -// Returns "" if no project directory can be determined. +// extractProjectDir extracts a working directory name from a tool call. +// For exec: uses the basename of the cd target (the directory AI actually works in). +// For file tools: strips the workspace prefix and takes the first path component. +// No assumptions are made about directory naming conventions. func extractProjectDir(toolName string, args map[string]interface{}, workspace string) string { switch toolName { case "exec": @@ -1010,14 +971,38 @@ func extractProjectDir(toolName string, args map[string]interface{}, workspace s if len(m) < 2 { return "" } - return projectDirFromPath(m[1], workspace) + // basename of cd target — the directory the AI actually cd's into + cdPath := strings.TrimRight(m[1], "/\\") + if idx := strings.LastIndex(cdPath, "/"); idx >= 0 { + return cdPath[idx+1:] + } + if idx := strings.LastIndex(cdPath, "\\"); idx >= 0 { + return cdPath[idx+1:] + } + return cdPath case "read_file", "write_file", "edit_file", "append_file", "list_dir": path, _ := args["path"].(string) if path == "" { return "" } - return projectDirFromPath(path, workspace) + ws := strings.TrimRight(workspace, "/\\") + if ws == "" { + return "" + } + rest := strings.TrimPrefix(path, ws) + if rest == path { + return "" // path not under workspace + } + rest = strings.TrimLeft(rest, "/\\") + if rest == "" { + return "" + } + // first component after workspace + if idx := strings.IndexAny(rest, "/\\"); idx >= 0 { + return rest[:idx] + } + return rest } return "" } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 3f66f7740..de8f47135 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1612,34 +1612,34 @@ func TestExtractProjectDir(t *testing.T) { workspace string want string }{ - // exec: cd prefix + // exec: basename of cd target { - name: "exec cd into projects subdir", + name: "exec cd deep path", toolName: "exec", args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest"}, workspace: ws, want: "terra-py-form", }, { - name: "exec cd with trailing slash workspace", - toolName: "exec", - args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && ls"}, - workspace: ws + "/", - want: "terra-py-form", - }, - { - name: "exec cd into direct subdir", + name: "exec cd direct subdir", toolName: "exec", args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/my-app && make build"}, workspace: ws, want: "my-app", }, + { + name: "exec cd trailing slash target", + toolName: "exec", + args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/my-app/ && ls"}, + workspace: ws, + want: "my-app", + }, { name: "exec cd to workspace itself", toolName: "exec", args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace && ls"}, workspace: ws, - want: "", + want: "workspace", }, { name: "exec no cd prefix", @@ -1648,16 +1648,16 @@ func TestExtractProjectDir(t *testing.T) { workspace: ws, want: "", }, - // file tools + // file tools: first component after workspace { - name: "read_file in projects subdir", + name: "read_file first component is projects", toolName: "read_file", args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/projects/terra-py-form/src/main.py"}, workspace: ws, - want: "terra-py-form", + want: "projects", }, { - name: "edit_file in direct subdir", + name: "edit_file direct subdir", toolName: "edit_file", args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/my-app/README.md"}, workspace: ws, @@ -1671,7 +1671,14 @@ func TestExtractProjectDir(t *testing.T) { want: "notes.txt", }, { - name: "unknown tool returns empty", + name: "file outside workspace", + toolName: "read_file", + args: map[string]interface{}{"path": "/tmp/foo.txt"}, + workspace: ws, + want: "", + }, + { + name: "unknown tool", toolName: "web_search", args: map[string]interface{}{"query": "test"}, workspace: ws,