| 1 | package llm |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "sync" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // ContextNode represents a failure context in the memory graph |
| 12 | type ContextNode struct { |
| 13 | ID string // Unique context identifier |
| 14 | CommandPattern string // Abstracted command pattern |
| 15 | ErrorType string // Error category |
| 16 | ProjectType string // Language/framework |
| 17 | Timestamp time.Time // When this occurred |
| 18 | Transitions map[string]*Transition // Edges to other contexts |
| 19 | InsultPool []WeightedInsult // Specialized insults for this path |
| 20 | VisitCount int // How often we've seen this |
| 21 | SuccessRate float64 // How often user moves on |
| 22 | } |
| 23 | |
| 24 | // Transition represents a path between contexts |
| 25 | type Transition struct { |
| 26 | ToContext string // Target context ID |
| 27 | Count int // How many times we've seen this transition |
| 28 | AvgTimeBetween float64 // Average time between failures (seconds) |
| 29 | LastSeen time.Time // Last time we saw this transition |
| 30 | SpecialInsults []string // Insults specific to this failure sequence |
| 31 | } |
| 32 | |
| 33 | // WeightedInsult is an insult with dynamic weight |
| 34 | type WeightedInsult struct { |
| 35 | Text string |
| 36 | BaseWeight float64 |
| 37 | DynamicWeight float64 // Updated via RL |
| 38 | UseCount int |
| 39 | LastUsed time.Time |
| 40 | Effectiveness float64 // RL score: how well it worked |
| 41 | } |
| 42 | |
| 43 | // ContextualMemoryGraph tracks failure patterns and relationships |
| 44 | type ContextualMemoryGraph struct { |
| 45 | mu sync.RWMutex |
| 46 | nodes map[string]*ContextNode |
| 47 | currentContext string |
| 48 | previousContext string |
| 49 | lastTransition time.Time |
| 50 | persistencePath string |
| 51 | |
| 52 | // Configuration |
| 53 | decayFactor float64 // How quickly weights decay |
| 54 | minEffectiveness float64 // Minimum effectiveness to keep insult |
| 55 | maxPoolSize int // Maximum insults per pool |
| 56 | } |
| 57 | |
| 58 | // NewContextualMemoryGraph creates a new memory graph |
| 59 | func NewContextualMemoryGraph() *ContextualMemoryGraph { |
| 60 | homeDir, _ := os.UserHomeDir() |
| 61 | persistPath := filepath.Join(homeDir, ".parrot", "context_graph.json") |
| 62 | |
| 63 | graph := &ContextualMemoryGraph{ |
| 64 | nodes: make(map[string]*ContextNode), |
| 65 | persistencePath: persistPath, |
| 66 | decayFactor: 0.98, // Slow decay |
| 67 | minEffectiveness: 0.3, // Keep insults above 30% effectiveness |
| 68 | maxPoolSize: 50, // Max 50 specialized insults per context |
| 69 | } |
| 70 | |
| 71 | // Try to load existing graph |
| 72 | graph.Load() |
| 73 | |
| 74 | return graph |
| 75 | } |
| 76 | |
| 77 | // RecordContext records a failure in the graph |
| 78 | func (cmg *ContextualMemoryGraph) RecordContext(ctx *SmartFallbackContext) string { |
| 79 | cmg.mu.Lock() |
| 80 | defer cmg.mu.Unlock() |
| 81 | |
| 82 | // Create context ID from key features |
| 83 | contextID := cmg.generateContextID(ctx) |
| 84 | |
| 85 | // Get or create node |
| 86 | node, exists := cmg.nodes[contextID] |
| 87 | if !exists { |
| 88 | node = &ContextNode{ |
| 89 | ID: contextID, |
| 90 | CommandPattern: abstractCommand(ctx.FullCommand), |
| 91 | ErrorType: ctx.ErrorPattern, |
| 92 | ProjectType: ctx.ProjectType, |
| 93 | Timestamp: time.Now(), |
| 94 | Transitions: make(map[string]*Transition), |
| 95 | InsultPool: make([]WeightedInsult, 0), |
| 96 | VisitCount: 0, |
| 97 | SuccessRate: 0.5, // Start neutral |
| 98 | } |
| 99 | cmg.nodes[contextID] = node |
| 100 | } |
| 101 | |
| 102 | node.VisitCount++ |
| 103 | |
| 104 | // Record transition from previous context |
| 105 | if cmg.previousContext != "" && cmg.previousContext != contextID { |
| 106 | timeSince := time.Since(cmg.lastTransition).Seconds() |
| 107 | |
| 108 | transition, exists := node.Transitions[cmg.previousContext] |
| 109 | if !exists { |
| 110 | transition = &Transition{ |
| 111 | ToContext: contextID, |
| 112 | Count: 0, |
| 113 | AvgTimeBetween: 0, |
| 114 | SpecialInsults: make([]string, 0), |
| 115 | } |
| 116 | node.Transitions[cmg.previousContext] = transition |
| 117 | } |
| 118 | |
| 119 | // Update transition statistics (exponential moving average) |
| 120 | alpha := 0.3 |
| 121 | transition.AvgTimeBetween = alpha*timeSince + (1-alpha)*transition.AvgTimeBetween |
| 122 | transition.Count++ |
| 123 | transition.LastSeen = time.Now() |
| 124 | |
| 125 | // If this is a rapid repeat (< 30 seconds), it's likely the same issue |
| 126 | if timeSince < 30 { |
| 127 | // Generate special "repeated failure" insult for this path |
| 128 | cmg.addTransitionInsult(transition, ctx) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // Update context tracking |
| 133 | cmg.previousContext = cmg.currentContext |
| 134 | cmg.currentContext = contextID |
| 135 | cmg.lastTransition = time.Now() |
| 136 | |
| 137 | // Periodically persist |
| 138 | if node.VisitCount%10 == 0 { |
| 139 | go cmg.Save() |
| 140 | } |
| 141 | |
| 142 | return contextID |
| 143 | } |
| 144 | |
| 145 | // GetContextualInsult retrieves the best insult for current context |
| 146 | func (cmg *ContextualMemoryGraph) GetContextualInsult(contextID string) string { |
| 147 | cmg.mu.RLock() |
| 148 | defer cmg.mu.RUnlock() |
| 149 | |
| 150 | node, exists := cmg.nodes[contextID] |
| 151 | if !exists || len(node.InsultPool) == 0 { |
| 152 | return "" // No specialized insult |
| 153 | } |
| 154 | |
| 155 | // Select insult using weighted random selection with novelty penalty |
| 156 | bestInsult := "" |
| 157 | bestScore := 0.0 |
| 158 | |
| 159 | now := time.Now() |
| 160 | for _, weighted := range node.InsultPool { |
| 161 | // Calculate score based on effectiveness and recency |
| 162 | score := weighted.DynamicWeight * weighted.Effectiveness |
| 163 | |
| 164 | // Penalty for recent use (novelty) |
| 165 | hoursSinceUse := now.Sub(weighted.LastUsed).Hours() |
| 166 | noveltyBonus := 1.0 |
| 167 | if hoursSinceUse < 1 { |
| 168 | noveltyBonus = 0.1 // Heavy penalty |
| 169 | } else if hoursSinceUse < 24 { |
| 170 | noveltyBonus = 0.5 // Medium penalty |
| 171 | } |
| 172 | |
| 173 | score *= noveltyBonus |
| 174 | |
| 175 | if score > bestScore { |
| 176 | bestScore = score |
| 177 | bestInsult = weighted.Text |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | return bestInsult |
| 182 | } |
| 183 | |
| 184 | // GetTransitionInsult gets insult specific to failure sequence |
| 185 | func (cmg *ContextualMemoryGraph) GetTransitionInsult(fromContext, toContext string) string { |
| 186 | cmg.mu.RLock() |
| 187 | defer cmg.mu.RUnlock() |
| 188 | |
| 189 | node, exists := cmg.nodes[toContext] |
| 190 | if !exists { |
| 191 | return "" |
| 192 | } |
| 193 | |
| 194 | transition, exists := node.Transitions[fromContext] |
| 195 | if !exists || len(transition.SpecialInsults) == 0 { |
| 196 | return "" |
| 197 | } |
| 198 | |
| 199 | // Return a transition-specific insult |
| 200 | idx := transition.Count % len(transition.SpecialInsults) |
| 201 | return transition.SpecialInsults[idx] |
| 202 | } |
| 203 | |
| 204 | // RecordInsultUse records that an insult was used |
| 205 | func (cmg *ContextualMemoryGraph) RecordInsultUse(contextID string, insult string) { |
| 206 | cmg.mu.Lock() |
| 207 | defer cmg.mu.Unlock() |
| 208 | |
| 209 | node, exists := cmg.nodes[contextID] |
| 210 | if !exists { |
| 211 | return |
| 212 | } |
| 213 | |
| 214 | // Find and update the insult |
| 215 | for i := range node.InsultPool { |
| 216 | if node.InsultPool[i].Text == insult { |
| 217 | node.InsultPool[i].UseCount++ |
| 218 | node.InsultPool[i].LastUsed = time.Now() |
| 219 | break |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | // RecordSuccess records that user moved past this failure |
| 225 | func (cmg *ContextualMemoryGraph) RecordSuccess(contextID string) { |
| 226 | cmg.mu.Lock() |
| 227 | defer cmg.mu.Unlock() |
| 228 | |
| 229 | node, exists := cmg.nodes[contextID] |
| 230 | if !exists { |
| 231 | return |
| 232 | } |
| 233 | |
| 234 | // Update success rate (exponential moving average) |
| 235 | alpha := 0.2 |
| 236 | node.SuccessRate = alpha*1.0 + (1-alpha)*node.SuccessRate |
| 237 | |
| 238 | // Boost effectiveness of recently used insults (they "worked") |
| 239 | now := time.Now() |
| 240 | for i := range node.InsultPool { |
| 241 | if now.Sub(node.InsultPool[i].LastUsed).Minutes() < 5 { |
| 242 | // This insult was used recently and user succeeded - boost it! |
| 243 | node.InsultPool[i].Effectiveness = |
| 244 | 0.2*1.0 + 0.8*node.InsultPool[i].Effectiveness |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | // AddInsultToPool adds an insult to context-specific pool |
| 250 | func (cmg *ContextualMemoryGraph) AddInsultToPool(contextID string, insult string, weight float64) { |
| 251 | cmg.mu.Lock() |
| 252 | defer cmg.mu.Unlock() |
| 253 | |
| 254 | node, exists := cmg.nodes[contextID] |
| 255 | if !exists { |
| 256 | return |
| 257 | } |
| 258 | |
| 259 | // Check if already exists |
| 260 | for i := range node.InsultPool { |
| 261 | if node.InsultPool[i].Text == insult { |
| 262 | // Already exists, just update weight |
| 263 | node.InsultPool[i].DynamicWeight = |
| 264 | 0.5*weight + 0.5*node.InsultPool[i].DynamicWeight |
| 265 | return |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // Add new insult |
| 270 | weighted := WeightedInsult{ |
| 271 | Text: insult, |
| 272 | BaseWeight: weight, |
| 273 | DynamicWeight: weight, |
| 274 | UseCount: 0, |
| 275 | LastUsed: time.Now().Add(-24 * time.Hour), // Start as "old" |
| 276 | Effectiveness: 0.5, // Start neutral |
| 277 | } |
| 278 | |
| 279 | node.InsultPool = append(node.InsultPool, weighted) |
| 280 | |
| 281 | // Prune pool if too large (remove least effective) |
| 282 | if len(node.InsultPool) > cmg.maxPoolSize { |
| 283 | cmg.pruneInsultPool(node) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | // pruneInsultPool removes least effective insults |
| 288 | func (cmg *ContextualMemoryGraph) pruneInsultPool(node *ContextNode) { |
| 289 | // Sort by effectiveness |
| 290 | pool := node.InsultPool |
| 291 | |
| 292 | // Simple selection sort to find worst |
| 293 | for i := 0; i < len(pool)-1; i++ { |
| 294 | minIdx := i |
| 295 | for j := i + 1; j < len(pool); j++ { |
| 296 | if pool[j].Effectiveness < pool[minIdx].Effectiveness { |
| 297 | minIdx = j |
| 298 | } |
| 299 | } |
| 300 | if minIdx != i { |
| 301 | pool[i], pool[minIdx] = pool[minIdx], pool[i] |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | // Remove bottom 10% |
| 306 | removeCount := cmg.maxPoolSize / 10 |
| 307 | if removeCount < 1 { |
| 308 | removeCount = 1 |
| 309 | } |
| 310 | |
| 311 | node.InsultPool = pool[removeCount:] |
| 312 | } |
| 313 | |
| 314 | // ApplyDecay applies decay to all insult weights (prevents stagnation) |
| 315 | func (cmg *ContextualMemoryGraph) ApplyDecay() { |
| 316 | cmg.mu.Lock() |
| 317 | defer cmg.mu.Unlock() |
| 318 | |
| 319 | for _, node := range cmg.nodes { |
| 320 | for i := range node.InsultPool { |
| 321 | // Decay dynamic weight back toward base weight |
| 322 | node.InsultPool[i].DynamicWeight = |
| 323 | cmg.decayFactor*node.InsultPool[i].DynamicWeight + |
| 324 | (1-cmg.decayFactor)*node.InsultPool[i].BaseWeight |
| 325 | } |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | // GetStats returns graph statistics |
| 330 | func (cmg *ContextualMemoryGraph) GetStats() map[string]interface{} { |
| 331 | cmg.mu.RLock() |
| 332 | defer cmg.mu.RUnlock() |
| 333 | |
| 334 | totalInsults := 0 |
| 335 | totalTransitions := 0 |
| 336 | |
| 337 | for _, node := range cmg.nodes { |
| 338 | totalInsults += len(node.InsultPool) |
| 339 | totalTransitions += len(node.Transitions) |
| 340 | } |
| 341 | |
| 342 | return map[string]interface{}{ |
| 343 | "total_contexts": len(cmg.nodes), |
| 344 | "total_insults": totalInsults, |
| 345 | "total_transitions": totalTransitions, |
| 346 | "current_context": cmg.currentContext, |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | // Save persists the graph to disk |
| 351 | func (cmg *ContextualMemoryGraph) Save() error { |
| 352 | cmg.mu.RLock() |
| 353 | defer cmg.mu.RUnlock() |
| 354 | |
| 355 | // Ensure directory exists |
| 356 | dir := filepath.Dir(cmg.persistencePath) |
| 357 | os.MkdirAll(dir, 0755) |
| 358 | |
| 359 | // Marshal to JSON |
| 360 | data, err := json.MarshalIndent(cmg.nodes, "", " ") |
| 361 | if err != nil { |
| 362 | return err |
| 363 | } |
| 364 | |
| 365 | // Write atomically |
| 366 | tmpPath := cmg.persistencePath + ".tmp" |
| 367 | if err := os.WriteFile(tmpPath, data, 0644); err != nil { |
| 368 | return err |
| 369 | } |
| 370 | |
| 371 | return os.Rename(tmpPath, cmg.persistencePath) |
| 372 | } |
| 373 | |
| 374 | // Load restores the graph from disk |
| 375 | func (cmg *ContextualMemoryGraph) Load() error { |
| 376 | cmg.mu.Lock() |
| 377 | defer cmg.mu.Unlock() |
| 378 | |
| 379 | data, err := os.ReadFile(cmg.persistencePath) |
| 380 | if err != nil { |
| 381 | return err // File might not exist yet |
| 382 | } |
| 383 | |
| 384 | nodes := make(map[string]*ContextNode) |
| 385 | if err := json.Unmarshal(data, &nodes); err != nil { |
| 386 | return err |
| 387 | } |
| 388 | |
| 389 | cmg.nodes = nodes |
| 390 | return nil |
| 391 | } |
| 392 | |
| 393 | // Helper functions |
| 394 | |
| 395 | func (cmg *ContextualMemoryGraph) generateContextID(ctx *SmartFallbackContext) string { |
| 396 | // Create a unique but abstract ID |
| 397 | // Format: commandType_errorPattern_projectType |
| 398 | parts := []string{ |
| 399 | ctx.CommandType, |
| 400 | ctx.ErrorPattern, |
| 401 | ctx.ProjectType, |
| 402 | } |
| 403 | |
| 404 | id := "" |
| 405 | for i, part := range parts { |
| 406 | if part == "" { |
| 407 | part = "unknown" |
| 408 | } |
| 409 | if i > 0 { |
| 410 | id += "_" |
| 411 | } |
| 412 | id += part |
| 413 | } |
| 414 | |
| 415 | return id |
| 416 | } |
| 417 | |
| 418 | func abstractCommand(command string) string { |
| 419 | // Replace specific values with placeholders |
| 420 | // "git push origin feature-123" -> "git push origin <branch>" |
| 421 | |
| 422 | abstract := command |
| 423 | |
| 424 | // Replace file paths |
| 425 | abstract = replacePattern(abstract, `[\w/]+\.(js|ts|py|go|rs|java|cpp)`, "<file>") |
| 426 | |
| 427 | // Replace branch names |
| 428 | abstract = replacePattern(abstract, `(feature|bugfix|hotfix)/[\w-]+`, "<branch>") |
| 429 | |
| 430 | // Replace version numbers |
| 431 | abstract = replacePattern(abstract, `\d+\.\d+\.\d+`, "<version>") |
| 432 | |
| 433 | // Replace ports |
| 434 | abstract = replacePattern(abstract, `:\d{2,5}`, ":<port>") |
| 435 | |
| 436 | return abstract |
| 437 | } |
| 438 | |
| 439 | func replacePattern(text string, pattern string, replacement string) string { |
| 440 | // Simple pattern replacement (in production, use regexp) |
| 441 | // This is a placeholder - implement properly |
| 442 | return text |
| 443 | } |
| 444 | |
| 445 | func (cmg *ContextualMemoryGraph) addTransitionInsult(transition *Transition, ctx *SmartFallbackContext) { |
| 446 | // Generate insult specific to this failure sequence |
| 447 | insults := []string{ |
| 448 | "Same mistake, different hour. Classic.", |
| 449 | "Trying again won't make it work. Won't make you smarter either.", |
| 450 | "Definition of insanity: Detected.", |
| 451 | "Still failing? Maybe it's not the computer.", |
| 452 | "Repeated failure #" + intToStr(transition.Count) + ": The saga continues.", |
| 453 | "Your consistency is admirable. Consistently wrong.", |
| 454 | } |
| 455 | |
| 456 | // Add if not already present |
| 457 | for _, insult := range insults { |
| 458 | found := false |
| 459 | for _, existing := range transition.SpecialInsults { |
| 460 | if existing == insult { |
| 461 | found = true |
| 462 | break |
| 463 | } |
| 464 | } |
| 465 | if !found && len(transition.SpecialInsults) < 10 { |
| 466 | transition.SpecialInsults = append(transition.SpecialInsults, insult) |
| 467 | } |
| 468 | } |
| 469 | } |
| 470 | |
| 471 | func intToStr(n int) string { |
| 472 | if n == 0 { |
| 473 | return "0" |
| 474 | } |
| 475 | |
| 476 | digits := "" |
| 477 | for n > 0 { |
| 478 | digits = string('0'+rune(n%10)) + digits |
| 479 | n /= 10 |
| 480 | } |
| 481 | return digits |
| 482 | } |
| 483 | |
| 484 |