mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
1000 lines
28 KiB
Go
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()
|
|
}
|