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.
- package/README.md +3 -1
- package/bin/rlm-go +0 -0
- package/dist/bridge-interface.d.ts +149 -0
- package/go/cmd/rlm/main.go +39 -6
- package/go/go.mod +13 -3
- package/go/go.sum +53 -2
- package/go/rlm/compression.go +59 -0
- package/go/rlm/context_overflow.go +21 -36
- package/go/rlm/context_savings_test.go +387 -0
- package/go/rlm/json_extraction.go +140 -0
- package/go/rlm/lcm_agentic_map.go +317 -0
- package/go/rlm/lcm_context_loop.go +309 -0
- package/go/rlm/lcm_delegation.go +257 -0
- package/go/rlm/lcm_episodes.go +313 -0
- package/go/rlm/lcm_episodes_test.go +384 -0
- package/go/rlm/lcm_files.go +424 -0
- package/go/rlm/lcm_map.go +348 -0
- package/go/rlm/lcm_store.go +615 -0
- package/go/rlm/lcm_summarizer.go +239 -0
- package/go/rlm/lcm_test.go +1407 -0
- package/go/rlm/rlm.go +124 -1
- package/go/rlm/store_backend.go +121 -0
- package/go/rlm/store_backend_test.go +428 -0
- package/go/rlm/store_sqlite.go +575 -0
- package/go/rlm/structured.go +6 -83
- package/go/rlm/token_tracking_test.go +25 -11
- package/go/rlm/tokenizer.go +216 -0
- package/go/rlm/tokenizer_test.go +305 -0
- package/go/rlm/types.go +23 -1
- package/go/rlm.test +0 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|