feat(brain): add brain_ingest, brain_search tools and extend search to wiki/
This commit is contained in:
@@ -33,46 +33,52 @@ func Query(brainDir, query string, limit int) ([]Result, error) {
|
|||||||
|
|
||||||
var results []Result
|
var results []Result
|
||||||
|
|
||||||
err := filepath.WalkDir(filepath.Join(brainDir, "knowledge"), func(path string, d os.DirEntry, err error) error {
|
for _, subdir := range []string{"knowledge", "wiki"} {
|
||||||
if err != nil {
|
dir := filepath.Join(brainDir, subdir)
|
||||||
slog.Warn("search: skipping path", "path", path, "err", err)
|
if _, statErr := os.Stat(dir); os.IsNotExist(statErr) {
|
||||||
return nil
|
continue
|
||||||
}
|
|
||||||
if d.IsDir() || !strings.HasSuffix(path, ".md") {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
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)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("search: skipping unreadable file", "path", path, "err", err)
|
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
|
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 {
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import (
|
|||||||
"net/http"
|
"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) {
|
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
||||||
switch tool {
|
switch tool {
|
||||||
case "brain_query":
|
case "brain_query":
|
||||||
return s.query(ctx, args)
|
return s.query(ctx, args)
|
||||||
case "brain_write":
|
case "brain_write":
|
||||||
return s.write(ctx, args)
|
return s.write(ctx, args)
|
||||||
|
case "brain_ingest":
|
||||||
|
return s.ingest(ctx, args)
|
||||||
|
case "brain_search":
|
||||||
|
return s.search(ctx, args)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown brain tool: %s", tool)
|
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)
|
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) {
|
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)
|
b, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal request: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build request: %w", err)
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
|
|
||||||
// Config holds brain skill configuration.
|
// Config holds brain skill configuration.
|
||||||
type Config struct {
|
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.
|
// 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"}
|
str := map[string]any{"type": "string"}
|
||||||
num := map[string]any{"type": "integer"}
|
num := map[string]any{"type": "integer"}
|
||||||
|
|
||||||
return []registry.ToolDef{
|
tools := []registry.ToolDef{
|
||||||
{
|
{
|
||||||
Name: "brain_query",
|
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{
|
InputSchema: schema([]string{"query"}, map[string]any{
|
||||||
"query": str,
|
"query": str,
|
||||||
"limit": num,
|
"limit": num,
|
||||||
@@ -43,7 +45,7 @@ func (s *Skill) Tools() []registry.ToolDef {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "brain_write",
|
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{
|
InputSchema: schema([]string{"content"}, map[string]any{
|
||||||
"content": str,
|
"content": str,
|
||||||
"type": 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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user