Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 27 additions & 13 deletions internal/brain/brain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 := ""
Expand All @@ -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() {
Expand All @@ -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":
Expand All @@ -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 "$@").
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down
79 changes: 79 additions & 0 deletions internal/brain/local_flag_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading