feat: detect project dir via file path LCP convergence
For file tools (read_file, edit_file, etc.), track the longest common directory prefix across all accessed file paths. As more files are touched, the LCP converges to the actual project root. Exec cd target remains authoritative; file LCP is used as fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
120f1f0785
commit
f40f5fa7d2
2 changed files with 192 additions and 140 deletions
|
|
@ -44,8 +44,9 @@ type activeTask struct {
|
||||||
interrupt chan string // buffered 1, for user message injection
|
interrupt chan string // buffered 1, for user message injection
|
||||||
toolLog []toolLogEntry
|
toolLog []toolLogEntry
|
||||||
lastError *toolLogEntry // sticky: most recent error, persists across iterations
|
lastError *toolLogEntry // sticky: most recent error, persists across iterations
|
||||||
projectDir string // detected project directory name (from cd prefix)
|
projectDir string // detected from exec cd target (authoritative)
|
||||||
mu sync.Mutex
|
fileCommonDir string // LCP of file paths relative to workspace (fallback)
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolLogEntry records a single tool call for the live terminal view.
|
// toolLogEntry records a single tool call for the live terminal view.
|
||||||
|
|
@ -956,53 +957,81 @@ var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`)
|
||||||
// argument (e.g. "-A 20") are kept because removing them would lose context.
|
// argument (e.g. "-A 20") are kept because removing them would lose context.
|
||||||
var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`)
|
var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`)
|
||||||
|
|
||||||
// extractProjectDir extracts a working directory name from a tool call.
|
// extractExecProjectDir extracts the basename of an exec cd target.
|
||||||
// For exec: uses the basename of the cd target (the directory AI actually works in).
|
// Returns "" if the command has no cd prefix.
|
||||||
// For file tools: strips the workspace prefix and takes the first path component.
|
func extractExecProjectDir(args map[string]interface{}) string {
|
||||||
// No assumptions are made about directory naming conventions.
|
cmd, _ := args["command"].(string)
|
||||||
func extractProjectDir(toolName string, args map[string]interface{}, workspace string) string {
|
if cmd == "" {
|
||||||
switch toolName {
|
return ""
|
||||||
case "exec":
|
}
|
||||||
cmd, _ := args["command"].(string)
|
m := cdPrefixPattern.FindStringSubmatch(cmd)
|
||||||
if cmd == "" {
|
if len(m) < 2 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
m := cdPrefixPattern.FindStringSubmatch(cmd)
|
cdPath := strings.TrimRight(m[1], "/\\")
|
||||||
if len(m) < 2 {
|
if idx := strings.LastIndex(cdPath, "/"); idx >= 0 {
|
||||||
return ""
|
return cdPath[idx+1:]
|
||||||
}
|
}
|
||||||
// basename of cd target — the directory the AI actually cd's into
|
if idx := strings.LastIndex(cdPath, "\\"); idx >= 0 {
|
||||||
cdPath := strings.TrimRight(m[1], "/\\")
|
return cdPath[idx+1:]
|
||||||
if idx := strings.LastIndex(cdPath, "/"); idx >= 0 {
|
}
|
||||||
return cdPath[idx+1:]
|
return cdPath
|
||||||
}
|
}
|
||||||
if idx := strings.LastIndex(cdPath, "\\"); idx >= 0 {
|
|
||||||
return cdPath[idx+1:]
|
|
||||||
}
|
|
||||||
return cdPath
|
|
||||||
|
|
||||||
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
|
// fileParentRelDir returns the parent directory of a file path, relative to
|
||||||
path, _ := args["path"].(string)
|
// workspace. Returns "" if the path is not under workspace or has no parent.
|
||||||
if path == "" {
|
func fileParentRelDir(filePath, workspace string) string {
|
||||||
return ""
|
ws := strings.TrimRight(workspace, "/\\")
|
||||||
|
if ws == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(filePath, ws)
|
||||||
|
if rest == filePath {
|
||||||
|
return "" // not under workspace
|
||||||
|
}
|
||||||
|
rest = strings.TrimLeft(rest, "/\\")
|
||||||
|
// Remove the filename — keep only the directory part
|
||||||
|
if idx := strings.LastIndexAny(rest, "/\\"); idx >= 0 {
|
||||||
|
return rest[:idx]
|
||||||
|
}
|
||||||
|
return "" // file is directly under workspace, no meaningful dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// commonDirPrefix computes the longest common directory prefix of two
|
||||||
|
// slash-separated paths. Returns "" if there is no common component.
|
||||||
|
func commonDirPrefix(a, b string) string {
|
||||||
|
partsA := strings.Split(a, "/")
|
||||||
|
partsB := strings.Split(b, "/")
|
||||||
|
n := len(partsA)
|
||||||
|
if len(partsB) < n {
|
||||||
|
n = len(partsB)
|
||||||
|
}
|
||||||
|
common := 0
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if partsA[i] != partsB[i] {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
ws := strings.TrimRight(workspace, "/\\")
|
common = i + 1
|
||||||
if ws == "" {
|
}
|
||||||
return ""
|
if common == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(partsA[:common], "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayProjectDir returns the project directory name for status display.
|
||||||
|
// Prefers the authoritative exec-based projectDir; falls back to the
|
||||||
|
// basename of the file-based common directory.
|
||||||
|
func displayProjectDir(task *activeTask) string {
|
||||||
|
if task.projectDir != "" {
|
||||||
|
return task.projectDir
|
||||||
|
}
|
||||||
|
if task.fileCommonDir != "" {
|
||||||
|
dir := task.fileCommonDir
|
||||||
|
if idx := strings.LastIndex(dir, "/"); idx >= 0 {
|
||||||
|
return dir[idx+1:]
|
||||||
}
|
}
|
||||||
rest := strings.TrimPrefix(path, ws)
|
return dir
|
||||||
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -1202,10 +1231,10 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri
|
||||||
sb.WriteByte('/')
|
sb.WriteByte('/')
|
||||||
sb.WriteString(strconv.Itoa(task.MaxIter))
|
sb.WriteString(strconv.Itoa(task.MaxIter))
|
||||||
sb.WriteString(")\n")
|
sb.WriteString(")\n")
|
||||||
// Workspace: prefer detected project dir, fall back to workspace basename
|
// Project directory: exec cd (authoritative) → file LCP → workspace basename
|
||||||
sb.WriteString("\U0001F4C1 ")
|
sb.WriteString("\U0001F4C1 ")
|
||||||
if task.projectDir != "" {
|
if dir := displayProjectDir(task); dir != "" {
|
||||||
sb.WriteString(task.projectDir)
|
sb.WriteString(dir)
|
||||||
} else if workspace != "" {
|
} else if workspace != "" {
|
||||||
project := strings.TrimRight(workspace, "/\\")
|
project := strings.TrimRight(workspace, "/\\")
|
||||||
if idx := strings.LastIndex(project, "/"); idx >= 0 {
|
if idx := strings.LastIndex(project, "/"); idx >= 0 {
|
||||||
|
|
@ -1544,9 +1573,21 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace),
|
ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace),
|
||||||
Result: "\u23F3",
|
Result: "\u23F3",
|
||||||
})
|
})
|
||||||
// Detect project directory from tool call args (once)
|
// Detect project directory
|
||||||
if task.projectDir == "" {
|
if task.projectDir == "" && tc.Name == "exec" {
|
||||||
task.projectDir = extractProjectDir(tc.Name, tc.Arguments, agent.Workspace)
|
task.projectDir = extractExecProjectDir(tc.Arguments)
|
||||||
|
}
|
||||||
|
switch tc.Name {
|
||||||
|
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
|
||||||
|
if p, _ := tc.Arguments["path"].(string); p != "" {
|
||||||
|
if rel := fileParentRelDir(p, agent.Workspace); rel != "" {
|
||||||
|
if task.fileCommonDir == "" {
|
||||||
|
task.fileCommonDir = rel
|
||||||
|
} else {
|
||||||
|
task.fileCommonDir = commonDirPrefix(task.fileCommonDir, rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
task.mu.Unlock()
|
task.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -1570,7 +1570,7 @@ func TestBuildRichStatus(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildRichStatus_ProjectDir(t *testing.T) {
|
func TestBuildRichStatus_ProjectDir(t *testing.T) {
|
||||||
// projectDir takes priority over workspace basename
|
// exec-based projectDir takes priority
|
||||||
task := &activeTask{
|
task := &activeTask{
|
||||||
Iteration: 1,
|
Iteration: 1,
|
||||||
MaxIter: 10,
|
MaxIter: 10,
|
||||||
|
|
@ -1581,14 +1581,25 @@ func TestBuildRichStatus_ProjectDir(t *testing.T) {
|
||||||
}
|
}
|
||||||
got := buildRichStatus(task, false, "/home/user/.picoclaw/workspace")
|
got := buildRichStatus(task, false, "/home/user/.picoclaw/workspace")
|
||||||
if !strings.Contains(got, "terra-py-form") {
|
if !strings.Contains(got, "terra-py-form") {
|
||||||
t.Errorf("expected projectDir 'terra-py-form' in output, got:\n%s", got)
|
t.Errorf("expected projectDir in output, got:\n%s", got)
|
||||||
}
|
|
||||||
if strings.Contains(got, "\U0001F4C1 workspace") {
|
|
||||||
t.Error("should not show workspace basename when projectDir is set")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: no projectDir, trailing slash should still work
|
// fileCommonDir fallback
|
||||||
task2 := &activeTask{
|
task2 := &activeTask{
|
||||||
|
Iteration: 1,
|
||||||
|
MaxIter: 10,
|
||||||
|
fileCommonDir: "projects/terra-py-form",
|
||||||
|
toolLog: []toolLogEntry{
|
||||||
|
{Name: "read_file", ArgsSnip: "src/main.py", Result: "✓ 0.1s"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got2 := buildRichStatus(task2, false, "/home/user/.picoclaw/workspace")
|
||||||
|
if !strings.Contains(got2, "terra-py-form") {
|
||||||
|
t.Errorf("expected fileCommonDir basename in output, got:\n%s", got2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// workspace basename fallback with trailing slash
|
||||||
|
task3 := &activeTask{
|
||||||
Iteration: 1,
|
Iteration: 1,
|
||||||
MaxIter: 10,
|
MaxIter: 10,
|
||||||
toolLog: []toolLogEntry{
|
toolLog: []toolLogEntry{
|
||||||
|
|
@ -1596,105 +1607,105 @@ func TestBuildRichStatus_ProjectDir(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, ws := range []string{"/home/user/my-project/", "/home/user/my-project"} {
|
for _, ws := range []string{"/home/user/my-project/", "/home/user/my-project"} {
|
||||||
got := buildRichStatus(task2, false, ws)
|
got := buildRichStatus(task3, false, ws)
|
||||||
if !strings.Contains(got, "my-project") {
|
if !strings.Contains(got, "my-project") {
|
||||||
t.Errorf("workspace %q: expected 'my-project' in output, got:\n%s", ws, got)
|
t.Errorf("workspace %q: expected 'my-project' in output, got:\n%s", ws, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractProjectDir(t *testing.T) {
|
func TestExtractExecProjectDir(t *testing.T) {
|
||||||
ws := "/home/user/.picoclaw/workspace"
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
toolName string
|
cmd string
|
||||||
args map[string]interface{}
|
want string
|
||||||
workspace string
|
|
||||||
want string
|
|
||||||
}{
|
}{
|
||||||
// exec: basename of cd target
|
{"cd deep path", "cd /ws/projects/terra-py-form && pytest", "terra-py-form"},
|
||||||
{
|
{"cd direct subdir", "cd /ws/my-app && make build", "my-app"},
|
||||||
name: "exec cd deep path",
|
{"cd trailing slash", "cd /ws/my-app/ && ls", "my-app"},
|
||||||
toolName: "exec",
|
{"cd to workspace", "cd /ws && ls", "ws"},
|
||||||
args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest"},
|
{"no cd prefix", "pytest tests/", ""},
|
||||||
workspace: ws,
|
{"empty command", "", ""},
|
||||||
want: "terra-py-form",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: "workspace",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exec no cd prefix",
|
|
||||||
toolName: "exec",
|
|
||||||
args: map[string]interface{}{"command": "pytest tests/"},
|
|
||||||
workspace: ws,
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
// file tools: first component after workspace
|
|
||||||
{
|
|
||||||
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: "projects",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "edit_file direct subdir",
|
|
||||||
toolName: "edit_file",
|
|
||||||
args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/my-app/README.md"},
|
|
||||||
workspace: ws,
|
|
||||||
want: "my-app",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "write_file at workspace root",
|
|
||||||
toolName: "write_file",
|
|
||||||
args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/notes.txt"},
|
|
||||||
workspace: ws,
|
|
||||||
want: "notes.txt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := extractProjectDir(tt.toolName, tt.args, tt.workspace)
|
args := map[string]interface{}{"command": tt.cmd}
|
||||||
|
got := extractExecProjectDir(args)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("extractProjectDir(%q, args, %q) = %q, want %q", tt.toolName, tt.workspace, got, tt.want)
|
t.Errorf("extractExecProjectDir(%q) = %q, want %q", tt.cmd, got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileParentRelDir(t *testing.T) {
|
||||||
|
ws := "/home/user/.picoclaw/workspace"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"deep path", ws + "/projects/terra/src/main.py", "projects/terra/src"},
|
||||||
|
{"direct subdir", ws + "/my-app/README.md", "my-app"},
|
||||||
|
{"workspace root file", ws + "/notes.txt", ""},
|
||||||
|
{"outside workspace", "/tmp/foo.txt", ""},
|
||||||
|
{"trailing slash ws", ws + "/projects/terra/src/main.py", "projects/terra/src"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := fileParentRelDir(tt.path, ws)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("fileParentRelDir(%q, ws) = %q, want %q", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonDirPrefix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
a, b string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"same dir", "projects/terra/src", "projects/terra/src", "projects/terra/src"},
|
||||||
|
{"converge to project", "projects/terra/src", "projects/terra/tests", "projects/terra"},
|
||||||
|
{"converge to top", "projects/terra/src", "projects/other/tests", "projects"},
|
||||||
|
{"no common", "aaa/bbb", "ccc/ddd", ""},
|
||||||
|
{"one is prefix", "projects/terra", "projects/terra/src", "projects/terra"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := commonDirPrefix(tt.a, tt.b)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("commonDirPrefix(%q, %q) = %q, want %q", tt.a, tt.b, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayProjectDir(t *testing.T) {
|
||||||
|
// exec projectDir wins
|
||||||
|
task1 := &activeTask{projectDir: "my-app", fileCommonDir: "projects/other"}
|
||||||
|
if got := displayProjectDir(task1); got != "my-app" {
|
||||||
|
t.Errorf("expected 'my-app', got %q", got)
|
||||||
|
}
|
||||||
|
// fileCommonDir fallback: basename
|
||||||
|
task2 := &activeTask{fileCommonDir: "projects/terra-py-form"}
|
||||||
|
if got := displayProjectDir(task2); got != "terra-py-form" {
|
||||||
|
t.Errorf("expected 'terra-py-form', got %q", got)
|
||||||
|
}
|
||||||
|
// single component
|
||||||
|
task3 := &activeTask{fileCommonDir: "my-app"}
|
||||||
|
if got := displayProjectDir(task3); got != "my-app" {
|
||||||
|
t.Errorf("expected 'my-app', got %q", got)
|
||||||
|
}
|
||||||
|
// empty
|
||||||
|
task4 := &activeTask{}
|
||||||
|
if got := displayProjectDir(task4); got != "" {
|
||||||
|
t.Errorf("expected empty, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildRichStatus_FixedHeight(t *testing.T) {
|
func TestBuildRichStatus_FixedHeight(t *testing.T) {
|
||||||
// Test that output has the same number of lines regardless of entry count
|
// Test that output has the same number of lines regardless of entry count
|
||||||
countLines := func(s string) int {
|
countLines := func(s string) int {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue