recursive-llm-ts 4.9.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,615 @@
1
+ package rlm
2
+
3
+ import (
4
+ "fmt"
5
+ "regexp"
6
+ "strings"
7
+ "sync"
8
+ "time"
9
+ )
10
+
11
+ // ─── LCM Data Model ─────────────────────────────────────────────────────────
12
+ // Implements the dual-state memory architecture from the LCM paper:
13
+ // - Immutable Store: every message persisted verbatim, never modified
14
+ // - Active Context: assembled from recent messages + summary nodes
15
+
16
+ // MessageRole represents the role of a message sender.
17
+ type MessageRole string
18
+
19
+ const (
20
+ RoleUser MessageRole = "user"
21
+ RoleAssistant MessageRole = "assistant"
22
+ RoleTool MessageRole = "tool"
23
+ RoleSystem MessageRole = "system"
24
+ )
25
+
26
+ // StoreMessage is an immutable record in the LCM store.
27
+ // Once persisted, it is never modified or deleted.
28
+ type StoreMessage struct {
29
+ ID string `json:"id"`
30
+ Role MessageRole `json:"role"`
31
+ Content string `json:"content"`
32
+ Tokens int `json:"tokens"`
33
+ Timestamp time.Time `json:"timestamp"`
34
+ FileIDs []string `json:"file_ids,omitempty"` // Referenced file IDs
35
+ Metadata map[string]string `json:"metadata,omitempty"`
36
+ }
37
+
38
+ // SummaryKind distinguishes leaf from condensed summaries in the DAG.
39
+ type SummaryKind string
40
+
41
+ const (
42
+ SummaryLeaf SummaryKind = "leaf" // Direct summary of a span of messages
43
+ SummaryCondensed SummaryKind = "condensed" // Higher-order summary of other summaries
44
+ )
45
+
46
+ // SummaryNode is a node in the hierarchical summary DAG.
47
+ // It represents a compressed view of a span of messages or other summaries.
48
+ type SummaryNode struct {
49
+ ID string `json:"id"`
50
+ Kind SummaryKind `json:"kind"`
51
+ Content string `json:"content"` // The summary text
52
+ Tokens int `json:"tokens"` // Token count of this summary
53
+ Level int `json:"level"` // Escalation level that produced this (1, 2, or 3)
54
+ CreatedAt time.Time `json:"created_at"`
55
+
56
+ // Provenance: what this summary covers
57
+ MessageIDs []string `json:"message_ids"` // IDs of messages directly summarized (leaf)
58
+ ChildIDs []string `json:"child_ids"` // IDs of child summary nodes (condensed)
59
+ ParentID string `json:"parent_id,omitempty"` // Parent summary node (if further condensed)
60
+
61
+ // File IDs propagated from summarized messages
62
+ FileIDs []string `json:"file_ids,omitempty"`
63
+ }
64
+
65
+ // ActiveContextItem represents one item in the active context window.
66
+ // It's either a raw message or a pointer to a summary node.
67
+ type ActiveContextItem struct {
68
+ // Exactly one of these is set
69
+ Message *StoreMessage `json:"message,omitempty"`
70
+ Summary *SummaryNode `json:"summary,omitempty"`
71
+
72
+ // Position in the active context (for ordering)
73
+ Position int `json:"position"`
74
+ }
75
+
76
+ // IsMessage returns true if this item is a raw message (not summarized).
77
+ func (i *ActiveContextItem) IsMessage() bool {
78
+ return i.Message != nil
79
+ }
80
+
81
+ // GetTokens returns the token count for this item.
82
+ func (i *ActiveContextItem) GetTokens() int {
83
+ if i.Message != nil {
84
+ return i.Message.Tokens
85
+ }
86
+ if i.Summary != nil {
87
+ return i.Summary.Tokens
88
+ }
89
+ return 0
90
+ }
91
+
92
+ // GetContent returns the content text.
93
+ func (i *ActiveContextItem) GetContent() string {
94
+ if i.Message != nil {
95
+ return i.Message.Content
96
+ }
97
+ if i.Summary != nil {
98
+ return i.Summary.Content
99
+ }
100
+ return ""
101
+ }
102
+
103
+ // GetFileIDs returns file IDs from this item.
104
+ func (i *ActiveContextItem) GetFileIDs() []string {
105
+ if i.Message != nil {
106
+ return i.Message.FileIDs
107
+ }
108
+ if i.Summary != nil {
109
+ return i.Summary.FileIDs
110
+ }
111
+ return nil
112
+ }
113
+
114
+ // ─── LCM Store ──────────────────────────────────────────────────────────────
115
+
116
+ // LCMStore is the persistent, transactional store for LCM sessions.
117
+ // It maintains the immutable message history and the derived summary DAG.
118
+ // This is an in-memory implementation; production could use embedded PostgreSQL.
119
+ type LCMStore struct {
120
+ mu sync.RWMutex
121
+
122
+ // Immutable Store: messages indexed by ID
123
+ messages map[string]*StoreMessage
124
+ messageSeq []*StoreMessage // Chronological order
125
+
126
+ // Summary DAG: summary nodes indexed by ID
127
+ summaries map[string]*SummaryNode
128
+
129
+ // Active Context: the window sent to the LLM
130
+ active []*ActiveContextItem
131
+
132
+ // Counters
133
+ nextMsgID int
134
+ nextSumID int
135
+
136
+ // Session metadata
137
+ sessionID string
138
+ createdAt time.Time
139
+ }
140
+
141
+ // NewLCMStore creates a new empty LCM store for a session.
142
+ func NewLCMStore(sessionID string) *LCMStore {
143
+ return &LCMStore{
144
+ messages: make(map[string]*StoreMessage),
145
+ messageSeq: make([]*StoreMessage, 0),
146
+ summaries: make(map[string]*SummaryNode),
147
+ active: make([]*ActiveContextItem, 0),
148
+ sessionID: sessionID,
149
+ createdAt: time.Now(),
150
+ }
151
+ }
152
+
153
+ // ─── Immutable Store Operations ─────────────────────────────────────────────
154
+
155
+ // PersistMessage adds a message to the immutable store and active context.
156
+ // The message is never modified after this call.
157
+ func (s *LCMStore) PersistMessage(role MessageRole, content string, fileIDs []string) *StoreMessage {
158
+ s.mu.Lock()
159
+ defer s.mu.Unlock()
160
+
161
+ s.nextMsgID++
162
+ msg := &StoreMessage{
163
+ ID: fmt.Sprintf("msg_%s_%d", s.sessionID, s.nextMsgID),
164
+ Role: role,
165
+ Content: content,
166
+ Tokens: EstimateTokens(content),
167
+ Timestamp: time.Now(),
168
+ FileIDs: fileIDs,
169
+ }
170
+
171
+ s.messages[msg.ID] = msg
172
+ s.messageSeq = append(s.messageSeq, msg)
173
+
174
+ // Add to active context as a raw message pointer
175
+ s.active = append(s.active, &ActiveContextItem{
176
+ Message: msg,
177
+ Position: len(s.active),
178
+ })
179
+
180
+ return msg
181
+ }
182
+
183
+ // GetMessage retrieves a message by ID from the immutable store.
184
+ func (s *LCMStore) GetMessage(id string) (*StoreMessage, bool) {
185
+ s.mu.RLock()
186
+ defer s.mu.RUnlock()
187
+ msg, ok := s.messages[id]
188
+ return msg, ok
189
+ }
190
+
191
+ // GetAllMessages returns all messages in chronological order.
192
+ func (s *LCMStore) GetAllMessages() []*StoreMessage {
193
+ s.mu.RLock()
194
+ defer s.mu.RUnlock()
195
+ result := make([]*StoreMessage, len(s.messageSeq))
196
+ copy(result, s.messageSeq)
197
+ return result
198
+ }
199
+
200
+ // MessageCount returns the number of messages in the store.
201
+ func (s *LCMStore) MessageCount() int {
202
+ s.mu.RLock()
203
+ defer s.mu.RUnlock()
204
+ return len(s.messageSeq)
205
+ }
206
+
207
+ // ─── Summary DAG Operations ─────────────────────────────────────────────────
208
+
209
+ // CreateLeafSummary creates a summary node from a span of messages.
210
+ func (s *LCMStore) CreateLeafSummary(messageIDs []string, content string, level int) *SummaryNode {
211
+ s.mu.Lock()
212
+ defer s.mu.Unlock()
213
+
214
+ s.nextSumID++
215
+ node := &SummaryNode{
216
+ ID: fmt.Sprintf("sum_%s_%d", s.sessionID, s.nextSumID),
217
+ Kind: SummaryLeaf,
218
+ Content: content,
219
+ Tokens: EstimateTokens(content),
220
+ Level: level,
221
+ CreatedAt: time.Now(),
222
+ MessageIDs: messageIDs,
223
+ }
224
+
225
+ // Propagate file IDs from summarized messages
226
+ fileIDSet := make(map[string]bool)
227
+ for _, msgID := range messageIDs {
228
+ if msg, ok := s.messages[msgID]; ok {
229
+ for _, fid := range msg.FileIDs {
230
+ fileIDSet[fid] = true
231
+ }
232
+ }
233
+ }
234
+ for fid := range fileIDSet {
235
+ node.FileIDs = append(node.FileIDs, fid)
236
+ }
237
+
238
+ s.summaries[node.ID] = node
239
+ return node
240
+ }
241
+
242
+ // CreateCondensedSummary creates a higher-order summary from existing summaries.
243
+ func (s *LCMStore) CreateCondensedSummary(childIDs []string, content string, level int) *SummaryNode {
244
+ s.mu.Lock()
245
+ defer s.mu.Unlock()
246
+
247
+ s.nextSumID++
248
+ node := &SummaryNode{
249
+ ID: fmt.Sprintf("sum_%s_%d", s.sessionID, s.nextSumID),
250
+ Kind: SummaryCondensed,
251
+ Content: content,
252
+ Tokens: EstimateTokens(content),
253
+ Level: level,
254
+ CreatedAt: time.Now(),
255
+ ChildIDs: childIDs,
256
+ }
257
+
258
+ // Propagate file IDs from child summaries and set parent pointers
259
+ fileIDSet := make(map[string]bool)
260
+ for _, childID := range childIDs {
261
+ if child, ok := s.summaries[childID]; ok {
262
+ child.ParentID = node.ID
263
+ for _, fid := range child.FileIDs {
264
+ fileIDSet[fid] = true
265
+ }
266
+ }
267
+ }
268
+ for fid := range fileIDSet {
269
+ node.FileIDs = append(node.FileIDs, fid)
270
+ }
271
+
272
+ s.summaries[node.ID] = node
273
+ return node
274
+ }
275
+
276
+ // GetSummary retrieves a summary node by ID.
277
+ func (s *LCMStore) GetSummary(id string) (*SummaryNode, bool) {
278
+ s.mu.RLock()
279
+ defer s.mu.RUnlock()
280
+ sum, ok := s.summaries[id]
281
+ return sum, ok
282
+ }
283
+
284
+ // ExpandSummary returns the original messages that a summary covers.
285
+ // For leaf summaries, returns the directly summarized messages.
286
+ // For condensed summaries, recursively expands all children.
287
+ // NOTE: This is the unrestricted internal method. External callers should use
288
+ // ExpandSummaryRestricted which enforces the sub-agent-only policy from the LCM paper.
289
+ func (s *LCMStore) ExpandSummary(summaryID string) ([]*StoreMessage, error) {
290
+ s.mu.RLock()
291
+ defer s.mu.RUnlock()
292
+
293
+ sum, ok := s.summaries[summaryID]
294
+ if !ok {
295
+ return nil, fmt.Errorf("summary %s not found", summaryID)
296
+ }
297
+
298
+ return s.expandSummaryLocked(sum)
299
+ }
300
+
301
+ // ExpandSummaryRestricted enforces the LCM paper's restriction (Appendix C.1):
302
+ // lcm_expand can only be called by sub-agents (depth > 0), not the main agent.
303
+ // This prevents uncontrolled context growth in the primary interaction loop.
304
+ // When the main agent needs to inspect compacted history, it must delegate
305
+ // the expansion to a sub-agent via the Task tool.
306
+ func (s *LCMStore) ExpandSummaryRestricted(summaryID string, callerDepth int) ([]*StoreMessage, error) {
307
+ if callerDepth == 0 {
308
+ return nil, &ExpandRestrictionError{
309
+ SummaryID: summaryID,
310
+ }
311
+ }
312
+ return s.ExpandSummary(summaryID)
313
+ }
314
+
315
+ // ExpandRestrictionError is returned when the main agent tries to call lcm_expand.
316
+ type ExpandRestrictionError struct {
317
+ SummaryID string
318
+ }
319
+
320
+ func (e *ExpandRestrictionError) Error() string {
321
+ return fmt.Sprintf(
322
+ "lcm_expand(%s) rejected: only sub-agents can expand summaries. "+
323
+ "Delegate the expansion to a sub-agent via the Task tool, which will "+
324
+ "process the expanded content in its own context window and return only the relevant findings.",
325
+ e.SummaryID,
326
+ )
327
+ }
328
+
329
+ func (s *LCMStore) expandSummaryLocked(sum *SummaryNode) ([]*StoreMessage, error) {
330
+ if sum.Kind == SummaryLeaf {
331
+ var msgs []*StoreMessage
332
+ for _, msgID := range sum.MessageIDs {
333
+ if msg, ok := s.messages[msgID]; ok {
334
+ msgs = append(msgs, msg)
335
+ }
336
+ }
337
+ return msgs, nil
338
+ }
339
+
340
+ // Condensed: recursively expand children
341
+ var allMsgs []*StoreMessage
342
+ for _, childID := range sum.ChildIDs {
343
+ child, ok := s.summaries[childID]
344
+ if !ok {
345
+ continue
346
+ }
347
+ childMsgs, err := s.expandSummaryLocked(child)
348
+ if err != nil {
349
+ return nil, err
350
+ }
351
+ allMsgs = append(allMsgs, childMsgs...)
352
+ }
353
+ return allMsgs, nil
354
+ }
355
+
356
+ // ─── Active Context Operations ──────────────────────────────────────────────
357
+
358
+ // GetActiveContext returns the current active context items.
359
+ func (s *LCMStore) GetActiveContext() []*ActiveContextItem {
360
+ s.mu.RLock()
361
+ defer s.mu.RUnlock()
362
+ result := make([]*ActiveContextItem, len(s.active))
363
+ copy(result, s.active)
364
+ return result
365
+ }
366
+
367
+ // ActiveContextTokens returns the total token count of the active context.
368
+ func (s *LCMStore) ActiveContextTokens() int {
369
+ s.mu.RLock()
370
+ defer s.mu.RUnlock()
371
+ total := 0
372
+ for _, item := range s.active {
373
+ total += item.GetTokens()
374
+ }
375
+ return total
376
+ }
377
+
378
+ // CompactOldestBlock replaces the oldest block of raw messages in active context
379
+ // with a summary node. Returns the IDs of compacted messages.
380
+ func (s *LCMStore) CompactOldestBlock(summary *SummaryNode) []string {
381
+ s.mu.Lock()
382
+ defer s.mu.Unlock()
383
+
384
+ // Find the oldest contiguous block of raw messages (skip first item if system prompt)
385
+ startIdx := 0
386
+ for startIdx < len(s.active) {
387
+ if s.active[startIdx].IsMessage() && s.active[startIdx].Message.Role == RoleSystem {
388
+ startIdx++ // Skip system prompt
389
+ continue
390
+ }
391
+ break
392
+ }
393
+
394
+ // Collect message IDs from the block that this summary covers
395
+ compactedIDs := make(map[string]bool)
396
+ for _, msgID := range summary.MessageIDs {
397
+ compactedIDs[msgID] = true
398
+ }
399
+
400
+ // Build new active context replacing compacted messages with summary
401
+ var newActive []*ActiveContextItem
402
+ summaryInserted := false
403
+ var removedIDs []string
404
+
405
+ for _, item := range s.active {
406
+ if item.IsMessage() && compactedIDs[item.Message.ID] {
407
+ removedIDs = append(removedIDs, item.Message.ID)
408
+ if !summaryInserted {
409
+ newActive = append(newActive, &ActiveContextItem{
410
+ Summary: summary,
411
+ Position: len(newActive),
412
+ })
413
+ summaryInserted = true
414
+ }
415
+ } else {
416
+ item.Position = len(newActive)
417
+ newActive = append(newActive, item)
418
+ }
419
+ }
420
+
421
+ s.active = newActive
422
+ return removedIDs
423
+ }
424
+
425
+ // BuildMessages converts the active context into a Messages slice for LLM calls.
426
+ // Summary nodes include their IDs as annotations for deterministic retrievability.
427
+ func (s *LCMStore) BuildMessages() []Message {
428
+ s.mu.RLock()
429
+ defer s.mu.RUnlock()
430
+
431
+ var msgs []Message
432
+ for _, item := range s.active {
433
+ if item.IsMessage() {
434
+ msgs = append(msgs, Message{
435
+ Role: string(item.Message.Role),
436
+ Content: item.Message.Content,
437
+ })
438
+ } else if item.Summary != nil {
439
+ // Annotate summary with IDs for deterministic retrievability
440
+ content := fmt.Sprintf("[Summary %s | covers %d items]\n%s",
441
+ item.Summary.ID,
442
+ len(item.Summary.MessageIDs)+len(item.Summary.ChildIDs),
443
+ item.Summary.Content,
444
+ )
445
+ // Summary nodes are presented as system-level context
446
+ msgs = append(msgs, Message{
447
+ Role: "system",
448
+ Content: content,
449
+ })
450
+ }
451
+ }
452
+ return msgs
453
+ }
454
+
455
+ // ─── LCM Grep (Search) ─────────────────────────────────────────────────────
456
+
457
+ // GrepResult represents a search hit from lcm_grep.
458
+ type GrepResult struct {
459
+ Message *StoreMessage `json:"message"`
460
+ SummaryID string `json:"summary_id,omitempty"` // Summary that covers this message
461
+ MatchLine string `json:"match_line"`
462
+ }
463
+
464
+ // Grep searches the immutable store for messages matching a regex pattern.
465
+ // Results are grouped by the summary node that currently covers them.
466
+ func (s *LCMStore) Grep(pattern string, maxResults int) ([]GrepResult, error) {
467
+ s.mu.RLock()
468
+ defer s.mu.RUnlock()
469
+
470
+ re, err := regexp.Compile(pattern)
471
+ if err != nil {
472
+ return nil, fmt.Errorf("invalid regex pattern: %w", err)
473
+ }
474
+
475
+ if maxResults <= 0 {
476
+ maxResults = 50 // Default pagination
477
+ }
478
+
479
+ // Build reverse index: message ID → covering summary ID
480
+ coveringSum := make(map[string]string)
481
+ for _, sum := range s.summaries {
482
+ if sum.Kind == SummaryLeaf {
483
+ for _, msgID := range sum.MessageIDs {
484
+ // Use the deepest (most specific) summary
485
+ if _, exists := coveringSum[msgID]; !exists {
486
+ coveringSum[msgID] = sum.ID
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ var results []GrepResult
493
+ for _, msg := range s.messageSeq {
494
+ if len(results) >= maxResults {
495
+ break
496
+ }
497
+ if re.MatchString(msg.Content) {
498
+ // Extract matching line
499
+ matchLine := ""
500
+ for _, line := range strings.Split(msg.Content, "\n") {
501
+ if re.MatchString(line) {
502
+ matchLine = line
503
+ break
504
+ }
505
+ }
506
+ results = append(results, GrepResult{
507
+ Message: msg,
508
+ SummaryID: coveringSum[msg.ID],
509
+ MatchLine: matchLine,
510
+ })
511
+ }
512
+ }
513
+
514
+ return results, nil
515
+ }
516
+
517
+ // ─── LCM Describe ──────────────────────────────────────────────────────────
518
+
519
+ // DescribeResult contains metadata about an LCM identifier.
520
+ type DescribeResult struct {
521
+ Type string `json:"type"` // "message" or "summary"
522
+ ID string `json:"id"`
523
+ Tokens int `json:"tokens"`
524
+ Metadata map[string]string `json:"metadata,omitempty"`
525
+
526
+ // Message-specific
527
+ Role string `json:"role,omitempty"`
528
+ Timestamp *time.Time `json:"timestamp,omitempty"`
529
+
530
+ // Summary-specific
531
+ Kind string `json:"kind,omitempty"`
532
+ Level int `json:"level,omitempty"`
533
+ CoveredIDs []string `json:"covered_ids,omitempty"`
534
+ FileIDs []string `json:"file_ids,omitempty"`
535
+ Content string `json:"content,omitempty"` // Summary text (not for messages)
536
+ }
537
+
538
+ // Describe returns metadata for any LCM identifier (message or summary).
539
+ func (s *LCMStore) Describe(id string) (*DescribeResult, error) {
540
+ s.mu.RLock()
541
+ defer s.mu.RUnlock()
542
+
543
+ if msg, ok := s.messages[id]; ok {
544
+ return &DescribeResult{
545
+ Type: "message",
546
+ ID: msg.ID,
547
+ Tokens: msg.Tokens,
548
+ Role: string(msg.Role),
549
+ Timestamp: &msg.Timestamp,
550
+ FileIDs: msg.FileIDs,
551
+ Metadata: msg.Metadata,
552
+ }, nil
553
+ }
554
+
555
+ if sum, ok := s.summaries[id]; ok {
556
+ coveredIDs := sum.MessageIDs
557
+ if sum.Kind == SummaryCondensed {
558
+ coveredIDs = sum.ChildIDs
559
+ }
560
+ return &DescribeResult{
561
+ Type: "summary",
562
+ ID: sum.ID,
563
+ Tokens: sum.Tokens,
564
+ Kind: string(sum.Kind),
565
+ Level: sum.Level,
566
+ CoveredIDs: coveredIDs,
567
+ FileIDs: sum.FileIDs,
568
+ Content: sum.Content,
569
+ }, nil
570
+ }
571
+
572
+ return nil, fmt.Errorf("LCM identifier %s not found", id)
573
+ }
574
+
575
+ // ─── Statistics ─────────────────────────────────────────────────────────────
576
+
577
+ // LCMStoreStats contains runtime statistics about the store.
578
+ type LCMStoreStats struct {
579
+ TotalMessages int `json:"total_messages"`
580
+ TotalSummaries int `json:"total_summaries"`
581
+ ActiveContextItems int `json:"active_context_items"`
582
+ ActiveContextTokens int `json:"active_context_tokens"`
583
+ ImmutableStoreTokens int `json:"immutable_store_tokens"`
584
+ CompressionRatio float64 `json:"compression_ratio"` // active/total
585
+ }
586
+
587
+ // Stats returns current statistics about the store.
588
+ func (s *LCMStore) Stats() LCMStoreStats {
589
+ s.mu.RLock()
590
+ defer s.mu.RUnlock()
591
+
592
+ totalTokens := 0
593
+ for _, msg := range s.messageSeq {
594
+ totalTokens += msg.Tokens
595
+ }
596
+
597
+ activeTokens := 0
598
+ for _, item := range s.active {
599
+ activeTokens += item.GetTokens()
600
+ }
601
+
602
+ ratio := 0.0
603
+ if totalTokens > 0 {
604
+ ratio = float64(activeTokens) / float64(totalTokens)
605
+ }
606
+
607
+ return LCMStoreStats{
608
+ TotalMessages: len(s.messageSeq),
609
+ TotalSummaries: len(s.summaries),
610
+ ActiveContextItems: len(s.active),
611
+ ActiveContextTokens: activeTokens,
612
+ ImmutableStoreTokens: totalTokens,
613
+ CompressionRatio: ratio,
614
+ }
615
+ }