diff --git a/README.md b/README.md index 3948fe8..f196659 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ devbrain rebuild # re-indexes pages and embeds stale entries ``` `devbrain brain search/query` ranks the current repo first and keeps at most two -cross-project results. Add `--global` to preserve the engine's all-project ordering. +cross-project results. Add `--global` to preserve the engine's all-project ordering, or `--local` to return only the current project's pages. ### Multiple Brains diff --git a/internal/brain/brain.go b/internal/brain/brain.go index b9a16ff..bcd638c 100644 --- a/internal/brain/brain.go +++ b/internal/brain/brain.go @@ -40,7 +40,8 @@ func gbrainPath() string { } // Run is the one public brain entry point. Retrieval is current-project-first; -// --global preserves the engine's original all-project ordering. Other verbs +// --global preserves the engine's original all-project ordering; --local drops +// the cross-project padding and returns current-project pages only. Other verbs // pass through unchanged when gbrain is installed. func Run(args []string, stdout, stderr io.Writer, stdin io.Reader) int { data, err := config.ResolveDataDir() @@ -53,8 +54,13 @@ func Run(args []string, stdout, stderr io.Writer, stdin io.Reader) int { sub, rest = args[0], args[1:] } retrieval := sub == "search" || sub == "query" || sub == "ask" - global, cleanRest := stripGlobal(rest) + global, rest1 := stripFlag(rest, "--global") + localOnly, cleanRest := stripFlag(rest1, "--local") if retrieval { + if global && localOnly { + fmt.Fprintln(stderr, "brain: --local and --global are exclusive") + return 1 + } args = append([]string{sub}, cleanRest...) } project := "" @@ -75,7 +81,7 @@ func Run(args []string, stdout, stderr io.Writer, stdin io.Reader) int { if gb := gbrainPath(); gb != "" { if retrieval && project != "" { - return projectFirstPassthrough(gb, args, project, stdout, stderr, stdin) + return projectFirstPassthrough(gb, args, project, localOnly, stdout, stderr, stdin) } code := passthrough(gb, args, stdout, stderr, stdin) if code == 0 && gbrainSubcommand(args) == "put" && hasOpenAIKey() { @@ -88,7 +94,7 @@ func Run(args []string, stdout, stderr io.Writer, stdin io.Reader) int { if config.MultipleBrains() && sub != "search" { fmt.Fprintln(stderr, "brain: named brains use isolated keyword search; the shared semantic index is disabled") } - return fallbackSearch(data, cleanRest, project, stdout) + return fallbackSearch(data, cleanRest, project, localOnly, stdout) case "get": return fallbackGet(data, rest, stdout, stderr) case "put", "tag", "embed", "link", "import", "sync", "delete": @@ -111,17 +117,19 @@ func Run(args []string, stdout, stderr io.Writer, stdin io.Reader) int { } } -func stripGlobal(args []string) (bool, []string) { +// stripFlag removes every occurrence of a devbrain-only retrieval flag from +// args and reports whether it was present, so it never reaches the engine. +func stripFlag(args []string, flag string) (bool, []string) { clean := make([]string, 0, len(args)) - global := false + found := false for _, arg := range args { - if arg == "--global" { - global = true + if arg == flag { + found = true continue } clean = append(clean, arg) } - return global, clean + return found, clean } // passthrough hands the whole call to the real gbrain (exec gbrain "$@"). @@ -193,7 +201,7 @@ type engineResult struct { text string } -func projectFirstPassthrough(gb string, args []string, project string, stdout, stderr io.Writer, stdin io.Reader) int { +func projectFirstPassthrough(gb string, args []string, project string, localOnly bool, stdout, stderr io.Writer, stdin io.Reader) int { limit := retrievalLimit(args[1:]) fetchArgs := withRetrievalLimit(args, 100) var out bytes.Buffer @@ -203,7 +211,7 @@ func projectFirstPassthrough(gb string, args []string, project string, stdout, s _, _ = io.Copy(stdout, &out) return code } - for _, result := range prioritizeEngineResults(results, project, limit) { + for _, result := range prioritizeEngineResults(results, project, limit, localOnly) { _, _ = io.WriteString(stdout, result.text) } return code @@ -250,7 +258,7 @@ func parseEngineResults(out string) []engineResult { return results } -func prioritizeEngineResults(results []engineResult, project string, limit int) []engineResult { +func prioritizeEngineResults(results []engineResult, project string, limit int, localOnly bool) []engineResult { seen := make(map[string]bool) var localCurated, localLogs, crossCurated, crossLogs []engineResult for _, result := range results { @@ -273,6 +281,9 @@ func prioritizeEngineResults(results []engineResult, project string, limit int) } local := append(localCurated, localLogs...) cross := append(crossCurated, crossLogs...) + if localOnly { + cross = nil + } return projectFirstSlice(local, cross, limit) } @@ -383,7 +394,7 @@ func (h hit) line() string { // fallbackSearch: OR-keyword scoring — pages ranked by how many DISTINCT // terms they hit, then total line hits, with the same project-first policy as // the ranked engine. -func fallbackSearch(data string, args []string, project string, stdout io.Writer) int { +func fallbackSearch(data string, args []string, project string, localOnly bool, stdout io.Writer) int { limit := retrievalLimit(args) terms := searchTerms(strings.Join(withoutRetrievalOptions(args), " ")) if len(terms) == 0 { @@ -452,6 +463,9 @@ func fallbackSearch(data string, args []string, project string, stdout io.Writer cross = append(cross, h) } } + if localOnly { + cross = nil + } hits = projectFirstSlice(local, cross, limit) } else if len(hits) > limit { hits = hits[:limit] diff --git a/internal/brain/local_flag_test.go b/internal/brain/local_flag_test.go new file mode 100644 index 0000000..9fea929 --- /dev/null +++ b/internal/brain/local_flag_test.go @@ -0,0 +1,79 @@ +package brain_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/TheWeiHu/devbrain/internal/clitest" +) + +// --local keeps retrieval to the current project: no cross-project padding, +// and the flag is consumed here rather than forwarded to the engine. +func TestBrainSearchLocalFlagEngine(t *testing.T) { + h := clitest.New(t) + h.Project = "owner__alpha" + stub := filepath.Join(t.TempDir(), "gbrain-stub") + clitest.WriteExec(t, stub, `#!/bin/sh +case "$1" in + search|query|ask) + case "$*" in *--local*) echo 'engine rejected --local'; exit 2;; esac + printf '%s\n' \ + '[0.9900] projects/owner__beta/brain/guide -- beta curated' \ + '[0.9800] projects/owner__alpha/brain/guide -- alpha curated' \ + '[0.9700] projects/owner__alpha/brain/setup -- alpha setup' \ + '[0.9600] projects/owner__gamma/brain/guide -- gamma curated' + ;; + *) printf 'stub:%s\n' "$*" ;; +esac +`) + h.Env["DEVBRAIN_GBRAIN"] = stub + + res := h.Run("brain", "search", "guide", "--local") + lines := resultLines(res.Stdout) + if res.Code != 0 || len(lines) != 2 { + t.Fatalf("--local: code=%d results=%d, want 0 and the 2 current-project pages:\n%s", res.Code, len(lines), res.Stdout) + } + for _, l := range lines { + if !strings.Contains(l, "owner__alpha/") { + t.Errorf("--local leaked a cross-project result: %s", l) + } + } + // the default is unchanged: local first, then up to two cross-project hits + if def := h.Run("brain", "search", "guide").Stdout; len(resultLines(def)) != 4 { + t.Errorf("default search changed shape:\n%s", def) + } +} + +// The offline fallback honors --local the same way, and the two scope flags +// cannot be combined. +func TestBrainSearchLocalFlagOffline(t *testing.T) { + h := clitest.New(t) + h.Project = "owner__alpha" + for _, p := range []struct{ project, body string }{ + {"owner__alpha", "# Install\nHow to install the widget daemon.\n"}, + {"owner__beta", "# Install\nBeta notes on the widget daemon.\n"}, + } { + dir := filepath.Join(h.Data, "projects", p.project, "brain") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + clitest.WriteFile(t, filepath.Join(dir, "install.md"), p.body) + } + h.Env["DEVBRAIN_GBRAIN"] = "gbrain-offline-stub-not-a-real-binary" + if _, err := exec.LookPath("gbrain-offline-stub-not-a-real-binary"); err == nil { + t.Skip("skip: could not remove gbrain from PATH for offline test") + } + out := h.Run("brain", "search", "widget", "daemon", "--local").Stdout + if lines := resultLines(out); len(lines) != 1 || !strings.Contains(lines[0], "owner__alpha/install") { + t.Errorf("offline --local: want only owner__alpha/install, got:\n%s", out) + } + if def := h.Run("brain", "search", "widget", "daemon").Stdout; !strings.Contains(def, "owner__beta/install") { + t.Errorf("default offline search must still include the cross-project page:\n%s", def) + } + if both := h.Run("brain", "search", "widget", "--local", "--global"); both.Code == 0 { + t.Errorf("--local with --global must be rejected, got code 0:\n%s", both.Stdout) + } +}