feat: add the relationship graph with extraction, corrections and explorer

Phase 11. Entities are mind-scoped nodes with canonical matching,
aliases and deterministic kind guessing (hostnames and hyphenated ids
are systems, capitalised names people, file suffixes documents).
Relationships carry a relation from the shared vocabulary, confidence,
source, reinforcement count and the event that produced them; links tie
entities to memories, beliefs, events, goals and predictions.

The perceive and attend handlers upsert the entities a message or
observation mentions and record co-mention edges cited to the event, so
the graph grows only from what was actually seen. Operators can merge
("these are the same": edges and links move, the old name becomes an
alias, the old id still resolves), record that two entities are
different, correct kinds and add typed edges; every correction is an
operator action and a corrections row.

The explorer renders a bounded subgraph on a canvas with a small
force-directed layout in plain JavaScript, with kind filters, focus on
a neighbourhood and a side panel per entity. API, entity page, CLI and
docs/graph.md.
This commit is contained in:
Andy Dixon
2026-06-29 19:28:55 +01:00
parent 703d080d13
commit 99a89ba847
25 changed files with 1455 additions and 22 deletions
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
"text/tabwriter"
)
func init() {
extraCommands = append(extraCommands, command{"entity", "list <mind> [query]|inspect <mind> <id>|merge <mind> <id> <other>|distinct <mind> <id> <other> – relationship graph", cmdEntity})
}
func cmdEntity(args []string) int {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: noemactl entity list <mind> [query] | inspect <mind> <id> | merge <mind> <id> <other> | distinct <mind> <id> <other>")
return exitUsage
}
mind := args[1]
switch args[0] {
case "list":
q := ""
if len(args) > 2 {
q = "?q=" + url.QueryEscape(strings.Join(args[2:], " "))
}
var out struct{ Items []map[string]any }
if code, err := apiRequest("GET", "/api/v1/minds/"+mind+"/entities"+q, nil, &out); err != nil {
return exitFor(code, err)
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "KIND\tNAME\tMENTIONS\tID")
for _, e := range out.Items {
fmt.Fprintf(tw, "%v\t%v\t%v\t%v\n", e["kind"], e["name"], e["mention_count"], e["id"])
}
tw.Flush()
return exitOK
case "inspect":
if len(args) < 3 {
return exitUsage
}
var d map[string]any
if code, err := apiRequest("GET", "/api/v1/minds/"+mind+"/entities/"+args[2], nil, &d); err != nil {
return exitFor(code, err)
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(d) //nolint:errcheck
return exitOK
case "merge", "distinct":
if len(args) < 4 {
return exitUsage
}
var d map[string]any
if code, err := apiRequest("POST", "/api/v1/minds/"+mind+"/entities/"+args[2]+"/"+args[0], map[string]any{"other": strings.Join(args[3:], " ")}, &d); err != nil {
return exitFor(code, err)
}
fmt.Println(args[0], "recorded")
return exitOK
}
return exitUsage
}
+10 -1
View File
@@ -25,6 +25,7 @@ import (
"github.com/andydixon/noema/internal/conversation"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/goals"
"github.com/andydixon/noema/internal/graph"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
"github.com/andydixon/noema/internal/memory"
@@ -130,6 +131,7 @@ func run() error {
deps.Motivation = motivation.NewStore(db)
deps.Predictions = predictions.NewStore(db)
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log, Metrics: metrics}
deps.Graph = graph.NewStore(db)
deps.Narrative = narrative.NewStore(db)
deps.Notifications = notifications.NewStore(db)
deps.Consolidation = &consolidation.Service{DB: db, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, LLM: deps.LLM, Prompts: deps.Prompts, Minds: deps.Minds, Log: log}
@@ -137,7 +139,7 @@ func run() error {
registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{}).AddProcess(cognition.Perceive{}).
AddProcess(cognition.GoalMonitor{}).AddProcess(cognition.Curiosity{}).AddProcess(cognition.Metacognition{}).
AddHook(cognition.MotivationHook{Store: deps.Motivation}).AddHook(cognition.NarrativeHook{Store: deps.Narrative}).AddHook(cognition.NotifyHook{Store: deps.Notifications})
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations}.Register(registry)
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations, Graph: deps.Graph}.Register(registry)
deps.Brain = brain.NewManager(brain.Config{DB: db, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(cfg.MaxWorkers),
Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs, LLM: deps.LLM, Prompts: deps.Prompts,
Goals: deps.Goals, Motivation: deps.Motivation, Predictions: deps.Predictions}}, group)
@@ -173,6 +175,13 @@ func run() error {
ResetState: func(r *http.Request, mind ids.ID) (motivation.State, error) { return srv.ResetState(r, mind) },
Consolidate: func(r *http.Request, mind ids.ID) (consolidation.Job, error) { return srv.RunConsolidation(r, mind) },
Reflect: func(r *http.Request, mind ids.ID) (reflection.Reflection, error) { return srv.RunReflection(r, mind) },
Entity: func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error) { return srv.EntityDetail(r, mind, id) },
Merge: func(r *http.Request, mind ids.ID, e graph.Entity, other string) error {
return srv.MergeEntities(r, mind, e, other)
},
Distinct: func(r *http.Request, mind ids.ID, e graph.Entity, other string) error {
return srv.DistinctEntities(r, mind, e, other)
},
})
if err != nil {
return err