diff --git a/AGENTS.md b/AGENTS.md index 5f2a24d..7ce7f29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,7 @@ elsewhere; regenerate from `internal/mcp/` when tools change): get_entity_knowledge(entity_slug) — all docs/investigations/runbooks linked to a slug get_knowledge_content(slug) — full markdown body of one knowledge entry upsert_knowledge(title, content, about, tags, kind) — write what you learned + upsert_session_summary(session_id, summary, entities_touched, discoveries) — batch-write session findings into the graph; creates knowledge entries, links entities, records a session-audit entry delete_knowledge(knowledge_slug) — soft-delete a knowledge entry restore_knowledge(knowledge_slug) — restore a soft-deleted entry merge_knowledge(target_slug, source_slugs) — fold entries into a target diff --git a/internal/mcp/knowledge_tools.go b/internal/mcp/knowledge_tools.go index e751ce9..d2c2e75 100644 --- a/internal/mcp/knowledge_tools.go +++ b/internal/mcp/knowledge_tools.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "encoding/json" "fmt" "strings" @@ -335,5 +336,149 @@ func KnowledgeTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolR query += ` ORDER BY e.slug LIMIT 100` return queryRows(ctx, pool, query, dbArgs...), nil }}, + // upsert_session_summary persists what was learned in a session + // back into the knowledge graph. Call this at the end of a + // session to close the loop: knowledge entries are created, + // entity attributes are updated, and the graph stays current. + {tool: &mcp.Tool{Name: "upsert_session_summary", Description: "Batch-write session discoveries into the knowledge graph. Call at session end to persist everything learned: creates knowledge entries for each discovery, links them to the entities they affect, and records a session-audit knowledge entry tagged with the session_id. This is how the system gets smarter over time — without it, everything discovered in a session is lost when the conversation ends.", + InputSchema: objSchema( + prop{"session_id", "string", "Unique session identifier (e.g. chat session UUID, cron job ID). Used to tag and group knowledge entries from this session."}, + prop{"summary", "string", "Free-text summary of what was accomplished in this session. Written as a readable paragraph for future reference."}, + prop{"entities_touched", "string", "JSON array of entity slugs that were modified or inspected (e.g. [\"lxc:arriman\", \"service:sabnzbd\"]). Each gets linked to the session-audit knowledge entry."}, + prop{"discoveries", "string", "Optional JSON array of discovery objects. Each object: {title, content, kind (document|investigation|runbook), tags (comma-separated)}. Each creates a separate searchable knowledge entry linked to the relevant entities."}, + ), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + sessionID, _ := args["session_id"].(string) + summary, _ := args["summary"].(string) + entitiesRaw, _ := args["entities_touched"].(string) + discoveriesRaw, _ := args["discoveries"].(string) + + if sessionID == "" || summary == "" { + return textResult("error: session_id and summary are required"), nil + } + + var entitySlugs []string + if entitiesRaw != "" { + if err := json.Unmarshal([]byte(entitiesRaw), &entitySlugs); err != nil { + return textResult(fmt.Sprintf("error: entities_touched is not valid JSON array: %v", err)), nil + } + } + + var discoveries []struct { + Title string `json:"title"` + Content string `json:"content"` + Kind string `json:"kind"` + Tags string `json:"tags"` + } + if discoveriesRaw != "" { + if err := json.Unmarshal([]byte(discoveriesRaw), &discoveries); err != nil { + return textResult(fmt.Sprintf("error: discoveries is not valid JSON: %v", err)), nil + } + } + + created := 0 + linked := 0 + updated := 0 + + // Create the session-audit knowledge entry + auditTitle := fmt.Sprintf("Session audit: %s", sessionID) + auditSlug := fmt.Sprintf("session-audit/%s", sessionID) + tags := []string{"audit", "session"} + auditDocID, _ := uuid.NewV7() + err := pool.QueryRow(ctx, ` + INSERT INTO entities (id, slug, type, name, attributes) + VALUES ($1, $2, 'investigation', $3, '{}') + ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now() + RETURNING id`, auditDocID, auditSlug, auditTitle).Scan(&auditDocID) + if err != nil { + return textResult(fmt.Sprintf("error creating audit entity: %v", err)), nil + } + _, err = pool.Exec(ctx, ` + INSERT INTO knowledge_entities (entity_id, title, content, source, tags, updated_at) + VALUES ($1, $2, $3, 'nomos-agent', $4, now()) + ON CONFLICT (entity_id) DO UPDATE + SET title = EXCLUDED.title, content = EXCLUDED.content, + tags = EXCLUDED.tags, updated_at = now()`, + auditDocID, auditTitle, summary, tags) + if err != nil { + return textResult(fmt.Sprintf("error writing audit knowledge: %v", err)), nil + } + created++ + + // Link the audit entry to each touched entity + for _, slug := range entitySlugs { + var targetID uuid.UUID + if qerr := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&targetID); qerr == nil { + pool.Exec(ctx, ` + INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) + SELECT $1, $2, 'documents', '{"by":"nomos"}'::jsonb, now() + WHERE NOT EXISTS ( + SELECT 1 FROM relationships + WHERE source_id = $1 AND target_id = $2 AND type = 'documents' AND valid_to IS NULL)`, + auditDocID, targetID) + linked++ + } + } + + // Create discovery knowledge entries + for _, d := range discoveries { + kind := d.Kind + switch kind { + case "document", "investigation", "runbook": + case "": + kind = "investigation" + default: + continue + } + discTitle := strings.TrimSpace(d.Title) + discContent := strings.TrimSpace(d.Content) + if discTitle == "" || discContent == "" { + continue + } + slug := fmt.Sprintf("discovery/%s/%s", sessionID, strings.ReplaceAll(strings.ToLower(discTitle), " ", "-")) + discID, _ := uuid.NewV7() + err := pool.QueryRow(ctx, ` + INSERT INTO entities (id, slug, type, name, attributes) + VALUES ($1, $2, $3, $4, '{}') + ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now() + RETURNING id`, discID, slug, kind, discTitle).Scan(&discID) + if err != nil { + continue + } + var discTags []string + for _, t := range strings.Split(d.Tags, ",") { + if t = strings.TrimSpace(t); t != "" { + discTags = append(discTags, t) + } + } + discTags = append(discTags, "discovery", "session") + _, err = pool.Exec(ctx, ` + INSERT INTO knowledge_entities (entity_id, title, content, source, tags, updated_at) + VALUES ($1, $2, $3, 'nomos-agent', $4, now()) + ON CONFLICT (entity_id) DO UPDATE + SET title = EXCLUDED.title, content = EXCLUDED.content, + tags = EXCLUDED.tags, updated_at = now()`, + discID, discTitle, discContent, discTags) + if err != nil { + continue + } + created++ + } + + // Update attributes on each touched entity + for _, slug := range entitySlugs { + pool.Exec(ctx, ` + UPDATE entities + SET attributes = jsonb_set(COALESCE(attributes, '{}'), '{last_agent_session}', to_jsonb($2::text), true), + updated_at = now() + WHERE slug = $1`, slug, sessionID) + updated++ + } + + return textResult(fmt.Sprintf( + "Session summary saved. Created %d knowledge entries, linked to %d entities, updated %d entity attributes.", + created, linked, updated)), nil + }}, } }