feat(brain): structured wing/hall taxonomy + obsidian-compatible layout
Adds a two-dimensional address (wing, hall) to brain notes. A wing is a
topic domain (e.g. jepa-fx, hyperguild); a hall is one of a closed
vocabulary of memory types (facts, decisions, failures, hypotheses,
sources). Notes route to brain/wiki/<wing>/<hall>/<slug>.md with
wing/hall/created_at YAML frontmatter, making the directory a valid
Obsidian vault.
Changes:
- new package ingestion/internal/brain (NotePath, ValidHalls, Sanitise,
BuildWingIndex, BuildAllWingIndexes)
- api.WriteNote refactored to WriteNoteOptions; wing+hall routes to
brain/wiki/, otherwise falls back to brain/knowledge/ (legacy)
- search.Query → QueryOptions with optional Wing/Hall filtering; Result
carries wing/hall extracted from frontmatter or path segments
- MCP tools brain_write and brain_query gain optional wing/hall params
(hall enum-validated); new brain_index tool regenerates _index.md MOC
- POST /index REST endpoint mirrors brain_index
- brain_write auto-rebuilds the wing's _index.md after a wing+hall write
- scripts/migrate-brain-halls.sh migrates flat brain/wiki/{concepts,entities}/
into the new layout (dry-run by default, --commit applies)
All existing tests pass; new tests cover wing/hall write routing, scope
filtering, invalid hall rejection, _index.md generation, and migration
script paths.
Closes hyperguild#1.
This commit is contained in:
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
@@ -24,6 +26,10 @@ func (s *Server) tools() []map[string]any {
|
||||
int_ := func(desc string) map[string]any {
|
||||
return map[string]any{"type": "integer", "description": desc}
|
||||
}
|
||||
enum := func(desc string, vals ...string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": desc, "enum": vals}
|
||||
}
|
||||
halls := []string{"facts", "decisions", "failures", "hypotheses", "sources"}
|
||||
schema := func(required []string, props map[string]any) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"type": "object", "required": required, "properties": props,
|
||||
@@ -34,20 +40,31 @@ func (s *Server) tools() []map[string]any {
|
||||
return []map[string]any{
|
||||
{
|
||||
"name": "brain_query",
|
||||
"description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files.",
|
||||
"description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files. Optionally scope by wing (topic domain) and hall (memory type).",
|
||||
"inputSchema": schema([]string{"query"}, map[string]any{
|
||||
"query": str("search terms"),
|
||||
"limit": int_("max results, default 5"),
|
||||
"wing": str("optional wing to scope to, e.g. jepa-fx"),
|
||||
"hall": enum("optional hall to scope to (requires wing)", halls...),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_write",
|
||||
"description": "Write a raw knowledge note to brain/knowledge/.",
|
||||
"description": "Write a markdown note to the brain. With wing+hall set, routes to brain/wiki/<wing>/<hall>/ with wing/hall/created_at frontmatter; otherwise writes to brain/knowledge/ (legacy).",
|
||||
"inputSchema": schema([]string{"content"}, map[string]any{
|
||||
"content": str("markdown content"),
|
||||
"filename": str("optional filename"),
|
||||
"type": str("optional frontmatter type"),
|
||||
"domain": str("optional frontmatter domain"),
|
||||
"filename": str("optional filename or slug"),
|
||||
"type": str("optional frontmatter type (legacy)"),
|
||||
"domain": str("optional frontmatter domain (legacy)"),
|
||||
"wing": str("optional topic domain, e.g. jepa-fx"),
|
||||
"hall": enum("optional memory type (requires wing)", halls...),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_index",
|
||||
"description": "Regenerate _index.md (Map of Content) for one or all wings under brain/wiki/. Auto-called after brain_write with wing+hall.",
|
||||
"inputSchema": schema([]string{}, map[string]any{
|
||||
"wing": str("optional wing to index; if absent, rebuilds every wing"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -104,6 +121,8 @@ func (s *Server) tools() []map[string]any {
|
||||
type brainQueryArgs struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Wing string `json:"wing,omitempty"`
|
||||
Hall string `json:"hall,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
@@ -117,7 +136,12 @@ func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.Raw
|
||||
if a.Limit == 0 {
|
||||
a.Limit = 5
|
||||
}
|
||||
results, err := search.Query(s.brainDir, a.Query, a.Limit)
|
||||
results, err := search.Query(s.brainDir, search.QueryOptions{
|
||||
Query: a.Query,
|
||||
Limit: a.Limit,
|
||||
Wing: a.Wing,
|
||||
Hall: a.Hall,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: %w", err)
|
||||
}
|
||||
@@ -129,6 +153,8 @@ type brainWriteArgs struct {
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Wing string `json:"wing,omitempty"`
|
||||
Hall string `json:"hall,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
@@ -136,13 +162,51 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
relPath, err := api.WriteNote(s.brainDir, a.Content, a.Filename, a.Type, a.Domain)
|
||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||
Content: a.Content,
|
||||
Filename: a.Filename,
|
||||
Type: a.Type,
|
||||
Domain: a.Domain,
|
||||
Wing: a.Wing,
|
||||
Hall: a.Hall,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Auto-regenerate the wing _index.md when the write landed in the
|
||||
// structured wiki. A failure here is best-effort — log and move on,
|
||||
// since the note itself is already written.
|
||||
if a.Wing != "" && a.Hall != "" {
|
||||
if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil {
|
||||
slog.Warn("brain_write: auto-index failed", "wing", a.Wing, "err", err)
|
||||
}
|
||||
}
|
||||
return json.Marshal(map[string]string{"path": relPath})
|
||||
}
|
||||
|
||||
type brainIndexArgs struct {
|
||||
Wing string `json:"wing,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) brainIndex(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainIndexArgs
|
||||
if len(args) > 0 {
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
}
|
||||
if a.Wing == "" {
|
||||
if err := brain.BuildAllWingIndexes(s.brainDir); err != nil {
|
||||
return nil, fmt.Errorf("index: %w", err)
|
||||
}
|
||||
return json.Marshal(map[string]any{"status": "ok", "scope": "all"})
|
||||
}
|
||||
if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil {
|
||||
return nil, fmt.Errorf("index: %w", err)
|
||||
}
|
||||
return json.Marshal(map[string]any{"status": "ok", "scope": a.Wing})
|
||||
}
|
||||
|
||||
type brainIngestRawArgs struct {
|
||||
Source string `json:"source"`
|
||||
Pages []pipeline.RawPage `json:"pages"`
|
||||
|
||||
Reference in New Issue
Block a user