Go · 12670 bytes Raw Blame History
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