Add Phase 4 knowledge graph: nodes, cells, facts, edges, gRPC service

Build the knowledge graph pillar with the kg package:
- Node: hierarchical notes with parent/children, C2 wiki-style naming,
  shared tag/category pool with artifacts
- Cell: content units (markdown, code, plain) with ordinal ordering
- Fact: EAV tuples with transaction timestamps and retraction support
- Edge: directed graph links (child, parent, related, artifact_link)

Includes schema migration (002_knowledge_graph.sql), protobuf definitions
(kg.proto), full gRPC KnowledgeGraphService implementation, CLI commands
(node create/get), and comprehensive test coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 10:05:43 -07:00
parent a336dc1ebb
commit 051a85d846
14 changed files with 3283 additions and 6 deletions

View File

@@ -29,6 +29,8 @@ Commands:
cat add <category> [...] Create categories
cat list List all categories
search tag <tag> Search artifacts by tag
node create <name> Create a knowledge graph node
node get <id> Get a node with its cells
version Print version
Environment:
@@ -53,13 +55,15 @@ func main() {
runCat(os.Args[2:])
case "search":
runSearch(os.Args[2:])
case "node":
runNode(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
usage()
}
}
func dial() pb.ArtifactServiceClient {
func dialConn() *grpc.ClientConn {
addr := os.Getenv("EXO_ADDR")
if addr == "" {
addr = "localhost:9090"
@@ -70,8 +74,15 @@ func dial() pb.ArtifactServiceClient {
if err != nil {
log.Fatalf("exo: failed to connect to %s: %v", addr, err)
}
// Connection will be closed when the process exits.
return pb.NewArtifactServiceClient(conn)
return conn
}
func dial() pb.ArtifactServiceClient {
return pb.NewArtifactServiceClient(dialConn())
}
func dialKG() pb.KnowledgeGraphServiceClient {
return pb.NewKnowledgeGraphServiceClient(dialConn())
}
func runImport(args []string) {
@@ -203,3 +214,62 @@ func runSearch(args []string) {
os.Exit(1)
}
}
func runNode(args []string) {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: exo node <create|get> [...]")
os.Exit(1)
}
client := dialKG()
ctx := context.Background()
switch args[0] {
case "create":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: exo node create <name>")
os.Exit(1)
}
resp, err := client.CreateNode(ctx, &pb.CreateNodeRequest{
Name: args[1],
Type: "note",
})
if err != nil {
log.Fatalf("exo: failed to create node: %v", err)
}
fmt.Printf("created node: %s\n", resp.Id)
case "get":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: exo node get <id>")
os.Exit(1)
}
resp, err := client.GetNode(ctx, &pb.GetNodeRequest{Id: args[1]})
if err != nil {
log.Fatalf("exo: failed to get node: %v", err)
}
n := resp.Node
fmt.Printf("ID: %s\n", n.Id)
fmt.Printf("Name: %s\n", n.Name)
fmt.Printf("Type: %s\n", n.Type)
fmt.Printf("Parent: %s\n", n.ParentId)
fmt.Printf("Created: %s\n", n.Created)
fmt.Printf("Modified: %s\n", n.Modified)
if len(n.Children) > 0 {
fmt.Printf("Children: %v\n", n.Children)
}
if len(n.Tags) > 0 {
fmt.Printf("Tags: %v\n", n.Tags)
}
if len(resp.Cells) > 0 {
fmt.Printf("Cells: %d\n", len(resp.Cells))
for _, c := range resp.Cells {
fmt.Printf(" [%d] %s (%s): %s\n", c.Ordinal, c.Id, c.Type, string(c.Contents))
}
}
default:
fmt.Fprintf(os.Stderr, "unknown node subcommand: %s\n", args[0])
os.Exit(1)
}
}

View File

@@ -71,6 +71,7 @@ func main() {
grpcServer := grpc.NewServer()
pb.RegisterArtifactServiceServer(grpcServer, server.NewArtifactServer(database, blobStore))
pb.RegisterKnowledgeGraphServiceServer(grpcServer, server.NewKGServer(database))
// Graceful shutdown on SIGINT/SIGTERM.
sigCh := make(chan os.Signal, 1)