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:
dj-oyu 2026-02-22 06:50:03 +09:00
parent e6e8482613
commit 5ae2ece515
2 changed files with 192 additions and 140 deletions

View file

@ -44,8 +44,9 @@ type activeTask struct {
interrupt chan string // buffered 1, for user message injection
toolLog []toolLogEntry
lastError *toolLogEntry // sticky: most recent error, persists across iterations
projectDir string // detected project directory name (from cd prefix)
mu sync.Mutex
projectDir string // detected from exec cd target (authoritative)
fileCommonDir string // LCP of file paths relative to workspace (fallback)
mu sync.Mutex
}
// 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.
var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`)
// 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":
cmd, _ := args["command"].(string)
if cmd == "" {
return ""
}
m := cdPrefixPattern.FindStringSubmatch(cmd)
if len(m) < 2 {
return ""
}
// 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
// extractExecProjectDir extracts the basename of an exec cd target.
// Returns "" if the command has no cd prefix.
func extractExecProjectDir(args map[string]interface{}) string {
cmd, _ := args["command"].(string)
if cmd == "" {
return ""
}
m := cdPrefixPattern.FindStringSubmatch(cmd)
if len(m) < 2 {
return ""
}
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 ""
// fileParentRelDir returns the parent directory of a file path, relative to
// workspace. Returns "" if the path is not under workspace or has no parent.
func fileParentRelDir(filePath, workspace string) string {
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, "/\\")
if ws == "" {
return ""
common = i + 1
}
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)
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 dir
}
return ""
}
@ -1202,10 +1231,10 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri
sb.WriteByte('/')
sb.WriteString(strconv.Itoa(task.MaxIter))
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 ")
if task.projectDir != "" {
sb.WriteString(task.projectDir)
if dir := displayProjectDir(task); dir != "" {
sb.WriteString(dir)
} else if workspace != "" {
project := strings.TrimRight(workspace, "/\\")
if idx := strings.LastIndex(project, "/"); idx >= 0 {
@ -1544,9 +1573,21 @@ func (al *AgentLoop) runLLMIteration(
ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace),
Result: "\u23F3",
})
// Detect project directory from tool call args (once)
if task.projectDir == "" {
task.projectDir = extractProjectDir(tc.Name, tc.Arguments, agent.Workspace)
// Detect project directory
if task.projectDir == "" && tc.Name == "exec" {
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()

View file

@ -1570,7 +1570,7 @@ func TestBuildRichStatus(t *testing.T) {
}
func TestBuildRichStatus_ProjectDir(t *testing.T) {
// projectDir takes priority over workspace basename
// exec-based projectDir takes priority
task := &activeTask{
Iteration: 1,
MaxIter: 10,
@ -1581,14 +1581,25 @@ func TestBuildRichStatus_ProjectDir(t *testing.T) {
}
got := buildRichStatus(task, false, "/home/user/.picoclaw/workspace")
if !strings.Contains(got, "terra-py-form") {
t.Errorf("expected projectDir 'terra-py-form' in output, got:\n%s", got)
}
if strings.Contains(got, "\U0001F4C1 workspace") {
t.Error("should not show workspace basename when projectDir is set")
t.Errorf("expected projectDir in output, got:\n%s", got)
}
// Fallback: no projectDir, trailing slash should still work
// fileCommonDir fallback
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,
MaxIter: 10,
toolLog: []toolLogEntry{
@ -1596,105 +1607,105 @@ func TestBuildRichStatus_ProjectDir(t *testing.T) {
},
}
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") {
t.Errorf("workspace %q: expected 'my-project' in output, got:\n%s", ws, got)
}
}
}
func TestExtractProjectDir(t *testing.T) {
ws := "/home/user/.picoclaw/workspace"
func TestExtractExecProjectDir(t *testing.T) {
tests := []struct {
name string
toolName string
args map[string]interface{}
workspace string
want string
name string
cmd string
want string
}{
// exec: basename of cd target
{
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 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: "",
},
{"cd deep path", "cd /ws/projects/terra-py-form && pytest", "terra-py-form"},
{"cd direct subdir", "cd /ws/my-app && make build", "my-app"},
{"cd trailing slash", "cd /ws/my-app/ && ls", "my-app"},
{"cd to workspace", "cd /ws && ls", "ws"},
{"no cd prefix", "pytest tests/", ""},
{"empty command", "", ""},
}
for _, tt := range tests {
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 {
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) {
// Test that output has the same number of lines regardless of entry count
countLines := func(s string) int {