diff --git a/cmd/gateway.go b/cmd/gateway.go index 060f7dee..d1f79c0b 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -118,7 +118,8 @@ func runGateway() { // Memory tools — PG-backed; always registered (PG memory is always available) toolsReg.Register(tools.NewMemorySearchTool()) toolsReg.Register(tools.NewMemoryGetTool()) - slog.Info("memory tools registered (PG-backed)") + toolsReg.Register(tools.NewKnowledgeGraphSearchTool()) + slog.Info("memory + knowledge graph tools registered (PG-backed)") // Browser automation tool var browserMgr *browser.Manager @@ -623,6 +624,11 @@ func runGateway() { server.SetMemoryHandler(httpapi.NewMemoryHandler(pgStores.Memory, cfg.Gateway.Token)) } + // Knowledge graph API + if pgStores != nil && pgStores.KnowledgeGraph != nil { + server.SetKnowledgeGraphHandler(httpapi.NewKnowledgeGraphHandler(pgStores.KnowledgeGraph, providerRegistry, cfg.Gateway.Token)) + } + // Workspace file serving endpoint — serves files by absolute path, auth-token protected. // Supports media from any agent workspace (each agent has its own workspace from DB). server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token)) diff --git a/cmd/gateway_builtin_tools.go b/cmd/gateway_builtin_tools.go index c0deab99..39e38661 100644 --- a/cmd/gateway_builtin_tools.go +++ b/cmd/gateway_builtin_tools.go @@ -37,6 +37,10 @@ func builtinToolSeedData() []store.BuiltinToolDef { {Name: "memory_get", DisplayName: "Memory Get", Description: "Retrieve a specific memory entry by key", Category: "memory", Enabled: true, Requires: []string{"memory"}, }, + {Name: "knowledge_graph_search", DisplayName: "Knowledge Graph Search", Description: "Search entities and traverse relationships in the knowledge graph", Category: "memory", Enabled: true, + Settings: json.RawMessage(`{"extract_on_memory_write":false,"extraction_provider":"","extraction_model":"","min_confidence":0.75}`), + Requires: []string{"knowledge_graph"}, + }, // media {Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: true, diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 0b5cec6b..67fcce83 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "log/slog" "path/filepath" @@ -11,6 +12,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/hooks" + kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" "github.com/nextlevelbuilder/goclaw/internal/media" "github.com/nextlevelbuilder/goclaw/internal/providers" @@ -162,6 +164,15 @@ func wireExtras( // Wire virtual FS interceptors: route context + memory file reads/writes to DB. // Share ONE ContextFileInterceptor instance between read_file and write_file // so they share the same cache. + // Write-capable tools share a memory interceptor with optional KG extraction hook. + var writeMemIntc *tools.MemoryInterceptor + if stores.Memory != nil { + writeMemIntc = tools.NewMemoryInterceptor(stores.Memory, workspace) + // Hook KG extraction on memory writes if KG store is available + if stores.KnowledgeGraph != nil && stores.BuiltinTools != nil { + writeMemIntc.SetKGExtractFunc(buildKGExtractFunc(stores.KnowledgeGraph, stores.BuiltinTools, providerReg)) + } + } if readTool, ok := toolsReg.Get("read_file"); ok { if ia, ok := readTool.(tools.InterceptorAware); ok { if contextFileInterceptor != nil { @@ -177,8 +188,8 @@ func wireExtras( if contextFileInterceptor != nil { ia.SetContextFileInterceptor(contextFileInterceptor) } - if stores.Memory != nil { - ia.SetMemoryInterceptor(tools.NewMemoryInterceptor(stores.Memory, workspace)) + if writeMemIntc != nil { + ia.SetMemoryInterceptor(writeMemIntc) } } } @@ -187,8 +198,8 @@ func wireExtras( if contextFileInterceptor != nil { ia.SetContextFileInterceptor(contextFileInterceptor) } - if stores.Memory != nil { - ia.SetMemoryInterceptor(tools.NewMemoryInterceptor(stores.Memory, workspace)) + if writeMemIntc != nil { + ia.SetMemoryInterceptor(writeMemIntc) } } } @@ -229,6 +240,16 @@ func wireExtras( slog.Info("memory layering enabled (Postgres)") } + // Wire knowledge graph store on KG tool + if stores.KnowledgeGraph != nil { + if kgTool, ok := toolsReg.Get("knowledge_graph_search"); ok { + if kgt, ok := kgTool.(*tools.KnowledgeGraphSearchTool); ok { + kgt.SetKGStore(stores.KnowledgeGraph) + } + } + slog.Info("knowledge graph tool wired (Postgres)") + } + // --- Cache invalidation event subscribers --- // Context file cache: invalidate on agent/context data changes @@ -514,3 +535,62 @@ func wireExtras( return contextFileInterceptor, delegateMgr, mcpPool, mediaStore } +// kgSettings holds KG extraction settings from the builtin_tools table. +type kgSettings struct { + ExtractOnMemoryWrite bool `json:"extract_on_memory_write"` + ExtractionProvider string `json:"extraction_provider"` + ExtractionModel string `json:"extraction_model"` + MinConfidence float64 `json:"min_confidence"` +} + +// buildKGExtractFunc returns a callback that extracts entities from memory content. +// Settings are read from the builtin_tools table on each invocation (not cached), +// so changes take effect immediately without restart. +func buildKGExtractFunc(kgStore store.KnowledgeGraphStore, bts store.BuiltinToolStore, providerReg *providers.Registry) tools.KGExtractFunc { + return func(ctx context.Context, agentID, userID, content string) { + slog.Info("kg extract: triggered", "agent", agentID, "user", userID, "content_len", len(content)) + // Read settings from DB on each call so admin changes take effect immediately + raw, err := bts.GetSettings(ctx, "knowledge_graph_search") + if err != nil || raw == nil { + slog.Warn("kg extract: no settings found", "error", err) + return + } + var settings kgSettings + if err := json.Unmarshal(raw, &settings); err != nil { + slog.Warn("kg extract: invalid settings", "error", err) + return + } + if !settings.ExtractOnMemoryWrite || settings.ExtractionProvider == "" || settings.ExtractionModel == "" { + return + } + + p, err := providerReg.Get(settings.ExtractionProvider) + if err != nil { + slog.Warn("kg extract: provider not found", "provider", settings.ExtractionProvider, "error", err) + return + } + extractor := kg.NewExtractor(p, settings.ExtractionModel, settings.MinConfidence) + result, err := extractor.Extract(ctx, content) + if err != nil { + slog.Warn("kg extract: extraction failed", "agent", agentID, "error", err) + return + } + if len(result.Entities) == 0 && len(result.Relations) == 0 { + return + } + for i := range result.Entities { + result.Entities[i].AgentID = agentID + result.Entities[i].UserID = userID + } + for i := range result.Relations { + result.Relations[i].AgentID = agentID + result.Relations[i].UserID = userID + } + if err := kgStore.IngestExtraction(ctx, agentID, userID, result.Entities, result.Relations); err != nil { + slog.Warn("kg extract: ingest failed", "agent", agentID, "error", err) + return + } + slog.Info("kg extract: ingested from memory write", "agent", agentID, "entities", len(result.Entities), "relations", len(result.Relations)) + } +} + diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 00dd1d12..4ac84ea1 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -48,6 +48,7 @@ type Server struct { builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API pendingMessagesHandler *httpapi.PendingMessagesHandler // pending messages API memoryHandler *httpapi.MemoryHandler // memory management API + kgHandler *httpapi.KnowledgeGraphHandler // knowledge graph API oauthHandler *httpapi.OAuthHandler // OAuth endpoints filesHandler *httpapi.FilesHandler // workspace file serving storageHandler *httpapi.StorageHandler // storage file management @@ -214,6 +215,11 @@ func (s *Server) BuildMux() *http.ServeMux { s.memoryHandler.RegisterRoutes(mux) } + // Knowledge graph API + if s.kgHandler != nil { + s.kgHandler.RegisterRoutes(mux) + } + // Workspace file serving (available in all modes) if s.filesHandler != nil { s.filesHandler.RegisterRoutes(mux) @@ -449,6 +455,9 @@ func (s *Server) SetMediaServeHandler(h *httpapi.MediaServeHandler) { s.mediaSer // SetMemoryHandler sets the memory management handler. func (s *Server) SetMemoryHandler(h *httpapi.MemoryHandler) { s.memoryHandler = h } +// SetKnowledgeGraphHandler sets the knowledge graph handler. +func (s *Server) SetKnowledgeGraphHandler(h *httpapi.KnowledgeGraphHandler) { s.kgHandler = h } + // SetAgentStore sets the agent store for context injection in tools_invoke. func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as } diff --git a/internal/http/knowledge_graph.go b/internal/http/knowledge_graph.go new file mode 100644 index 00000000..0774567d --- /dev/null +++ b/internal/http/knowledge_graph.go @@ -0,0 +1,57 @@ +package http + +import ( + "net/http" + + kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// KnowledgeGraphHandler handles KG entity/relation management endpoints. +type KnowledgeGraphHandler struct { + store store.KnowledgeGraphStore + providerReg *providers.Registry + token string +} + +// NewKnowledgeGraphHandler creates a handler for KG management endpoints. +func NewKnowledgeGraphHandler(s store.KnowledgeGraphStore, providerReg *providers.Registry, token string) *KnowledgeGraphHandler { + return &KnowledgeGraphHandler{store: s, providerReg: providerReg, token: token} +} + +// NewExtractor creates an Extractor from the given provider name and model. +func (h *KnowledgeGraphHandler) NewExtractor(providerName, model string, minConfidence float64) *kg.Extractor { + if h.providerReg == nil || providerName == "" || model == "" { + return nil + } + p, err := h.providerReg.Get(providerName) + if err != nil { + return nil + } + return kg.NewExtractor(p, model, minConfidence) +} + +// RegisterRoutes registers all KG routes on the given mux. +func (h *KnowledgeGraphHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/agents/{agentID}/kg/entities", h.auth(h.handleListEntities)) + mux.HandleFunc("GET /v1/agents/{agentID}/kg/entities/{entityID}", h.auth(h.handleGetEntity)) + mux.HandleFunc("POST /v1/agents/{agentID}/kg/entities", h.auth(h.handleUpsertEntity)) + mux.HandleFunc("DELETE /v1/agents/{agentID}/kg/entities/{entityID}", h.auth(h.handleDeleteEntity)) + mux.HandleFunc("POST /v1/agents/{agentID}/kg/traverse", h.auth(h.handleTraverse)) + mux.HandleFunc("POST /v1/agents/{agentID}/kg/extract", h.auth(h.handleExtract)) + mux.HandleFunc("GET /v1/agents/{agentID}/kg/stats", h.auth(h.handleStats)) + mux.HandleFunc("GET /v1/agents/{agentID}/kg/graph", h.auth(h.handleGraph)) +} + +func (h *KnowledgeGraphHandler) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if h.token != "" { + if extractBearerToken(r) != h.token { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + } + next(w, r) + } +} diff --git a/internal/http/knowledge_graph_handlers.go b/internal/http/knowledge_graph_handlers.go new file mode 100644 index 00000000..4b388711 --- /dev/null +++ b/internal/http/knowledge_graph_handlers.go @@ -0,0 +1,259 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + "strconv" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (h *KnowledgeGraphHandler) handleListEntities(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("agentID") + userID := r.URL.Query().Get("user_id") + entityType := r.URL.Query().Get("type") + query := r.URL.Query().Get("q") + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + if limit <= 0 { + limit = 50 + } + + // If query is provided, use search + if query != "" { + entities, err := h.store.SearchEntities(r.Context(), agentID, userID, query, limit) + if err != nil { + slog.Warn("kg.search_entities failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if entities == nil { + entities = []store.Entity{} + } + writeJSON(w, http.StatusOK, entities) + return + } + + entities, err := h.store.ListEntities(r.Context(), agentID, userID, store.EntityListOptions{ + EntityType: entityType, + Limit: limit, + Offset: offset, + }) + if err != nil { + slog.Warn("kg.list_entities failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if entities == nil { + entities = []store.Entity{} + } + writeJSON(w, http.StatusOK, entities) +} + +func (h *KnowledgeGraphHandler) handleGetEntity(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("agentID") + entityID := r.PathValue("entityID") + userID := r.URL.Query().Get("user_id") + + entity, err := h.store.GetEntity(r.Context(), agentID, userID, entityID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "entity not found"}) + return + } + + relations, err := h.store.ListRelations(r.Context(), agentID, userID, entityID) + if err != nil { + relations = []store.Relation{} + } + if relations == nil { + relations = []store.Relation{} + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "entity": entity, + "relations": relations, + }) +} + +func (h *KnowledgeGraphHandler) handleUpsertEntity(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("agentID") + + var entity store.Entity + if err := json.NewDecoder(r.Body).Decode(&entity); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + entity.AgentID = agentID + + if entity.ExternalID == "" || entity.Name == "" || entity.EntityType == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "external_id, name, and entity_type are required"}) + return + } + if entity.Confidence <= 0 { + entity.Confidence = 1.0 + } + + if err := h.store.UpsertEntity(r.Context(), &entity); err != nil { + slog.Warn("kg.upsert_entity failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *KnowledgeGraphHandler) handleDeleteEntity(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("agentID") + entityID := r.PathValue("entityID") + userID := r.URL.Query().Get("user_id") + + if err := h.store.DeleteEntity(r.Context(), agentID, userID, entityID); err != nil { + slog.Warn("kg.delete_entity failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +func (h *KnowledgeGraphHandler) handleTraverse(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("agentID") + + var body struct { + EntityID string `json:"entity_id"` + UserID string `json:"user_id"` + MaxDepth int `json:"max_depth"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if body.EntityID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "entity_id is required"}) + return + } + if body.MaxDepth <= 0 { + body.MaxDepth = 2 + } + if body.MaxDepth > 3 { + body.MaxDepth = 3 + } + + results, err := h.store.Traverse(r.Context(), agentID, body.UserID, body.EntityID, body.MaxDepth) + if err != nil { + slog.Warn("kg.traverse failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if results == nil { + results = []store.TraversalResult{} + } + writeJSON(w, http.StatusOK, results) +} + +func (h *KnowledgeGraphHandler) handleExtract(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("agentID") + + var body struct { + Text string `json:"text"` + UserID string `json:"user_id"` + Provider string `json:"provider"` + Model string `json:"model"` + MinConf float64 `json:"min_confidence"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if body.Text == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text is required"}) + return + } + if body.Provider == "" || body.Model == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) + return + } + + extractor := h.NewExtractor(body.Provider, body.Model, body.MinConf) + if extractor == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider or model"}) + return + } + + result, err := extractor.Extract(r.Context(), body.Text) + if err != nil { + slog.Warn("kg.extract failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + // Ingest extracted entities and relations into the store + for i := range result.Entities { + result.Entities[i].AgentID = agentID + result.Entities[i].UserID = body.UserID + } + for i := range result.Relations { + result.Relations[i].AgentID = agentID + result.Relations[i].UserID = body.UserID + } + + if err := h.store.IngestExtraction(r.Context(), agentID, body.UserID, result.Entities, result.Relations); err != nil { + slog.Warn("kg.ingest_extraction failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "entities": len(result.Entities), + "relations": len(result.Relations), + }) +} + +func (h *KnowledgeGraphHandler) handleStats(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("agentID") + userID := r.URL.Query().Get("user_id") + + stats, err := h.store.Stats(r.Context(), agentID, userID) + if err != nil { + slog.Warn("kg.stats failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, stats) +} + +// handleGraph returns all entities + relations for graph visualization. +func (h *KnowledgeGraphHandler) handleGraph(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("agentID") + userID := r.URL.Query().Get("user_id") + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 200 + } + + entities, err := h.store.ListEntities(r.Context(), agentID, userID, store.EntityListOptions{Limit: limit}) + if err != nil { + slog.Warn("kg.graph entities failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if entities == nil { + entities = []store.Entity{} + } + + relations, err := h.store.ListAllRelations(r.Context(), agentID, userID, limit*3) + if err != nil { + slog.Warn("kg.graph relations failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if relations == nil { + relations = []store.Relation{} + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "entities": entities, + "relations": relations, + }) +} diff --git a/internal/knowledgegraph/extractor.go b/internal/knowledgegraph/extractor.go new file mode 100644 index 00000000..7f6f05a5 --- /dev/null +++ b/internal/knowledgegraph/extractor.go @@ -0,0 +1,108 @@ +package knowledgegraph + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ExtractionResult holds entities and relations extracted from text. +type ExtractionResult struct { + Entities []store.Entity `json:"entities"` + Relations []store.Relation `json:"relations"` +} + +// Extractor extracts entities and relations from text using an LLM. +type Extractor struct { + provider providers.Provider + model string + minConfidence float64 +} + +// NewExtractor creates a new Extractor with the given provider, model, and confidence threshold. +func NewExtractor(provider providers.Provider, model string, minConfidence float64) *Extractor { + if minConfidence <= 0 { + minConfidence = 0.75 + } + return &Extractor{provider: provider, model: model, minConfidence: minConfidence} +} + +// Extract calls the LLM to extract entities and relations from text. +func (e *Extractor) Extract(ctx context.Context, text string) (*ExtractionResult, error) { + // Truncate very long texts to avoid overwhelming the LLM + const maxInputChars = 6000 + if len(text) > maxInputChars { + text = text[:maxInputChars] + "\n\n[...truncated]" + } + + req := providers.ChatRequest{ + Messages: []providers.Message{ + {Role: "system", Content: extractionSystemPrompt}, + {Role: "user", Content: text}, + }, + Model: e.model, + Options: map[string]interface{}{ + "max_tokens": 8192, + "temperature": 0.0, + }, + } + + resp, err := e.provider.Chat(ctx, req) + if err != nil { + return nil, fmt.Errorf("kg extraction LLM call: %w", err) + } + + // Parse JSON response + var result ExtractionResult + content := strings.TrimSpace(resp.Content) + // Handle markdown code blocks + content = stripCodeBlock(content) + if err := json.Unmarshal([]byte(content), &result); err != nil { + preview := content + if len(preview) > 300 { + preview = preview[:300] + "..." + } + slog.Warn("kg extraction: failed to parse LLM response", "error", err, "content_len", len(content), "finish_reason", resp.FinishReason, "preview", preview) + return nil, fmt.Errorf("parse extraction result: %w", err) + } + + // Filter by confidence threshold + filtered := &ExtractionResult{} + for _, ent := range result.Entities { + if ent.Confidence >= e.minConfidence { + ent.ExternalID = strings.ToLower(strings.TrimSpace(ent.ExternalID)) + ent.Name = strings.TrimSpace(ent.Name) + ent.EntityType = strings.ToLower(strings.TrimSpace(ent.EntityType)) + filtered.Entities = append(filtered.Entities, ent) + } + } + for _, rel := range result.Relations { + if rel.Confidence >= e.minConfidence { + rel.SourceEntityID = strings.ToLower(strings.TrimSpace(rel.SourceEntityID)) + rel.TargetEntityID = strings.ToLower(strings.TrimSpace(rel.TargetEntityID)) + rel.RelationType = strings.ToLower(strings.TrimSpace(rel.RelationType)) + filtered.Relations = append(filtered.Relations, rel) + } + } + + return filtered, nil +} + +// stripCodeBlock removes ```json ... ``` wrapper if present. +func stripCodeBlock(s string) string { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "```") { + if idx := strings.Index(s, "\n"); idx >= 0 { + s = s[idx+1:] + } + if idx := strings.LastIndex(s, "```"); idx >= 0 { + s = s[:idx] + } + } + return strings.TrimSpace(s) +} diff --git a/internal/knowledgegraph/extractor_prompt.go b/internal/knowledgegraph/extractor_prompt.go new file mode 100644 index 00000000..b6f67d6b --- /dev/null +++ b/internal/knowledgegraph/extractor_prompt.go @@ -0,0 +1,34 @@ +package knowledgegraph + +const extractionSystemPrompt = `You are an entity and relationship extractor. Given text, extract structured knowledge. + +Output valid JSON with this schema: +{ + "entities": [ + { + "external_id": "unique-lowercase-id", + "name": "Display Name", + "entity_type": "person|project|task|event|concept|location|organization", + "description": "Brief description (max 50 chars)", + "confidence": 0.0-1.0 + } + ], + "relations": [ + { + "source_entity_id": "external_id of source entity", + "relation_type": "verb_phrase", + "target_entity_id": "external_id of target entity", + "confidence": 0.0-1.0 + } + ] +} + +Rules: +- Extract only the TOP 15 most important entities and their relations +- Keep descriptions very short (under 50 characters) +- external_id: lowercase, no spaces, use hyphens (e.g. "john-doe", "project-alpha") +- entity_type: person, project, task, event, concept, location, or organization +- relation_type: lowercase verb phrase with underscores (e.g. works_on, reported_to) +- confidence: 1.0 = explicitly stated, 0.5 = inferred +- Keep names in original language +- Output ONLY the JSON object, no markdown, no explanation, no code blocks` diff --git a/internal/store/knowledge_graph_store.go b/internal/store/knowledge_graph_store.go new file mode 100644 index 00000000..1d151549 --- /dev/null +++ b/internal/store/knowledge_graph_store.go @@ -0,0 +1,78 @@ +package store + +import "context" + +// Entity represents a node in the knowledge graph. +type Entity struct { + ID string `json:"id"` + AgentID string `json:"agent_id"` + UserID string `json:"user_id,omitempty"` + ExternalID string `json:"external_id"` + Name string `json:"name"` + EntityType string `json:"entity_type"` + Description string `json:"description,omitempty"` + Properties map[string]string `json:"properties,omitempty"` + SourceID string `json:"source_id,omitempty"` + Confidence float64 `json:"confidence"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// Relation represents an edge between two entities. +type Relation struct { + ID string `json:"id"` + AgentID string `json:"agent_id"` + UserID string `json:"user_id,omitempty"` + SourceEntityID string `json:"source_entity_id"` + RelationType string `json:"relation_type"` + TargetEntityID string `json:"target_entity_id"` + Confidence float64 `json:"confidence"` + Properties map[string]string `json:"properties,omitempty"` + CreatedAt int64 `json:"created_at"` +} + +// TraversalResult is a connected entity with path info. +type TraversalResult struct { + Entity Entity `json:"entity"` + Depth int `json:"depth"` + Path []string `json:"path"` + Via string `json:"via"` +} + +// EntityListOptions configures a list query for entities. +type EntityListOptions struct { + EntityType string + Limit int + Offset int +} + +// GraphStats contains aggregate counts for a scoped graph. +type GraphStats struct { + EntityCount int `json:"entity_count"` + RelationCount int `json:"relation_count"` + EntityTypes map[string]int `json:"entity_types"` +} + +// KnowledgeGraphStore manages entity-relationship graphs. +type KnowledgeGraphStore interface { + UpsertEntity(ctx context.Context, entity *Entity) error + GetEntity(ctx context.Context, agentID, userID, entityID string) (*Entity, error) + DeleteEntity(ctx context.Context, agentID, userID, entityID string) error + ListEntities(ctx context.Context, agentID, userID string, opts EntityListOptions) ([]Entity, error) + SearchEntities(ctx context.Context, agentID, userID, query string, limit int) ([]Entity, error) + + UpsertRelation(ctx context.Context, relation *Relation) error + DeleteRelation(ctx context.Context, agentID, userID, relationID string) error + ListRelations(ctx context.Context, agentID, userID, entityID string) ([]Relation, error) + // ListAllRelations returns all relations for an agent (optionally scoped by user). + ListAllRelations(ctx context.Context, agentID, userID string, limit int) ([]Relation, error) + + Traverse(ctx context.Context, agentID, userID, startEntityID string, maxDepth int) ([]TraversalResult, error) + + IngestExtraction(ctx context.Context, agentID, userID string, entities []Entity, relations []Relation) error + PruneByConfidence(ctx context.Context, agentID, userID string, minConfidence float64) (int, error) + + Stats(ctx context.Context, agentID, userID string) (*GraphStats, error) + + Close() error +} diff --git a/internal/store/pg/builtin_tools.go b/internal/store/pg/builtin_tools.go index dd32462c..b969e1ed 100644 --- a/internal/store/pg/builtin_tools.go +++ b/internal/store/pg/builtin_tools.go @@ -116,6 +116,11 @@ func (s *PGBuiltinToolStore) Seed(ctx context.Context, tools []store.BuiltinTool category = EXCLUDED.category, requires = EXCLUDED.requires, metadata = EXCLUDED.metadata, + settings = CASE + WHEN builtin_tools.settings IS NULL OR builtin_tools.settings::text IN ('{}', 'null') + THEN EXCLUDED.settings + ELSE builtin_tools.settings + END, updated_at = EXCLUDED.updated_at`) if err != nil { return fmt.Errorf("prepare seed stmt: %w", err) diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index 938109c7..7b27fc4f 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -40,5 +40,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { Teams: NewPGTeamStore(db), BuiltinTools: NewPGBuiltinToolStore(db), PendingMessages: NewPGPendingMessageStore(db), + KnowledgeGraph: NewPGKnowledgeGraphStore(db), }, nil } diff --git a/internal/store/pg/knowledge_graph.go b/internal/store/pg/knowledge_graph.go new file mode 100644 index 00000000..54dd5a8d --- /dev/null +++ b/internal/store/pg/knowledge_graph.go @@ -0,0 +1,400 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGKnowledgeGraphStore implements store.KnowledgeGraphStore backed by Postgres. +type PGKnowledgeGraphStore struct { + db *sql.DB +} + +// NewPGKnowledgeGraphStore creates a new PG-backed knowledge graph store. +func NewPGKnowledgeGraphStore(db *sql.DB) *PGKnowledgeGraphStore { + return &PGKnowledgeGraphStore{db: db} +} + +func (s *PGKnowledgeGraphStore) UpsertEntity(ctx context.Context, entity *store.Entity) error { + aid := mustParseUUID(entity.AgentID) + props, err := json.Marshal(entity.Properties) + if err != nil { + props = []byte("{}") + } + now := time.Now() + id := uuid.Must(uuid.NewV7()) + _, err = s.db.ExecContext(ctx, ` + INSERT INTO kg_entities + (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) + ON CONFLICT (agent_id, user_id, external_id) DO UPDATE SET + name = EXCLUDED.name, + entity_type = EXCLUDED.entity_type, + description = EXCLUDED.description, + properties = EXCLUDED.properties, + source_id = EXCLUDED.source_id, + confidence = EXCLUDED.confidence, + updated_at = EXCLUDED.updated_at`, + id, aid, entity.UserID, entity.ExternalID, entity.Name, entity.EntityType, + entity.Description, props, entity.SourceID, entity.Confidence, now, + ) + return err +} + +func (s *PGKnowledgeGraphStore) GetEntity(ctx context.Context, agentID, userID, entityID string) (*store.Entity, error) { + aid := mustParseUUID(agentID) + eid := mustParseUUID(entityID) + row := s.db.QueryRowContext(ctx, ` + SELECT id, agent_id, user_id, external_id, name, entity_type, description, + properties, source_id, confidence, created_at, updated_at + FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`, + eid, aid, userID, + ) + return scanEntity(row) +} + +func (s *PGKnowledgeGraphStore) DeleteEntity(ctx context.Context, agentID, userID, entityID string) error { + aid := mustParseUUID(agentID) + eid := mustParseUUID(entityID) + _, err := s.db.ExecContext(ctx, + `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`, + eid, aid, userID, + ) + return err +} + +func (s *PGKnowledgeGraphStore) ListEntities(ctx context.Context, agentID, userID string, opts store.EntityListOptions) ([]store.Entity, error) { + aid := mustParseUUID(agentID) + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + + // Build dynamic WHERE clause: always filter by agent_id, optionally by user_id and entity_type + where := "agent_id = $1" + args := []any{aid} + idx := 2 + if userID != "" { + where += fmt.Sprintf(" AND user_id = $%d", idx) + args = append(args, userID) + idx++ + } + if opts.EntityType != "" { + where += fmt.Sprintf(" AND entity_type = $%d", idx) + args = append(args, opts.EntityType) + idx++ + } + args = append(args, limit, opts.Offset) + query := fmt.Sprintf(` + SELECT id, agent_id, user_id, external_id, name, entity_type, description, + properties, source_id, confidence, created_at, updated_at + FROM kg_entities WHERE %s + ORDER BY updated_at DESC LIMIT $%d OFFSET $%d`, where, idx, idx+1) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanEntities(rows) +} + +func (s *PGKnowledgeGraphStore) SearchEntities(ctx context.Context, agentID, userID, query string, limit int) ([]store.Entity, error) { + aid := mustParseUUID(agentID) + if limit <= 0 { + limit = 20 + } + pattern := "%" + query + "%" + + where := "agent_id = $1" + args := []any{aid} + idx := 2 + if userID != "" { + where += fmt.Sprintf(" AND user_id = $%d", idx) + args = append(args, userID) + idx++ + } + args = append(args, pattern, limit) + q := fmt.Sprintf(` + SELECT id, agent_id, user_id, external_id, name, entity_type, description, + properties, source_id, confidence, created_at, updated_at + FROM kg_entities + WHERE %s AND (name ILIKE $%d OR description ILIKE $%d) + ORDER BY confidence DESC, updated_at DESC LIMIT $%d`, where, idx, idx, idx+1) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanEntities(rows) +} + +func (s *PGKnowledgeGraphStore) UpsertRelation(ctx context.Context, relation *store.Relation) error { + aid := mustParseUUID(relation.AgentID) + src := mustParseUUID(relation.SourceEntityID) + tgt := mustParseUUID(relation.TargetEntityID) + props, err := json.Marshal(relation.Properties) + if err != nil { + props = []byte("{}") + } + id := uuid.Must(uuid.NewV7()) + now := time.Now() + _, err = s.db.ExecContext(ctx, ` + INSERT INTO kg_relations + (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (agent_id, user_id, source_entity_id, relation_type, target_entity_id) DO UPDATE SET + confidence = EXCLUDED.confidence, + properties = EXCLUDED.properties`, + id, aid, relation.UserID, src, relation.RelationType, tgt, relation.Confidence, props, now, + ) + return err +} + +func (s *PGKnowledgeGraphStore) DeleteRelation(ctx context.Context, agentID, userID, relationID string) error { + aid := mustParseUUID(agentID) + rid := mustParseUUID(relationID) + _, err := s.db.ExecContext(ctx, + `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2 AND user_id = $3`, + rid, aid, userID, + ) + return err +} + +func (s *PGKnowledgeGraphStore) ListRelations(ctx context.Context, agentID, userID, entityID string) ([]store.Relation, error) { + aid := mustParseUUID(agentID) + eid := mustParseUUID(entityID) + rows, err := s.db.QueryContext(ctx, ` + SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, + confidence, properties, created_at + FROM kg_relations + WHERE agent_id = $1 AND user_id = $2 + AND (source_entity_id = $3 OR target_entity_id = $3) + ORDER BY created_at DESC`, + aid, userID, eid, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanRelations(rows) +} + +func (s *PGKnowledgeGraphStore) ListAllRelations(ctx context.Context, agentID, userID string, limit int) ([]store.Relation, error) { + aid := mustParseUUID(agentID) + if limit <= 0 { + limit = 200 + } + where := "agent_id = $1" + args := []any{aid} + idx := 2 + if userID != "" { + where += fmt.Sprintf(" AND user_id = $%d", idx) + args = append(args, userID) + idx++ + } + args = append(args, limit) + q := fmt.Sprintf(` + SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, + confidence, properties, created_at + FROM kg_relations WHERE %s + ORDER BY created_at DESC LIMIT $%d`, where, idx) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanRelations(rows) +} + +func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, userID string, entities []store.Entity, relations []store.Relation) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck + + aid := mustParseUUID(agentID) + now := time.Now() + + // Upsert entities and build external_id → DB UUID lookup for relations + extIDToUUID := make(map[string]uuid.UUID, len(entities)) + for i := range entities { + e := &entities[i] + e.AgentID = agentID + e.UserID = userID + props, _ := json.Marshal(e.Properties) + id := uuid.Must(uuid.NewV7()) + // Use RETURNING to get the actual ID (could be existing row on conflict) + var actualID uuid.UUID + if err := tx.QueryRowContext(ctx, ` + INSERT INTO kg_entities + (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) + ON CONFLICT (agent_id, user_id, external_id) DO UPDATE SET + name = EXCLUDED.name, + entity_type = EXCLUDED.entity_type, + description = EXCLUDED.description, + properties = EXCLUDED.properties, + source_id = EXCLUDED.source_id, + confidence = EXCLUDED.confidence, + updated_at = EXCLUDED.updated_at + RETURNING id`, + id, aid, userID, e.ExternalID, e.Name, e.EntityType, + e.Description, props, e.SourceID, e.Confidence, now, + ).Scan(&actualID); err != nil { + return err + } + extIDToUUID[e.ExternalID] = actualID + } + + for i := range relations { + r := &relations[i] + r.AgentID = agentID + r.UserID = userID + // Resolve external_id references to actual DB UUIDs + src, ok1 := extIDToUUID[r.SourceEntityID] + tgt, ok2 := extIDToUUID[r.TargetEntityID] + if !ok1 || !ok2 { + continue // skip relations referencing unknown entities + } + props, _ := json.Marshal(r.Properties) + id := uuid.Must(uuid.NewV7()) + if _, err := tx.ExecContext(ctx, ` + INSERT INTO kg_relations + (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (agent_id, user_id, source_entity_id, relation_type, target_entity_id) DO UPDATE SET + confidence = EXCLUDED.confidence, + properties = EXCLUDED.properties`, + id, aid, userID, src, r.RelationType, tgt, r.Confidence, props, now, + ); err != nil { + return err + } + } + + return tx.Commit() +} + +func (s *PGKnowledgeGraphStore) PruneByConfidence(ctx context.Context, agentID, userID string, minConfidence float64) (int, error) { + aid := mustParseUUID(agentID) + res, err := s.db.ExecContext(ctx, + `DELETE FROM kg_entities WHERE agent_id = $1 AND user_id = $2 AND confidence < $3`, + aid, userID, minConfidence, + ) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return int(n), nil +} + +func (s *PGKnowledgeGraphStore) Stats(ctx context.Context, agentID, userID string) (*store.GraphStats, error) { + aid := mustParseUUID(agentID) + stats := &store.GraphStats{EntityTypes: make(map[string]int)} + + userFilter := "" + args := []any{aid} + if userID != "" { + userFilter = " AND user_id = $2" + args = append(args, userID) + } + + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter, args..., + ).Scan(&stats.EntityCount); err != nil { + return nil, err + } + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM kg_relations WHERE agent_id = $1`+userFilter, args..., + ).Scan(&stats.RelationCount); err != nil { + return nil, err + } + + rows, err := s.db.QueryContext(ctx, + `SELECT entity_type, COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter+` GROUP BY entity_type`, args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var t string + var c int + if err := rows.Scan(&t, &c); err != nil { + continue + } + stats.EntityTypes[t] = c + } + return stats, nil +} + +func (s *PGKnowledgeGraphStore) Close() error { return nil } + +// --- scan helpers --- + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanEntity(row rowScanner) (*store.Entity, error) { + var e store.Entity + var props []byte + var createdAt, updatedAt time.Time + if err := row.Scan( + &e.ID, &e.AgentID, &e.UserID, &e.ExternalID, &e.Name, &e.EntityType, + &e.Description, &props, &e.SourceID, &e.Confidence, &createdAt, &updatedAt, + ); err != nil { + return nil, err + } + json.Unmarshal(props, &e.Properties) //nolint:errcheck + e.CreatedAt = createdAt.UnixMilli() + e.UpdatedAt = updatedAt.UnixMilli() + return &e, nil +} + +func scanEntities(rows *sql.Rows) ([]store.Entity, error) { + var result []store.Entity + for rows.Next() { + var e store.Entity + var props []byte + var createdAt, updatedAt time.Time + if err := rows.Scan( + &e.ID, &e.AgentID, &e.UserID, &e.ExternalID, &e.Name, &e.EntityType, + &e.Description, &props, &e.SourceID, &e.Confidence, &createdAt, &updatedAt, + ); err != nil { + continue + } + json.Unmarshal(props, &e.Properties) //nolint:errcheck + e.CreatedAt = createdAt.UnixMilli() + e.UpdatedAt = updatedAt.UnixMilli() + result = append(result, e) + } + return result, rows.Err() +} + +func scanRelations(rows *sql.Rows) ([]store.Relation, error) { + var result []store.Relation + for rows.Next() { + var r store.Relation + var props []byte + var createdAt time.Time + if err := rows.Scan( + &r.ID, &r.AgentID, &r.UserID, &r.SourceEntityID, &r.RelationType, + &r.TargetEntityID, &r.Confidence, &props, &createdAt, + ); err != nil { + continue + } + json.Unmarshal(props, &r.Properties) //nolint:errcheck + r.CreatedAt = createdAt.UnixMilli() + result = append(result, r) + } + return result, rows.Err() +} diff --git a/internal/store/pg/knowledge_graph_traversal.go b/internal/store/pg/knowledge_graph_traversal.go new file mode 100644 index 00000000..d80bf75c --- /dev/null +++ b/internal/store/pg/knowledge_graph_traversal.go @@ -0,0 +1,112 @@ +package pg + +import ( + "context" + "encoding/json" + "time" + + "github.com/lib/pq" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// Traverse walks the knowledge graph from startEntityID up to maxDepth hops +// using a recursive CTE. Returns all reachable entities (excluding the start node). +// A 5-second statement timeout is applied for safety. +func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, startEntityID string, maxDepth int) ([]store.TraversalResult, error) { + if maxDepth <= 0 { + maxDepth = 3 + } + + aid := mustParseUUID(agentID) + startID := mustParseUUID(startEntityID) + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() //nolint:errcheck + + if _, err := tx.ExecContext(ctx, `SET LOCAL statement_timeout = '5000'`); err != nil { + return nil, err + } + + rows, err := tx.QueryContext(ctx, ` + WITH RECURSIVE paths AS ( + SELECT + e.id, e.agent_id, e.user_id, e.external_id, + e.name, e.entity_type, e.description, + e.properties, e.source_id, e.confidence, + e.created_at, e.updated_at, + 1 AS depth, + ARRAY[e.id::text] AS path, + ''::text AS via + FROM kg_entities e + WHERE e.id = $1 AND e.agent_id = $2 AND e.user_id = $3 + + UNION ALL + + SELECT + e.id, e.agent_id, e.user_id, e.external_id, + e.name, e.entity_type, e.description, + e.properties, e.source_id, e.confidence, + e.created_at, e.updated_at, + p.depth + 1, + p.path || e.id::text, + r.relation_type + FROM paths p + JOIN kg_relations r ON p.id = r.source_entity_id + JOIN kg_entities e ON r.target_entity_id = e.id + WHERE p.depth < $4 + AND NOT e.id::text = ANY(p.path) + ) + SELECT + id, agent_id, user_id, external_id, + name, entity_type, description, + properties, source_id, confidence, + created_at, updated_at, + depth, path, via + FROM paths WHERE depth > 1`, + startID, aid, userID, maxDepth, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []store.TraversalResult + for rows.Next() { + var e store.Entity + var props []byte + var createdAt, updatedAt time.Time + var depth int + var path []string + var via string + + if err := rows.Scan( + &e.ID, &e.AgentID, &e.UserID, &e.ExternalID, + &e.Name, &e.EntityType, &e.Description, + &props, &e.SourceID, &e.Confidence, + &createdAt, &updatedAt, + &depth, pq.Array(&path), &via, + ); err != nil { + continue + } + if len(props) > 0 { + json.Unmarshal(props, &e.Properties) //nolint:errcheck + } + e.CreatedAt = createdAt.UnixMilli() + e.UpdatedAt = updatedAt.UnixMilli() + + results = append(results, store.TraversalResult{ + Entity: e, + Depth: depth, + Path: path, + Via: via, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return results, tx.Commit() +} diff --git a/internal/store/stores.go b/internal/store/stores.go index da7c282a..f3611cb7 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -21,4 +21,5 @@ type Stores struct { Teams TeamStore BuiltinTools BuiltinToolStore PendingMessages PendingMessageStore + KnowledgeGraph KnowledgeGraphStore } diff --git a/internal/tools/knowledge_graph.go b/internal/tools/knowledge_graph.go new file mode 100644 index 00000000..1072080e --- /dev/null +++ b/internal/tools/knowledge_graph.go @@ -0,0 +1,162 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// KnowledgeGraphSearchTool provides graph-based search for agents. +type KnowledgeGraphSearchTool struct { + kgStore store.KnowledgeGraphStore +} + +// NewKnowledgeGraphSearchTool creates a new KnowledgeGraphSearchTool. +func NewKnowledgeGraphSearchTool() *KnowledgeGraphSearchTool { + return &KnowledgeGraphSearchTool{} +} + +// SetKGStore sets the KnowledgeGraphStore for this tool. +func (t *KnowledgeGraphSearchTool) SetKGStore(ks store.KnowledgeGraphStore) { + t.kgStore = ks +} + +func (t *KnowledgeGraphSearchTool) Name() string { return "knowledge_graph_search" } + +func (t *KnowledgeGraphSearchTool) Description() string { + return "Search the knowledge graph for entities and their relationships. Use this to find connections between people, projects, tasks, and concepts. Supports entity search by name/type and graph traversal to discover multi-hop relationships." +} + +func (t *KnowledgeGraphSearchTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Search query for entity names or descriptions", + }, + "entity_type": map[string]interface{}{ + "type": "string", + "description": "Filter by entity type (person, project, task, event, concept, location, organization)", + }, + "entity_id": map[string]interface{}{ + "type": "string", + "description": "Entity ID to traverse from (for relationship discovery)", + }, + "max_depth": map[string]interface{}{ + "type": "number", + "description": "Maximum traversal depth (default 2, max 3)", + }, + }, + "required": []string{"query"}, + } +} + +func (t *KnowledgeGraphSearchTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + if t.kgStore == nil { + return NewResult("Knowledge graph is not enabled for this agent.") + } + + agentID := store.AgentIDFromContext(ctx) + if agentID == uuid.Nil { + return ErrorResult("agent context not available") + } + userID := store.UserIDFromContext(ctx) + + query, _ := args["query"].(string) + if query == "" { + return ErrorResult("query parameter is required") + } + + entityID, _ := args["entity_id"].(string) + maxDepth := 2 + if md, ok := args["max_depth"].(float64); ok && md > 0 { + maxDepth = int(md) + if maxDepth > 3 { + maxDepth = 3 + } + } + + // Traversal mode: entity_id provided + if entityID != "" { + return t.executeTraversal(ctx, agentID.String(), userID, entityID, maxDepth) + } + + // Search mode + return t.executeSearch(ctx, agentID.String(), userID, query, args) +} + +func (t *KnowledgeGraphSearchTool) executeTraversal(ctx context.Context, agentID, userID, entityID string, maxDepth int) *Result { + results, err := t.kgStore.Traverse(ctx, agentID, userID, entityID, maxDepth) + if err != nil { + return ErrorResult(fmt.Sprintf("graph traversal failed: %v", err)) + } + if len(results) == 0 { + return NewResult(fmt.Sprintf("No connected entities found from entity_id=%q within depth %d.", entityID, maxDepth)) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Graph traversal from %q (max depth %d):\n\n", entityID, maxDepth)) + for _, r := range results { + sb.WriteString(fmt.Sprintf("- [depth %d] %s (%s)", r.Depth, r.Entity.Name, r.Entity.EntityType)) + if r.Via != "" { + sb.WriteString(fmt.Sprintf(" via %q", r.Via)) + } + if r.Entity.Description != "" { + sb.WriteString(fmt.Sprintf("\n %s", r.Entity.Description)) + } + if len(r.Path) > 0 { + sb.WriteString(fmt.Sprintf("\n path: %s", strings.Join(r.Path, " → "))) + } + sb.WriteString("\n") + } + return NewResult(sb.String()) +} + +func (t *KnowledgeGraphSearchTool) executeSearch(ctx context.Context, agentID, userID, query string, args map[string]interface{}) *Result { + entities, err := t.kgStore.SearchEntities(ctx, agentID, userID, query, 10) + if err != nil { + return ErrorResult(fmt.Sprintf("entity search failed: %v", err)) + } + if len(entities) == 0 { + return NewResult(fmt.Sprintf("No entities found matching %q.", query)) + } + + // Optional type filter (post-search) + entityType, _ := args["entity_type"].(string) + if entityType != "" { + filtered := entities[:0] + for _, e := range entities { + if e.EntityType == entityType { + filtered = append(filtered, e) + } + } + entities = filtered + if len(entities) == 0 { + return NewResult(fmt.Sprintf("No entities of type %q found matching %q.", entityType, query)) + } + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d entities matching %q:\n\n", len(entities), query)) + for _, e := range entities { + sb.WriteString(fmt.Sprintf("- %s [%s] (id: %s)\n", e.Name, e.EntityType, e.ExternalID)) + if e.Description != "" { + sb.WriteString(fmt.Sprintf(" %s\n", e.Description)) + } + + // Fetch relations to show connections + relations, err := t.kgStore.ListRelations(ctx, agentID, userID, e.ID) + if err == nil && len(relations) > 0 { + sb.WriteString(" Relations:\n") + for _, rel := range relations { + sb.WriteString(fmt.Sprintf(" %s → %s → %s\n", rel.SourceEntityID, rel.RelationType, rel.TargetEntityID)) + } + } + } + return NewResult(sb.String()) +} diff --git a/internal/tools/memory_interceptor.go b/internal/tools/memory_interceptor.go index 777fce37..a5175dec 100644 --- a/internal/tools/memory_interceptor.go +++ b/internal/tools/memory_interceptor.go @@ -59,11 +59,16 @@ func isMemoryPath(path, workspace string) bool { return false } +// KGExtractFunc is a callback invoked after a memory write to extract KG entities. +// agentID, userID, content are passed from the write context. +type KGExtractFunc func(ctx context.Context, agentID, userID, content string) + // MemoryInterceptor routes memory file reads/writes to the MemoryStore. // Keeps MEMORY.md and memory/* in Postgres. type MemoryInterceptor struct { - memStore store.MemoryStore - workspace string + memStore store.MemoryStore + workspace string + kgExtractFn KGExtractFunc } // NewMemoryInterceptor creates an interceptor backed by the given memory store. @@ -71,6 +76,11 @@ func NewMemoryInterceptor(ms store.MemoryStore, workspace string) *MemoryInterce return &MemoryInterceptor{memStore: ms, workspace: workspace} } +// SetKGExtractFunc sets the callback for KG extraction after memory writes. +func (m *MemoryInterceptor) SetKGExtractFunc(fn KGExtractFunc) { + m.kgExtractFn = fn +} + // ReadFile attempts to read a memory file from the DB. // Returns (content, true, nil) if handled, or ("", false, nil) if not a memory path. func (m *MemoryInterceptor) ReadFile(ctx context.Context, path string) (string, bool, error) { @@ -137,6 +147,11 @@ func (m *MemoryInterceptor) WriteFile(ctx context.Context, path, content string) } } + // Trigger KG extraction in background if configured + if m.kgExtractFn != nil && content != "" { + go m.kgExtractFn(context.WithoutCancel(ctx), agentStr, userID, content) + } + return true, nil } diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index ab1b4561..fcbe1811 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 12 +const RequiredSchemaVersion uint = 13 diff --git a/migrations/000013_knowledge_graph.down.sql b/migrations/000013_knowledge_graph.down.sql new file mode 100644 index 00000000..a7eb095b --- /dev/null +++ b/migrations/000013_knowledge_graph.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS kg_relations; +DROP TABLE IF EXISTS kg_entities; diff --git a/migrations/000013_knowledge_graph.up.sql b/migrations/000013_knowledge_graph.up.sql new file mode 100644 index 00000000..0d8ad752 --- /dev/null +++ b/migrations/000013_knowledge_graph.up.sql @@ -0,0 +1,33 @@ +CREATE TABLE kg_entities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL DEFAULT '', + external_id VARCHAR(255) NOT NULL, + name TEXT NOT NULL, + entity_type VARCHAR(100) NOT NULL, + description TEXT DEFAULT '', + properties JSONB DEFAULT '{}', + source_id VARCHAR(255) DEFAULT '', + confidence FLOAT NOT NULL DEFAULT 1.0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(agent_id, user_id, external_id) +); + +CREATE TABLE kg_relations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL DEFAULT '', + source_entity_id UUID NOT NULL REFERENCES kg_entities(id) ON DELETE CASCADE, + relation_type VARCHAR(200) NOT NULL, + target_entity_id UUID NOT NULL REFERENCES kg_entities(id) ON DELETE CASCADE, + confidence FLOAT NOT NULL DEFAULT 1.0, + properties JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(agent_id, user_id, source_entity_id, relation_type, target_entity_id) +); + +CREATE INDEX idx_kg_entities_scope ON kg_entities(agent_id, user_id); +CREATE INDEX idx_kg_entities_type ON kg_entities(agent_id, user_id, entity_type); +CREATE INDEX idx_kg_relations_source ON kg_relations(source_entity_id, relation_type); +CREATE INDEX idx_kg_relations_target ON kg_relations(target_entity_id); diff --git a/ui/web/package.json b/ui/web/package.json index 4596afd5..4d7dd850 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -16,8 +16,10 @@ "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "d3-force": "^3.0.0", "framer-motion": "^12.34.3", "highlight.js": "^11.11.1", "lucide-react": "^0.468.0", @@ -33,6 +35,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/d3-force": "^3.0.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/ws": "^8.18.1", diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index d4c59519..25412849 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -26,12 +26,18 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@xyflow/react': + specifier: ^12.10.1 + version: 12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 clsx: specifier: ^2.1.1 version: 2.1.1 + d3-force: + specifier: ^3.0.0 + version: 3.0.0 framer-motion: specifier: ^12.34.3 version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -72,6 +78,9 @@ importers: '@tailwindcss/vite': specifier: ^4.0.0 version: 4.2.0(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)) + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -1352,6 +1361,27 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1399,6 +1429,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@xyflow/react@12.10.1': + resolution: {integrity: sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.75': + resolution: {integrity: sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1437,6 +1476,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1459,6 +1501,52 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2131,6 +2219,21 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.11: resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} engines: {node: '>=12.20.0'} @@ -3338,6 +3441,29 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-force@3.0.10': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -3392,6 +3518,29 @@ snapshots: transitivePeerDependencies: - supports-color + '@xyflow/react@12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@xyflow/system': 0.0.75 + classcat: 5.0.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.75': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -3424,6 +3573,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + clsx@2.1.1: {} comma-separated-tokens@2.0.3: {} @@ -3436,6 +3587,50 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-quadtree@3.0.1: {} + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + debug@4.4.3: dependencies: ms: 2.1.3 @@ -4371,6 +4566,13 @@ snapshots: yallist@3.1.1: {} + zustand@4.5.7(@types/react@19.2.14)(react@19.2.4): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 98f5524c..610c6281 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -62,4 +62,10 @@ export const queryKeys = { all: ["memory"] as const, list: (params: Record) => ["memory", params] as const, }, + kg: { + all: ["kg"] as const, + list: (params: Record) => ["kg", params] as const, + stats: (agentId: string, userId?: string) => ["kg", "stats", agentId, userId] as const, + graph: (agentId: string, userId?: string) => ["kg", "graph", agentId, userId] as const, + }, }; diff --git a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx index 36981536..00afa911 100644 --- a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx +++ b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx @@ -12,6 +12,9 @@ import { Textarea } from "@/components/ui/textarea"; import type { BuiltinToolData } from "./hooks/use-builtin-tools"; import { MEDIA_TOOLS } from "./media-provider-params-schema"; import { MediaProviderChainForm } from "./media-provider-chain-form"; +import { KGSettingsForm } from "./kg-settings-form"; + +const KG_TOOL = "knowledge_graph_search"; interface Props { tool: BuiltinToolData | null; @@ -22,10 +25,12 @@ interface Props { export function BuiltinToolSettingsDialog({ tool, open, onOpenChange, onSave }: Props) { const isMedia = tool ? MEDIA_TOOLS.has(tool.name) : false; + const isKG = tool?.name === KG_TOOL; + const wide = isMedia || isKG; return ( - + {isMedia && tool ? ( onSave(tool.name, settings).then(() => onOpenChange(false))} onCancel={() => onOpenChange(false)} /> + ) : isKG && tool ? ( + onSave(tool.name, settings).then(() => onOpenChange(false))} + onCancel={() => onOpenChange(false)} + /> ) : ( )} diff --git a/ui/web/src/pages/builtin-tools/kg-settings-form.tsx b/ui/web/src/pages/builtin-tools/kg-settings-form.tsx new file mode 100644 index 00000000..963d898f --- /dev/null +++ b/ui/web/src/pages/builtin-tools/kg-settings-form.tsx @@ -0,0 +1,116 @@ +import { useState, useEffect } from "react"; +import { + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { ProviderModelSelect } from "@/components/shared/provider-model-select"; + +interface KGSettings { + extract_on_memory_write: boolean; + extraction_provider: string; + extraction_model: string; + min_confidence: number; +} + +const defaultSettings: KGSettings = { + extract_on_memory_write: false, + extraction_provider: "", + extraction_model: "", + min_confidence: 0.75, +}; + +interface Props { + initialSettings: Record; + onSave: (settings: Record) => Promise; + onCancel: () => void; +} + +export function KGSettingsForm({ initialSettings, onSave, onCancel }: Props) { + const [settings, setSettings] = useState(defaultSettings); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setSettings({ + ...defaultSettings, + ...initialSettings, + min_confidence: Number(initialSettings.min_confidence) || defaultSettings.min_confidence, + }); + }, [initialSettings]); + + const handleSave = async () => { + setSaving(true); + try { + await onSave(settings as unknown as Record); + } finally { + setSaving(false); + } + }; + + return ( + <> + + Knowledge Graph Settings + + Configure entity extraction from memory writes. Requires a provider/model capable of structured JSON output. + + + +
+ setSettings((s) => ({ ...s, extraction_provider: v }))} + model={settings.extraction_model} + onModelChange={(v) => setSettings((s) => ({ ...s, extraction_model: v }))} + providerLabel="Extraction Provider" + modelLabel="Extraction Model" + providerTip="LLM provider used to extract entities and relations from text." + modelTip="Model ID for extraction. Should support structured JSON output." + /> + +
+ + setSettings((s) => ({ ...s, min_confidence: Number(e.target.value) || 0.75 }))} + className="max-w-[120px]" + /> +

+ Entities below this confidence score are discarded (0.0–1.0). +

+
+ +
+
+ +

+ Automatically extract entities when agents write to memory files. +

+
+ setSettings((s) => ({ ...s, extract_on_memory_write: v }))} + /> +
+
+ + + + + + + ); +} diff --git a/ui/web/src/pages/memory/hooks/use-knowledge-graph.ts b/ui/web/src/pages/memory/hooks/use-knowledge-graph.ts new file mode 100644 index 00000000..287ec7ce --- /dev/null +++ b/ui/web/src/pages/memory/hooks/use-knowledge-graph.ts @@ -0,0 +1,187 @@ +import { useCallback, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import { toast } from "@/stores/use-toast-store"; +import type { KGEntity, KGRelation, KGStats, KGTraversalResult } from "@/types/knowledge-graph"; + +export interface KGFilters { + agentId: string; + userId?: string; + entityType?: string; + query?: string; +} + +export function useKnowledgeGraph(filters: KGFilters) { + const http = useHttp(); + const queryClient = useQueryClient(); + + const queryKey = queryKeys.kg.list({ ...filters }); + + const { data, isLoading, isFetching } = useQuery({ + queryKey, + queryFn: async () => { + if (!filters.agentId) return []; + const params: Record = {}; + if (filters.userId) params.user_id = filters.userId; + if (filters.entityType) params.type = filters.entityType; + if (filters.query) params.q = filters.query; + params.limit = "100"; + return (await http.get(`/v1/agents/${filters.agentId}/kg/entities`, params)) ?? []; + }, + enabled: !!filters.agentId, + placeholderData: (prev) => prev, + }); + + const entities = data ?? []; + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.kg.all }), + [queryClient], + ); + + const getEntityWithRelations = useCallback( + async (entityId: string, userId?: string) => { + const params: Record = {}; + if (userId) params.user_id = userId; + return http.get<{ entity: KGEntity; relations: KGRelation[] }>( + `/v1/agents/${filters.agentId}/kg/entities/${entityId}`, + params, + ); + }, + [http, filters.agentId], + ); + + const upsertEntity = useCallback( + async (entity: Partial) => { + try { + await http.post(`/v1/agents/${filters.agentId}/kg/entities`, entity); + await invalidate(); + toast.success("Entity saved", entity.name || ""); + } catch (err) { + toast.error("Failed to save entity", err instanceof Error ? err.message : "Unknown error"); + throw err; + } + }, + [http, filters.agentId, invalidate], + ); + + const deleteEntity = useCallback( + async (entityId: string, userId?: string) => { + try { + const qs = userId ? `?user_id=${encodeURIComponent(userId)}` : ""; + await http.delete(`/v1/agents/${filters.agentId}/kg/entities/${entityId}${qs}`); + await invalidate(); + toast.success("Entity deleted"); + } catch (err) { + toast.error("Failed to delete entity", err instanceof Error ? err.message : "Unknown error"); + throw err; + } + }, + [http, filters.agentId, invalidate], + ); + + const extractFromText = useCallback( + async (text: string, provider: string, model: string, userId?: string) => { + try { + const res = await http.post<{ entities: number; relations: number }>( + `/v1/agents/${filters.agentId}/kg/extract`, + { text, provider, model, user_id: userId || "" }, + ); + await invalidate(); + toast.success("Extraction complete", `${res.entities} entities, ${res.relations} relations`); + return res; + } catch (err) { + toast.error("Extraction failed", err instanceof Error ? err.message : "Unknown error"); + throw err; + } + }, + [http, filters.agentId, invalidate], + ); + + return { + entities, + loading: isLoading, + fetching: isFetching, + refresh: invalidate, + getEntityWithRelations, + upsertEntity, + deleteEntity, + extractFromText, + }; +} + +export function useKGStats(agentId: string, userId?: string) { + const http = useHttp(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.kg.stats(agentId, userId), + queryFn: async () => { + const params: Record = {}; + if (userId) params.user_id = userId; + return http.get(`/v1/agents/${agentId}/kg/stats`, params); + }, + enabled: !!agentId, + placeholderData: (prev) => prev, + }); + + return { stats: data, loading: isLoading }; +} + +export interface KGGraphData { + entities: KGEntity[]; + relations: KGRelation[]; +} + +export function useKGGraph(agentId: string, userId?: string) { + const http = useHttp(); + + const { data, isLoading, isFetching } = useQuery({ + queryKey: queryKeys.kg.graph(agentId, userId), + queryFn: async () => { + if (!agentId) return { entities: [], relations: [] }; + const params: Record = { limit: "200" }; + if (userId) params.user_id = userId; + return http.get(`/v1/agents/${agentId}/kg/graph`, params); + }, + enabled: !!agentId, + placeholderData: (prev) => prev, + }); + + return { + entities: data?.entities ?? [], + relations: data?.relations ?? [], + loading: isLoading, + fetching: isFetching, + }; +} + +export function useKGTraversal(agentId: string) { + const http = useHttp(); + const [results, setResults] = useState([]); + const [traversing, setTraversing] = useState(false); + + const traverse = useCallback( + async (entityId: string, userId?: string, maxDepth?: number) => { + setTraversing(true); + try { + const res = await http.post(`/v1/agents/${agentId}/kg/traverse`, { + entity_id: entityId, + user_id: userId || "", + max_depth: maxDepth || 2, + }); + setResults(res ?? []); + return res ?? []; + } catch (err) { + toast.error("Traversal failed", err instanceof Error ? err.message : "Unknown error"); + setResults([]); + return []; + } finally { + setTraversing(false); + } + }, + [http, agentId], + ); + + return { results, traversing, traverse }; +} diff --git a/ui/web/src/pages/memory/kg-entities-tab.tsx b/ui/web/src/pages/memory/kg-entities-tab.tsx new file mode 100644 index 00000000..ec07a31a --- /dev/null +++ b/ui/web/src/pages/memory/kg-entities-tab.tsx @@ -0,0 +1,231 @@ +import { useState } from "react"; +import { Network, Trash2, Search, GitFork, Sparkles, RefreshCw, LayoutGrid, Share2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { EmptyState } from "@/components/shared/empty-state"; +import { TableSkeleton } from "@/components/shared/loading-skeleton"; +import { ConfirmDialog } from "@/components/shared/confirm-dialog"; +import { useDeferredLoading } from "@/hooks/use-deferred-loading"; +import { useKnowledgeGraph, useKGStats, useKGGraph } from "./hooks/use-knowledge-graph"; +import { KGEntityDetailDialog } from "./kg-entity-detail-dialog"; +import { KGExtractDialog } from "./kg-extract-dialog"; +import { KGGraphView } from "./kg-graph-view"; +import type { KGEntity } from "@/types/knowledge-graph"; + +interface KGEntitiesTabProps { + agentId: string; + userId?: string; +} + +type ViewMode = "table" | "graph"; + +export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [appliedQuery, setAppliedQuery] = useState(""); + const [viewEntity, setViewEntity] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + const [extractOpen, setExtractOpen] = useState(false); + const [viewMode, setViewMode] = useState("table"); + + const { entities, loading, fetching, refresh, deleteEntity, getEntityWithRelations, extractFromText } = useKnowledgeGraph({ + agentId, + userId, + query: appliedQuery || undefined, + }); + const { stats } = useKGStats(agentId, userId); + const graphData = useKGGraph(agentId, userId); + const showSkeleton = useDeferredLoading(loading && entities.length === 0); + + const handleSearch = () => setAppliedQuery(searchQuery.trim()); + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSearch(); + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleteLoading(true); + try { + await deleteEntity(deleteTarget.id, deleteTarget.user_id); + setDeleteTarget(null); + } finally { + setDeleteLoading(false); + } + }; + + const handleExtract = (text: string, provider: string, model: string) => + extractFromText(text, provider, model, userId); + + return ( +
+ {/* Stats bar */} + {stats && ( +
+ Entities: {stats.entity_count} + Relations: {stats.relation_count} + {Object.entries(stats.entity_types).map(([type, count]) => ( + {type}: {count} + ))} +
+ )} + + {/* Search + actions */} +
+ setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search entities..." + className="max-w-sm" + /> + + {appliedQuery && ( + + )} +
+ + {/* View mode toggle */} +
+ + +
+ + + +
+ + {/* Content area */} + {viewMode === "graph" ? ( + + ) : showSkeleton ? ( + + ) : entities.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {entities.map((entity) => ( + + + + + + + + ))} + +
NameTypeDescriptionConfidenceActions
+ +

{entity.external_id}

+
+ {entity.entity_type} + + {entity.description || "-"} + + + +
+ + +
+
+
+ )} + + {/* Entity detail dialog */} + !open && setViewEntity(null)} + agentId={agentId} + entity={viewEntity} + getEntityWithRelations={getEntityWithRelations} + /> + + {/* Extract dialog */} + + + {/* Delete confirmation */} + !open && setDeleteTarget(null)} + title="Delete Entity" + description={`Delete "${deleteTarget?.name}"? This will also delete all associated relations.`} + confirmLabel="Delete" + variant="destructive" + onConfirm={handleDelete} + loading={deleteLoading} + /> +
+ ); +} + +function ConfidenceBar({ value }: { value: number }) { + const pct = Math.round(value * 100); + return ( +
+
+
+
+ {pct}% +
+ ); +} diff --git a/ui/web/src/pages/memory/kg-entity-detail-dialog.tsx b/ui/web/src/pages/memory/kg-entity-detail-dialog.tsx new file mode 100644 index 00000000..bc927aaf --- /dev/null +++ b/ui/web/src/pages/memory/kg-entity-detail-dialog.tsx @@ -0,0 +1,152 @@ +import { useState, useEffect, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { GitFork } from "lucide-react"; +import { useKGTraversal } from "./hooks/use-knowledge-graph"; +import type { KGEntity, KGRelation } from "@/types/knowledge-graph"; + +interface KGEntityDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + agentId: string; + entity: KGEntity | null; + getEntityWithRelations: (entityId: string, userId?: string) => Promise<{ entity: KGEntity; relations: KGRelation[] }>; +} + +export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getEntityWithRelations }: KGEntityDetailDialogProps) { + const [relations, setRelations] = useState([]); + const [loadingRels, setLoadingRels] = useState(false); + const { results: traversalResults, traversing, traverse } = useKGTraversal(agentId); + + const loadRelations = useCallback(async () => { + if (!entity) return; + setLoadingRels(true); + try { + const res = await getEntityWithRelations(entity.id, entity.user_id); + setRelations(res.relations ?? []); + } catch { + setRelations([]); + } finally { + setLoadingRels(false); + } + }, [entity, getEntityWithRelations]); + + useEffect(() => { + if (open && entity) { + loadRelations(); + } + }, [open, entity, loadRelations]); + + const handleTraverse = () => { + if (!entity) return; + traverse(entity.id, entity.user_id, 2); + }; + + return ( + !traversing && onOpenChange(v)}> + + + + {entity?.name} + {entity && ( + + {entity.entity_type} + + )} + + + +
+ {/* Entity info */} + {entity && ( +
+
External ID: {entity.external_id}
+
Confidence: {Math.round(entity.confidence * 100)}%
+ {entity.description && ( +
Description: {entity.description}
+ )} + {entity.source_id && ( +
Source: {entity.source_id}
+ )} + {entity.properties && Object.keys(entity.properties).length > 0 && ( +
+ Properties: +
{JSON.stringify(entity.properties, null, 2)}
+
+ )} +
+ )} + + {/* Relations */} +
+
+

Relations

+ +
+ {loadingRels ? ( +

Loading...

+ ) : relations.length === 0 ? ( +

No relations found.

+ ) : ( +
+ + + + + + + + + + + {relations.map((rel) => ( + + + + + + + ))} + +
DirectionRelationTargetConfidence
+ {rel.source_entity_id === entity?.id ? "→ outgoing" : "← incoming"} + {rel.relation_type} + {rel.source_entity_id === entity?.id ? rel.target_entity_id.slice(0, 8) : rel.source_entity_id.slice(0, 8)} + {Math.round(rel.confidence * 100)}%
+
+ )} +
+ + {/* Traversal results */} + {traversalResults.length > 0 && ( +
+

Traversal Results ({traversalResults.length})

+
+ {traversalResults.map((tr, i) => ( +
+ depth {tr.depth} + {tr.via && —[{tr.via}]→} + {tr.entity.name} + {tr.entity.entity_type} + {tr.entity.description && ( + {tr.entity.description} + )} +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/ui/web/src/pages/memory/kg-extract-dialog.tsx b/ui/web/src/pages/memory/kg-extract-dialog.tsx new file mode 100644 index 00000000..044b9009 --- /dev/null +++ b/ui/web/src/pages/memory/kg-extract-dialog.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { ProviderModelSelect } from "@/components/shared/provider-model-select"; + +interface KGExtractDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onExtract: (text: string, provider: string, model: string) => Promise; +} + +export function KGExtractDialog({ open, onOpenChange, onExtract }: KGExtractDialogProps) { + const [text, setText] = useState(""); + const [provider, setProvider] = useState(""); + const [model, setModel] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async () => { + if (!text.trim() || !provider || !model) return; + setLoading(true); + try { + await onExtract(text.trim(), provider, model); + setText(""); + onOpenChange(false); + } finally { + setLoading(false); + } + }; + + return ( + !loading && onOpenChange(v)}> + + + Extract Entities from Text + + +
+ + +
+ +