diff --git a/ingestion/internal/search/search.go b/ingestion/internal/search/search.go index 6e20b41..071fc46 100644 --- a/ingestion/internal/search/search.go +++ b/ingestion/internal/search/search.go @@ -33,46 +33,52 @@ func Query(brainDir, query string, limit int) ([]Result, error) { var results []Result - err := filepath.WalkDir(filepath.Join(brainDir, "knowledge"), func(path string, d os.DirEntry, err error) error { - if err != nil { - slog.Warn("search: skipping path", "path", path, "err", err) - return nil - } - if d.IsDir() || !strings.HasSuffix(path, ".md") { - return nil + for _, subdir := range []string{"knowledge", "wiki"} { + dir := filepath.Join(brainDir, subdir) + if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { + continue } + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + slog.Warn("search: skipping path", "path", path, "err", err) + return nil + } + if d.IsDir() || !strings.HasSuffix(path, ".md") { + return nil + } - content, err := os.ReadFile(path) - if err != nil { - slog.Warn("search: skipping unreadable file", "path", path, "err", err) + content, err := os.ReadFile(path) + if err != nil { + slog.Warn("search: skipping unreadable file", "path", path, "err", err) + return nil + } + + lower := strings.ToLower(string(content)) + score := 0 + for _, term := range terms { + score += strings.Count(lower, term) + } + if score == 0 { + return nil + } + + rel, err := filepath.Rel(brainDir, path) + if err != nil { + return fmt.Errorf("rel path: %w", err) + } + rel = filepath.ToSlash(rel) + + results = append(results, Result{ + Path: rel, + Title: extractTitle(string(content), d.Name()), + Excerpt: excerpt(string(content), 300), + Score: score, + }) return nil - } - - lower := strings.ToLower(string(content)) - score := 0 - for _, term := range terms { - score += strings.Count(lower, term) - } - if score == 0 { - return nil - } - - rel, err := filepath.Rel(brainDir, path) - if err != nil { - return fmt.Errorf("rel path: %w", err) - } - rel = filepath.ToSlash(rel) - - results = append(results, Result{ - Path: rel, - Title: extractTitle(string(content), d.Name()), - Excerpt: excerpt(string(content), 300), - Score: score, }) - return nil - }) - if err != nil { - return nil, err + if err != nil { + return nil, err + } } sort.Slice(results, func(i, j int) bool { diff --git a/internal/skills/brain/handlers.go b/internal/skills/brain/handlers.go index fb7584e..c6a825d 100644 --- a/internal/skills/brain/handlers.go +++ b/internal/skills/brain/handlers.go @@ -10,13 +10,17 @@ import ( "net/http" ) -// Handle dispatches brain_query and brain_write tool calls. +// Handle dispatches brain tool calls. func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { switch tool { case "brain_query": return s.query(ctx, args) case "brain_write": return s.write(ctx, args) + case "brain_ingest": + return s.ingest(ctx, args) + case "brain_search": + return s.search(ctx, args) default: return nil, fmt.Errorf("unknown brain tool: %s", tool) } @@ -59,12 +63,62 @@ func (s *Skill) write(ctx context.Context, args json.RawMessage) (json.RawMessag return s.post(ctx, "/write", a) } +type ingestArgs struct { + Content string `json:"content"` + Source string `json:"source"` + DryRun bool `json:"dry_run,omitempty"` +} + +func (s *Skill) ingest(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a ingestArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if a.Content == "" { + return nil, fmt.Errorf("content is required") + } + if a.Source == "" { + return nil, fmt.Errorf("source is required") + } + if s.cfg.IngestSvcURL == "" { + return nil, fmt.Errorf("brain_ingest: INGEST_SVC_URL not configured") + } + return s.postTo(ctx, s.cfg.IngestSvcURL+"/ingest", a) +} + +type searchArgs struct { + Query string `json:"query"` + Collection string `json:"collection,omitempty"` + Limit int `json:"limit,omitempty"` +} + +func (s *Skill) search(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a searchArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if a.Query == "" { + return nil, fmt.Errorf("query is required") + } + if a.Limit == 0 { + a.Limit = 5 + } + if s.cfg.KBRetrievalURL == "" { + return nil, fmt.Errorf("brain_search: KB_RETRIEVAL_URL not configured") + } + return s.postTo(ctx, s.cfg.KBRetrievalURL+"/api/v1/search", a) +} + func (s *Skill) post(ctx context.Context, path string, body any) (json.RawMessage, error) { + return s.postTo(ctx, s.cfg.IngestBaseURL+path, body) +} + +func (s *Skill) postTo(ctx context.Context, url string, body any) (json.RawMessage, error) { b, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.IngestBaseURL+path, bytes.NewReader(b)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) if err != nil { return nil, fmt.Errorf("build request: %w", err) } diff --git a/internal/skills/brain/skill.go b/internal/skills/brain/skill.go index b598e24..05a9a60 100644 --- a/internal/skills/brain/skill.go +++ b/internal/skills/brain/skill.go @@ -9,7 +9,9 @@ import ( // Config holds brain skill configuration. type Config struct { - IngestBaseURL string // base URL of the ingestion HTTP server, e.g. http://localhost:3300 + IngestBaseURL string // base URL of the ingestion HTTP server (brain_query, brain_write) + IngestSvcURL string // base URL of the ingestion-svc HTTP server (brain_ingest) + KBRetrievalURL string // base URL of the kb-retrieval server (brain_search) } // Skill implements registry.Skill for brain_query and brain_write. @@ -32,10 +34,10 @@ func (s *Skill) Tools() []registry.ToolDef { str := map[string]any{"type": "string"} num := map[string]any{"type": "integer"} - return []registry.ToolDef{ + tools := []registry.ToolDef{ { Name: "brain_query", - Description: "Search the hyperguild brain wiki for relevant knowledge. Call this before starting any significant task.", + Description: "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files. Fast, no embeddings needed. Call before any significant task.", InputSchema: schema([]string{"query"}, map[string]any{ "query": str, "limit": num, @@ -43,7 +45,7 @@ func (s *Skill) Tools() []registry.ToolDef { }, { Name: "brain_write", - Description: "Write a raw knowledge note to the brain for later ingestion into the wiki.", + Description: "Write a raw knowledge note to brain/knowledge/ for later ingestion.", InputSchema: schema([]string{"content"}, map[string]any{ "content": str, "type": str, @@ -52,4 +54,27 @@ func (s *Skill) Tools() []registry.ToolDef { }), }, } + if s.cfg.IngestSvcURL != "" { + tools = append(tools, registry.ToolDef{ + Name: "brain_ingest", + Description: "Ingest text content into the brain wiki (brain/wiki/). Calls an LLM to produce structured wiki pages. Use for substantial documents, articles, or knowledge worth structuring. Returns the list of wiki pages written.", + InputSchema: schema([]string{"content", "source"}, map[string]any{ + "content": str, + "source": map[string]any{"type": "string", "description": "human-readable name for the content, e.g. 'article-on-raft.md'"}, + "dry_run": map[string]any{"type": "boolean"}, + }), + }) + } + if s.cfg.KBRetrievalURL != "" { + tools = append(tools, registry.ToolDef{ + Name: "brain_search", + Description: "Semantic vector search across the brain wiki using embeddings. Use when brain_query returns no results or you need conceptually-related results rather than keyword matches.", + InputSchema: schema([]string{"query"}, map[string]any{ + "query": str, + "collection": str, + "limit": num, + }), + }) + } + return tools }