Pulse/internal/ai/knowledge/store.go
2026-03-29 14:18:20 +01:00

1000 lines
28 KiB
Go

// Package knowledge provides persistent storage for AI-learned information about guests
package knowledge
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/crypto"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
// Category constants for note categorization
const (
CategoryCredential = "credential"
CategoryService = "service"
CategoryPath = "path"
CategoryConfig = "config"
CategoryLearning = "learning"
CategoryHistory = "history"
CategoryInfra = "infrastructure" // Auto-discovered infrastructure facts
)
// Note represents a single piece of learned information
type Note struct {
ID string `json:"id"`
Category string `json:"category"` // "service", "path", "credential", "config", "learning", "history"
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GuestKnowledge represents all knowledge about a specific guest
type GuestKnowledge struct {
GuestID string `json:"guest_id"`
GuestName string `json:"guest_name"`
GuestType string `json:"guest_type"` // canonical v6 type (e.g. vm, system-container, app-container, node, agent)
Notes []Note `json:"notes"`
UpdatedAt time.Time `json:"updated_at"`
}
// Store manages persistent knowledge storage with encryption
type Store struct {
dataDir string
mu sync.RWMutex
cache map[string]*GuestKnowledge
crypto *crypto.CryptoManager
discoveryContextProvider func() string
discoveryContextProviderForResources func(resourceIDs []string) string
}
var newCryptoManagerAt = crypto.NewCryptoManagerAt
var beforeKnowledgeWriteLock func()
func normalizeGuestID(guestID string) string {
return strings.TrimSpace(guestID)
}
func isUnsupportedGuestID(guestID string) bool {
normalized := strings.ToLower(strings.TrimSpace(guestID))
if unifiedresources.IsUnsupportedLegacyResourceIDAlias(normalized) {
return true
}
return strings.HasPrefix(normalized, "qemu/") || strings.HasPrefix(normalized, "lxc/") || strings.HasPrefix(normalized, "lxc-") || strings.HasPrefix(normalized, "ct-")
}
func normalizeGuestType(guestType string) string {
return strings.ToLower(strings.TrimSpace(guestType))
}
func isUnsupportedGuestType(guestType string) bool {
normalized := normalizeGuestType(guestType)
if normalized == "" {
return false
}
if unifiedresources.IsUnsupportedLegacyResourceTypeAlias(normalized) {
return true
}
switch normalized {
case "guest", "qemu", "container", "lxc", "docker", "docker-container", "k8s", "kubernetes", "kubernetes-cluster", "docker_service", "dockerhost":
return true
default:
return false
}
}
// NewStore creates a new knowledge store with encryption
func NewStore(dataDir string) (*Store, error) {
knowledgeDir := filepath.Join(dataDir, "knowledge")
if err := os.MkdirAll(knowledgeDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create knowledge directory: %w", err)
}
// Initialize crypto manager for encryption (uses same key as other Pulse secrets)
cryptoMgr, err := newCryptoManagerAt(dataDir)
if err != nil {
return nil, fmt.Errorf("failed to initialize crypto for knowledge store: %w", err)
}
store := &Store{
dataDir: knowledgeDir,
cache: make(map[string]*GuestKnowledge),
crypto: cryptoMgr,
}
return store, nil
}
// guestFilePath returns the file path for a guest's knowledge
func (s *Store) guestFilePath(guestID string) string {
// Use .enc extension for encrypted files
fileBase := securityutil.HashedStorageName(guestID)
leaf := fileBase + ".json"
if s.crypto != nil {
leaf = fileBase + ".enc"
}
path, err := securityutil.JoinStorageLeaf(s.dataDir, leaf)
if err != nil {
panic(fmt.Sprintf("resolve guest knowledge storage path: %v", err))
}
return path
}
func (s *Store) loadKnowledgeFromPath(path string) (*GuestKnowledge, bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, false, err
}
migratedPlaintext := false
if s.crypto != nil {
if decrypted, decErr := s.crypto.Decrypt(data); decErr == nil {
data = decrypted
} else {
migratedPlaintext = true
}
}
var knowledge GuestKnowledge
if err := json.Unmarshal(data, &knowledge); err != nil {
return nil, false, fmt.Errorf("failed to parse knowledge file: %w", err)
}
return &knowledge, migratedPlaintext, nil
}
func (s *Store) findLegacyKnowledgePath(guestID string) (string, error) {
canonicalEnc := securityutil.HashedStorageName(guestID) + ".enc"
canonicalJSON := securityutil.HashedStorageName(guestID) + ".json"
legacyEnc := filepath.Base(guestID) + ".enc"
legacyJSON := filepath.Base(guestID) + ".json"
entries, err := os.ReadDir(s.dataDir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("failed to scan knowledge directory: %w", err)
}
for _, entry := range entries {
if entry.Name() == legacyEnc || entry.Name() == legacyJSON {
if entry.IsDir() {
return "", fmt.Errorf("legacy knowledge path %q is not a regular file", entry.Name())
}
}
if entry.IsDir() {
continue
}
switch filepath.Ext(entry.Name()) {
case ".json", ".enc":
default:
continue
}
if entry.Name() == canonicalEnc || entry.Name() == canonicalJSON {
continue
}
path, err := securityutil.JoinStorageLeaf(s.dataDir, entry.Name())
if err != nil {
if entry.Name() == legacyEnc || entry.Name() == legacyJSON {
return "", fmt.Errorf("invalid legacy knowledge file path %q: %w", entry.Name(), err)
}
log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to resolve legacy knowledge candidate path")
continue
}
knowledge, _, err := s.loadKnowledgeFromPath(path)
if err != nil {
if entry.Name() == legacyEnc || entry.Name() == legacyJSON {
return "", fmt.Errorf("failed to inspect legacy knowledge file %q: %w", entry.Name(), err)
}
log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to inspect legacy knowledge candidate")
continue
}
if normalizeGuestID(knowledge.GuestID) == guestID {
return path, nil
}
}
return "", nil
}
// GetKnowledge retrieves knowledge for a guest
func (s *Store) GetKnowledge(guestID string) (*GuestKnowledge, error) {
guestID = normalizeGuestID(guestID)
if isUnsupportedGuestID(guestID) {
return nil, fmt.Errorf("unsupported guest ID %q", guestID)
}
s.mu.RLock()
if cached, ok := s.cache[guestID]; ok {
s.mu.RUnlock()
return cached, nil
}
s.mu.RUnlock()
// Load from disk
if beforeKnowledgeWriteLock != nil {
beforeKnowledgeWriteLock()
}
s.mu.Lock()
defer s.mu.Unlock()
// Double-check after acquiring write lock
if cached, ok := s.cache[guestID]; ok {
return cached, nil
}
filePath := s.guestFilePath(guestID)
activePath := filePath
knowledge, migratedPlaintext, err := s.loadKnowledgeFromPath(filePath)
if err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read knowledge file: %w", err)
}
activePath, err = s.findLegacyKnowledgePath(guestID)
if err != nil {
return nil, err
}
if activePath == "" {
// No knowledge yet, return empty
knowledge := &GuestKnowledge{
GuestID: guestID,
Notes: []Note{},
}
s.cache[guestID] = knowledge
return knowledge, nil
}
knowledge, migratedPlaintext, err = s.loadKnowledgeFromPath(activePath)
if err != nil {
return nil, fmt.Errorf("failed to read knowledge file: %w", err)
}
log.Info().Str("guest_id", guestID).Str("path", activePath).Msg("loaded legacy knowledge file, rewriting canonical storage")
}
needsMigration := false
if knowledge.GuestID == "" {
knowledge.GuestID = guestID
needsMigration = true
}
if knowledge.GuestID != guestID {
knowledge.GuestID = guestID
needsMigration = true
}
s.cache[guestID] = knowledge
if activePath != filePath || needsMigration || migratedPlaintext {
if err := s.saveToFile(guestID, knowledge); err != nil {
log.Warn().Err(err).Str("guest_id", guestID).Msg("failed to migrate canonical guest knowledge file")
}
}
return knowledge, nil
}
// SaveNote adds or updates a note for a guest
func (s *Store) SaveNote(guestID, guestName, guestType, category, title, content string) error {
guestID = normalizeGuestID(guestID)
if isUnsupportedGuestID(guestID) {
return fmt.Errorf("unsupported guest ID %q", guestID)
}
guestType = normalizeGuestType(guestType)
if isUnsupportedGuestType(guestType) {
return fmt.Errorf("unsupported guest type %q", guestType)
}
// Prime cache so note updates merge against existing stored knowledge.
if _, err := s.GetKnowledge(guestID); err != nil {
log.Warn().
Err(err).
Str("guest_id", guestID).
Msg("failed to load existing knowledge for save; starting fresh")
s.mu.Lock()
s.cache[guestID] = &GuestKnowledge{
GuestID: guestID,
GuestName: guestName,
GuestType: guestType,
Notes: []Note{},
}
s.mu.Unlock()
}
s.mu.Lock()
defer s.mu.Unlock()
// Get or create knowledge
knowledge, ok := s.cache[guestID]
if !ok {
knowledge = &GuestKnowledge{
GuestID: guestID,
GuestName: guestName,
GuestType: guestType,
Notes: []Note{},
}
s.cache[guestID] = knowledge
}
// Update guest info if provided
if guestName != "" {
knowledge.GuestName = guestName
}
if guestType != "" {
knowledge.GuestType = guestType
} else {
knowledge.GuestType = normalizeGuestType(knowledge.GuestType)
}
knowledge.GuestID = guestID
now := time.Now()
// Check if note with same title exists in category
found := false
for i, note := range knowledge.Notes {
if note.Category == category && note.Title == title {
// Update existing note
knowledge.Notes[i].Content = content
knowledge.Notes[i].UpdatedAt = now
found = true
break
}
}
if !found {
// Add new note
note := Note{
ID: fmt.Sprintf("%s-%d", category, len(knowledge.Notes)+1),
Category: category,
Title: title,
Content: content,
CreatedAt: now,
UpdatedAt: now,
}
knowledge.Notes = append(knowledge.Notes, note)
}
knowledge.UpdatedAt = now
// Save to disk (encrypted)
return s.saveToFile(guestID, knowledge)
}
// DeleteNote removes a note
func (s *Store) DeleteNote(guestID, noteID string) error {
guestID = normalizeGuestID(guestID)
if isUnsupportedGuestID(guestID) {
return fmt.Errorf("unsupported guest ID %q", guestID)
}
s.mu.Lock()
defer s.mu.Unlock()
knowledge, ok := s.cache[guestID]
if !ok {
return fmt.Errorf("guest not found: %s", guestID)
}
// Find and remove note
for i, note := range knowledge.Notes {
if note.ID == noteID {
knowledge.Notes = append(knowledge.Notes[:i], knowledge.Notes[i+1:]...)
knowledge.UpdatedAt = time.Now()
return s.saveToFile(guestID, knowledge)
}
}
return fmt.Errorf("note not found: %s", noteID)
}
// GetNotesByCategory returns notes filtered by category
func (s *Store) GetNotesByCategory(guestID, category string) ([]Note, error) {
guestID = normalizeGuestID(guestID)
if isUnsupportedGuestID(guestID) {
return nil, fmt.Errorf("unsupported guest ID %q", guestID)
}
knowledge, err := s.GetKnowledge(guestID)
if err != nil {
return nil, err
}
var notes []Note
for _, note := range knowledge.Notes {
if category == "" || note.Category == category {
notes = append(notes, note)
}
}
return notes, nil
}
// FormatForContext formats knowledge for injection into AI context
func (s *Store) FormatForContext(guestID string) string {
guestID = normalizeGuestID(guestID)
if isUnsupportedGuestID(guestID) {
log.Warn().Str("guest_id", guestID).Msg("ignoring unsupported guest context request")
return ""
}
knowledge, err := s.GetKnowledge(guestID)
if err != nil {
log.Warn().Err(err).Str("guest_id", guestID).Msg("failed to load guest knowledge")
return ""
}
if len(knowledge.Notes) == 0 {
return ""
}
// Group notes by category
byCategory := make(map[string][]Note)
for _, note := range knowledge.Notes {
byCategory[note.Category] = append(byCategory[note.Category], note)
}
// Build formatted output with guidance on using this knowledge
var result string
result = fmt.Sprintf("\n## Previously Learned Information about %s\n", knowledge.GuestName)
result += "**If relevant to the current task, use this saved information directly instead of rediscovering it.**\n"
categoryOrder := []string{"credential", "service", "path", "config", "learning", "history", "infrastructure"}
categoryNames := map[string]string{
"credential": "Credentials",
"service": "Services",
"path": "Important Paths",
"config": "Configuration",
"learning": "Learnings",
"history": "Session History",
"infrastructure": "Discovered Infrastructure",
}
for _, cat := range categoryOrder {
notes, ok := byCategory[cat]
if !ok || len(notes) == 0 {
continue
}
result += fmt.Sprintf("\n### %s\n", categoryNames[cat])
for _, note := range notes {
result += fmt.Sprintf("- **%s**: %s\n", note.Title, note.Content)
}
}
return result
}
// saveToFile persists knowledge to disk with encryption
func (s *Store) saveToFile(guestID string, knowledge *GuestKnowledge) error {
data, err := json.MarshalIndent(knowledge, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal knowledge: %w", err)
}
// Encrypt if crypto manager is available
if s.crypto != nil {
encrypted, err := s.crypto.Encrypt(data)
if err != nil {
return fmt.Errorf("failed to encrypt knowledge: %w", err)
}
data = encrypted
}
filePath := s.guestFilePath(guestID)
if err := os.WriteFile(filePath, data, 0600); err != nil {
return fmt.Errorf("failed to write knowledge file: %w", err)
}
if legacyPath, err := s.findLegacyKnowledgePath(guestID); err == nil && legacyPath != "" && legacyPath != filePath {
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
log.Warn().Err(err).Str("guest_id", guestID).Str("path", legacyPath).Msg("failed to remove legacy knowledge file")
}
}
log.Debug().
Str("guest_id", guestID).
Int("notes", len(knowledge.Notes)).
Bool("encrypted", s.crypto != nil).
Msg("Saved guest knowledge")
return nil
}
// ListGuests returns all guests that have knowledge stored
func (s *Store) ListGuests() ([]string, error) {
files, err := os.ReadDir(s.dataDir)
if err != nil {
return nil, fmt.Errorf("failed to read knowledge directory: %w", err)
}
var guests []string
seen := make(map[string]struct{})
for _, file := range files {
if file.IsDir() {
continue
}
ext := filepath.Ext(file.Name())
if ext == ".json" || ext == ".enc" {
path, err := securityutil.JoinStorageLeaf(s.dataDir, file.Name())
if err != nil {
log.Warn().Err(err).Str("file", file.Name()).Msg("skipping invalid knowledge storage leaf while listing guests")
continue
}
knowledge, _, err := s.loadKnowledgeFromPath(path)
if err != nil {
log.Warn().Err(err).Str("file", file.Name()).Msg("failed to inspect knowledge file while listing guests")
continue
}
guestID := normalizeGuestID(knowledge.GuestID)
if guestID == "" {
continue
}
if _, exists := seen[guestID]; exists {
continue
}
seen[guestID] = struct{}{}
guests = append(guests, guestID)
}
}
return guests, nil
}
// FormatAllForContext returns a summary of all saved knowledge across all guests
// This is used when no specific target is selected to give the AI full context
// To prevent context bloat, it limits output to maxGuests and maxBytes
func (s *Store) FormatAllForContext() string {
const maxGuests = 10 // Only include the 10 most recently updated guests
const maxBytes = 8000 // Cap total output at ~8KB to leave room for other context
guests, err := s.ListGuests()
if err != nil || len(guests) == 0 {
return ""
}
// Load all guests with notes and sort by most recently updated
type guestWithTime struct {
id string
knowledge *GuestKnowledge
}
var guestsWithNotes []guestWithTime
for _, guestID := range guests {
knowledge, err := s.GetKnowledge(guestID)
if err != nil || len(knowledge.Notes) == 0 {
continue
}
guestsWithNotes = append(guestsWithNotes, guestWithTime{id: guestID, knowledge: knowledge})
}
if len(guestsWithNotes) == 0 {
return ""
}
// Sort by UpdatedAt descending (most recent first)
for i := 0; i < len(guestsWithNotes)-1; i++ {
for j := i + 1; j < len(guestsWithNotes); j++ {
if guestsWithNotes[j].knowledge.UpdatedAt.After(guestsWithNotes[i].knowledge.UpdatedAt) {
guestsWithNotes[i], guestsWithNotes[j] = guestsWithNotes[j], guestsWithNotes[i]
}
}
}
// Track how many guests and notes we're including vs total
totalGuests := len(guestsWithNotes)
totalNotes := 0
for _, g := range guestsWithNotes {
totalNotes += len(g.knowledge.Notes)
}
// Limit to maxGuests
truncatedGuests := false
if len(guestsWithNotes) > maxGuests {
guestsWithNotes = guestsWithNotes[:maxGuests]
truncatedGuests = true
}
var sections []string
includedNotes := 0
currentBytes := 0
for _, g := range guestsWithNotes {
knowledge := g.knowledge
// Build a summary for this guest
guestName := knowledge.GuestName
if guestName == "" {
guestName = g.id
}
// Group notes by category
byCategory := make(map[string][]Note)
for _, note := range knowledge.Notes {
byCategory[note.Category] = append(byCategory[note.Category], note)
}
var guestSection string
guestSection = fmt.Sprintf("\n### %s (%s)", guestName, knowledge.GuestType)
categoryOrder := []string{"credential", "service", "path", "config", "learning", "infrastructure"}
for _, cat := range categoryOrder {
notes, ok := byCategory[cat]
if !ok || len(notes) == 0 {
continue
}
for _, note := range notes {
// Mask credentials in the summary
content := note.Content
if cat == "credential" && len(content) > 6 {
content = content[:2] + "****" + content[len(content)-2:]
}
noteLine := fmt.Sprintf("\n- **%s**: %s", note.Title, content)
// Check if adding this note would exceed our byte limit
if currentBytes+len(guestSection)+len(noteLine) > maxBytes {
// Stop adding notes, we've hit the limit
if includedNotes > 0 {
log.Warn().
Int("total_notes", totalNotes).
Int("included_notes", includedNotes).
Int("total_guests", totalGuests).
Int("max_bytes", maxBytes).
Msg("Knowledge context truncated to prevent bloat - consider cleaning up old notes")
}
goto finalize
}
guestSection += noteLine
includedNotes++
}
}
currentBytes += len(guestSection)
sections = append(sections, guestSection)
}
finalize:
if len(sections) == 0 {
return ""
}
// Build result with info about truncation if applicable
var header string
if truncatedGuests || includedNotes < totalNotes {
header = fmt.Sprintf("\n\n## Saved Knowledge (%d/%d notes from %d/%d guests, most recent)\n",
includedNotes, totalNotes, len(sections), totalGuests)
} else {
header = fmt.Sprintf("\n\n## Saved Knowledge (%d notes across %d guests)\n", totalNotes, len(sections))
}
result := header
result += "This is information learned from previous sessions. Use it to avoid rediscovery.\n"
result += strings.Join(sections, "\n")
return result
}
// FormatForContextForResources returns a summary of saved knowledge scoped to specific resources.
// This avoids dumping global notes into targeted patrol runs.
func (s *Store) FormatForContextForResources(resourceIDs []string) string {
if len(resourceIDs) == 0 {
return ""
}
const maxGuests = 10
const maxBytes = 8000
guests, err := s.ListGuests()
if err != nil || len(guests) == 0 {
return ""
}
resourceTokens := buildResourceIDTokenSet(resourceIDs)
if len(resourceTokens) == 0 {
return ""
}
// Load only matching guests with notes and sort by most recently updated
type guestWithTime struct {
id string
knowledge *GuestKnowledge
}
var guestsWithNotes []guestWithTime
for _, guestID := range guests {
if !matchesResourceTokens(guestID, resourceTokens) {
continue
}
knowledge, err := s.GetKnowledge(guestID)
if err != nil || len(knowledge.Notes) == 0 {
continue
}
guestsWithNotes = append(guestsWithNotes, guestWithTime{id: guestID, knowledge: knowledge})
}
if len(guestsWithNotes) == 0 {
return ""
}
// Sort by UpdatedAt descending (most recent first)
for i := 0; i < len(guestsWithNotes)-1; i++ {
for j := i + 1; j < len(guestsWithNotes); j++ {
if guestsWithNotes[j].knowledge.UpdatedAt.After(guestsWithNotes[i].knowledge.UpdatedAt) {
guestsWithNotes[i], guestsWithNotes[j] = guestsWithNotes[j], guestsWithNotes[i]
}
}
}
totalGuests := len(guestsWithNotes)
totalNotes := 0
for _, g := range guestsWithNotes {
totalNotes += len(g.knowledge.Notes)
}
// Limit to maxGuests
truncatedGuests := false
if len(guestsWithNotes) > maxGuests {
guestsWithNotes = guestsWithNotes[:maxGuests]
truncatedGuests = true
}
var sections []string
includedNotes := 0
currentBytes := 0
for _, g := range guestsWithNotes {
knowledge := g.knowledge
guestName := knowledge.GuestName
if guestName == "" {
guestName = g.id
}
byCategory := make(map[string][]Note)
for _, note := range knowledge.Notes {
byCategory[note.Category] = append(byCategory[note.Category], note)
}
guestSection := fmt.Sprintf("\n### %s (%s)", guestName, knowledge.GuestType)
categoryOrder := []string{"credential", "service", "path", "config", "learning", "infrastructure"}
for _, cat := range categoryOrder {
notes, ok := byCategory[cat]
if !ok || len(notes) == 0 {
continue
}
for _, note := range notes {
content := note.Content
if cat == "credential" && len(content) > 6 {
content = content[:2] + "****" + content[len(content)-2:]
}
noteLine := fmt.Sprintf("\n- **%s**: %s", note.Title, content)
if currentBytes+len(guestSection)+len(noteLine) > maxBytes {
if includedNotes > 0 {
log.Warn().
Int("total_notes", totalNotes).
Int("included_notes", includedNotes).
Int("total_guests", totalGuests).
Int("max_bytes", maxBytes).
Msg("Knowledge context truncated to prevent bloat - consider cleaning up old notes")
}
goto finalize
}
guestSection += noteLine
includedNotes++
}
}
currentBytes += len(guestSection)
sections = append(sections, guestSection)
}
finalize:
if len(sections) == 0 {
return ""
}
var header string
if truncatedGuests || includedNotes < totalNotes {
header = fmt.Sprintf("\n\n## Saved Knowledge (%d/%d notes from %d/%d guests, most recent)\n",
includedNotes, totalNotes, len(sections), totalGuests)
} else {
header = fmt.Sprintf("\n\n## Saved Knowledge (%d notes across %d guests)\n", totalNotes, len(sections))
}
result := header
result += "This is information learned from previous sessions. Use it to avoid rediscovery.\n"
result += strings.Join(sections, "\n")
return result
}
// SetDiscoveryContextProvider sets the function that provides discovery context.
// This allows the knowledge store to include deep-scanned infrastructure info
// (service versions, CLI access, config paths, ports) in the context for investigations.
func (s *Store) SetDiscoveryContextProvider(provider func() string) {
s.mu.Lock()
defer s.mu.Unlock()
s.discoveryContextProvider = provider
}
// SetDiscoveryContextProviderForResources sets the provider for scoped discovery context.
// This allows Patrol to request discovery context for specific resources.
func (s *Store) SetDiscoveryContextProviderForResources(provider func(resourceIDs []string) string) {
s.mu.Lock()
defer s.mu.Unlock()
s.discoveryContextProviderForResources = provider
}
// GetInfrastructureContext returns all discovered infrastructure formatted for AI context.
// This is specifically used by Patrol and investigations to understand where services run
// and how to interact with them (e.g., knowing PBS runs in Docker so commands need docker exec).
//
// It combines two sources:
// 1. Discovery data (deep-scanned service details, versions, CLI access, ports)
// 2. Legacy knowledge notes (for backward compatibility)
func (s *Store) GetInfrastructureContext() string {
s.mu.RLock()
discoveryProvider := s.discoveryContextProvider
s.mu.RUnlock()
var sb strings.Builder
// First, include discovery context (the rich, deep-scanned data)
if discoveryProvider != nil {
if discoveryContext := discoveryProvider(); discoveryContext != "" {
sb.WriteString(discoveryContext)
}
}
// Then, include legacy knowledge notes (for backward compatibility)
legacyContext := s.getLegacyInfrastructureContext()
if legacyContext != "" {
// Only add if we don't already have discovery context
// (discovery is more comprehensive and replaces legacy notes)
if sb.Len() == 0 {
sb.WriteString(legacyContext)
}
}
return sb.String()
}
// GetInfrastructureContextForResources returns discovery context scoped to specific resources.
// If no scoped provider is configured, it returns an empty string to avoid over-broad context.
func (s *Store) GetInfrastructureContextForResources(resourceIDs []string) string {
if len(resourceIDs) == 0 {
return s.GetInfrastructureContext()
}
s.mu.RLock()
provider := s.discoveryContextProviderForResources
s.mu.RUnlock()
if provider == nil {
return ""
}
return provider(resourceIDs)
}
func buildResourceIDTokenSet(resourceIDs []string) map[string]struct{} {
tokens := make(map[string]struct{})
for _, id := range resourceIDs {
addResourceIDTokens(tokens, id)
}
return tokens
}
func addResourceIDTokens(tokens map[string]struct{}, resourceID string) {
trimmed := strings.TrimSpace(resourceID)
if trimmed == "" {
return
}
utils.AddToken(tokens, trimmed)
if last := utils.LastSegment(trimmed, '/'); last != "" {
utils.AddToken(tokens, last)
}
if last := utils.LastSegment(trimmed, ':'); last != "" {
utils.AddToken(tokens, last)
}
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "vm-") {
utils.AddToken(tokens, trimmed[3:])
}
if strings.HasPrefix(lower, "system-container-") {
utils.AddToken(tokens, trimmed[17:])
}
if strings.HasPrefix(lower, "app-container-") {
utils.AddToken(tokens, trimmed[14:])
}
if strings.HasPrefix(lower, "vm-") {
if digits := utils.TrailingDigits(trimmed); digits != "" {
utils.AddToken(tokens, digits)
}
}
if strings.Contains(trimmed, ":") {
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) == 2 {
rest := parts[1]
if slash := strings.Index(rest, "/"); slash >= 0 {
host := strings.TrimSpace(rest[:slash])
container := strings.TrimSpace(rest[slash+1:])
utils.AddToken(tokens, host)
utils.AddToken(tokens, container)
}
}
}
}
func matchesResourceTokens(guestID string, tokens map[string]struct{}) bool {
if guestID == "" || len(tokens) == 0 {
return false
}
guestTokens := buildResourceIDTokenSet([]string{guestID})
for token := range guestTokens {
if _, ok := tokens[token]; ok {
return true
}
}
return false
}
// getLegacyInfrastructureContext returns infrastructure context from legacy knowledge notes.
func (s *Store) getLegacyInfrastructureContext() string {
guests, err := s.ListGuests()
if err != nil || len(guests) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("\n## Discovered Infrastructure\n")
sb.WriteString("The following services have been auto-discovered on your infrastructure.\n")
sb.WriteString("Use this information to propose correct commands (e.g., use 'docker exec' for containerized services).\n\n")
hasNotes := false
for _, guestID := range guests {
knowledge, err := s.GetKnowledge(guestID)
if err != nil {
continue
}
// Filter for infrastructure notes only
var infraNotes []Note
for _, note := range knowledge.Notes {
if note.Category == CategoryInfra {
infraNotes = append(infraNotes, note)
}
}
if len(infraNotes) == 0 {
continue
}
hasNotes = true
guestName := knowledge.GuestName
if guestName == "" {
guestName = guestID
}
sb.WriteString(fmt.Sprintf("### %s\n", guestName))
for _, note := range infraNotes {
sb.WriteString(fmt.Sprintf("- %s: %s\n", note.Title, note.Content))
}
sb.WriteString("\n")
}
if !hasNotes {
return ""
}
return sb.String()
}