recursive-llm-ts 4.8.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 +5 -3
- package/bin/rlm-go +0 -0
- package/dist/bridge-interface.d.ts +152 -0
- package/dist/rlm.js +10 -0
- package/go/README.md +2 -2
- package/go/cmd/rlm/main.go +40 -7
- package/go/go.mod +14 -4
- package/go/go.sum +53 -2
- package/go/rlm/compression.go +59 -0
- package/go/rlm/context_overflow.go +49 -43
- package/go/rlm/context_savings_test.go +387 -0
- package/go/rlm/doc.go +2 -2
- 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/meta_agent.go +18 -2
- package/go/rlm/observability.go +6 -0
- package/go/rlm/openai.go +27 -10
- package/go/rlm/rlm.go +135 -4
- 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 +15 -83
- package/go/rlm/token_tracking_test.go +859 -0
- package/go/rlm/tokenizer.go +216 -0
- package/go/rlm/tokenizer_test.go +305 -0
- package/go/rlm/types.go +30 -5
- package/go/rlm.test +0 -0
- package/package.json +4 -4
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
package rlm
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
"sync"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// EpisodeStatus describes lifecycle state for an episode.
|
|
11
|
+
type EpisodeStatus string
|
|
12
|
+
|
|
13
|
+
const (
|
|
14
|
+
// EpisodeActive is currently accumulating messages.
|
|
15
|
+
EpisodeActive EpisodeStatus = "active"
|
|
16
|
+
// EpisodeCompacted has a generated summary while messages remain available.
|
|
17
|
+
EpisodeCompacted EpisodeStatus = "compacted"
|
|
18
|
+
// EpisodeArchived is deeply compressed and represented by summary in active context.
|
|
19
|
+
EpisodeArchived EpisodeStatus = "archived"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// Episode is a coherent interaction unit that groups related messages.
|
|
23
|
+
type Episode struct {
|
|
24
|
+
ID string `json:"id"`
|
|
25
|
+
Title string `json:"title"`
|
|
26
|
+
MessageIDs []string `json:"message_ids"`
|
|
27
|
+
StartTime time.Time `json:"start_time"`
|
|
28
|
+
EndTime time.Time `json:"end_time"`
|
|
29
|
+
Tokens int `json:"tokens"`
|
|
30
|
+
Summary string `json:"summary,omitempty"`
|
|
31
|
+
SummaryTokens int `json:"summary_tokens,omitempty"`
|
|
32
|
+
Status EpisodeStatus `json:"status"`
|
|
33
|
+
Tags []string `json:"tags,omitempty"`
|
|
34
|
+
ParentEpisodeID string `json:"parent_episode_id,omitempty"`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// EpisodeConfig controls episode boundaries and behavior.
|
|
38
|
+
type EpisodeConfig struct {
|
|
39
|
+
MaxEpisodeTokens int
|
|
40
|
+
MaxEpisodeMessages int
|
|
41
|
+
TopicChangeThreshold float64
|
|
42
|
+
AutoCompactAfterClose bool
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// EpisodeManager manages episode creation, compaction, and retrieval.
|
|
46
|
+
type EpisodeManager struct {
|
|
47
|
+
mu sync.RWMutex
|
|
48
|
+
episodes map[string]*Episode
|
|
49
|
+
episodeSeq []*Episode
|
|
50
|
+
activeEpisode *Episode
|
|
51
|
+
nextID int
|
|
52
|
+
sessionID string
|
|
53
|
+
config EpisodeConfig
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// NewEpisodeManager creates a manager with defaults applied.
|
|
57
|
+
func NewEpisodeManager(sessionID string, config EpisodeConfig) *EpisodeManager {
|
|
58
|
+
zeroConfig := config == (EpisodeConfig{})
|
|
59
|
+
|
|
60
|
+
if config.MaxEpisodeTokens <= 0 {
|
|
61
|
+
config.MaxEpisodeTokens = 2000
|
|
62
|
+
}
|
|
63
|
+
if config.MaxEpisodeMessages <= 0 {
|
|
64
|
+
config.MaxEpisodeMessages = 20
|
|
65
|
+
}
|
|
66
|
+
if config.TopicChangeThreshold <= 0 || config.TopicChangeThreshold > 1 {
|
|
67
|
+
config.TopicChangeThreshold = 0.5
|
|
68
|
+
}
|
|
69
|
+
if zeroConfig {
|
|
70
|
+
config.AutoCompactAfterClose = true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return &EpisodeManager{
|
|
74
|
+
episodes: make(map[string]*Episode),
|
|
75
|
+
episodeSeq: make([]*Episode, 0),
|
|
76
|
+
sessionID: sessionID,
|
|
77
|
+
config: config,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// AddMessage adds a message into the active episode, rotating when needed.
|
|
82
|
+
func (m *EpisodeManager) AddMessage(msg *StoreMessage) {
|
|
83
|
+
if msg == nil {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
m.mu.Lock()
|
|
88
|
+
defer m.mu.Unlock()
|
|
89
|
+
|
|
90
|
+
if m.activeEpisode == nil {
|
|
91
|
+
m.activeEpisode = m.newEpisodeLocked("")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if m.shouldCloseEpisodeLocked() {
|
|
95
|
+
closed := m.closeActiveEpisodeLocked()
|
|
96
|
+
parentID := ""
|
|
97
|
+
if closed != nil {
|
|
98
|
+
parentID = closed.ID
|
|
99
|
+
}
|
|
100
|
+
m.activeEpisode = m.newEpisodeLocked(parentID)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
ep := m.activeEpisode
|
|
104
|
+
ep.MessageIDs = append(ep.MessageIDs, msg.ID)
|
|
105
|
+
ep.Tokens += msg.Tokens
|
|
106
|
+
if ep.StartTime.IsZero() {
|
|
107
|
+
ep.StartTime = msg.Timestamp
|
|
108
|
+
}
|
|
109
|
+
ep.EndTime = msg.Timestamp
|
|
110
|
+
if strings.TrimSpace(ep.Title) == "" {
|
|
111
|
+
ep.Title = buildEpisodeTitle(msg.Content)
|
|
112
|
+
}
|
|
113
|
+
if len(ep.Tags) == 0 {
|
|
114
|
+
ep.Tags = buildEpisodeTags(msg.Content)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// CloseActiveEpisode closes the current active episode.
|
|
119
|
+
func (m *EpisodeManager) CloseActiveEpisode() *Episode {
|
|
120
|
+
m.mu.Lock()
|
|
121
|
+
defer m.mu.Unlock()
|
|
122
|
+
return m.closeActiveEpisodeLocked()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// GetEpisode retrieves an episode by id.
|
|
126
|
+
func (m *EpisodeManager) GetEpisode(id string) (*Episode, bool) {
|
|
127
|
+
m.mu.RLock()
|
|
128
|
+
defer m.mu.RUnlock()
|
|
129
|
+
ep, ok := m.episodes[id]
|
|
130
|
+
return ep, ok
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// GetAllEpisodes returns all episodes in chronological order.
|
|
134
|
+
func (m *EpisodeManager) GetAllEpisodes() []*Episode {
|
|
135
|
+
m.mu.RLock()
|
|
136
|
+
defer m.mu.RUnlock()
|
|
137
|
+
out := make([]*Episode, len(m.episodeSeq))
|
|
138
|
+
copy(out, m.episodeSeq)
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// GetActiveEpisode returns the currently active episode.
|
|
143
|
+
func (m *EpisodeManager) GetActiveEpisode() *Episode {
|
|
144
|
+
m.mu.RLock()
|
|
145
|
+
defer m.mu.RUnlock()
|
|
146
|
+
return m.activeEpisode
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// CompactEpisode compresses an episode into a summary.
|
|
150
|
+
func (m *EpisodeManager) CompactEpisode(episodeID string, summary string) error {
|
|
151
|
+
m.mu.Lock()
|
|
152
|
+
defer m.mu.Unlock()
|
|
153
|
+
|
|
154
|
+
ep, ok := m.episodes[episodeID]
|
|
155
|
+
if !ok {
|
|
156
|
+
return fmt.Errorf("episode not found: %s", episodeID)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
summary = strings.TrimSpace(summary)
|
|
160
|
+
if summary == "" {
|
|
161
|
+
return fmt.Errorf("summary cannot be empty")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
ep.Summary = summary
|
|
165
|
+
ep.SummaryTokens = EstimateTokens(summary)
|
|
166
|
+
ep.Status = EpisodeCompacted
|
|
167
|
+
if strings.TrimSpace(ep.Title) == "" {
|
|
168
|
+
ep.Title = buildEpisodeTitle(summary)
|
|
169
|
+
}
|
|
170
|
+
if len(ep.Tags) == 0 {
|
|
171
|
+
ep.Tags = buildEpisodeTags(summary)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return nil
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// GetEpisodesForContext returns reverse-chronological episodes within budget.
|
|
178
|
+
// Active episode is always included when present.
|
|
179
|
+
func (m *EpisodeManager) GetEpisodesForContext(tokenBudget int) []*Episode {
|
|
180
|
+
m.mu.RLock()
|
|
181
|
+
defer m.mu.RUnlock()
|
|
182
|
+
|
|
183
|
+
if tokenBudget < 0 {
|
|
184
|
+
tokenBudget = 0
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
result := make([]*Episode, 0)
|
|
188
|
+
included := make(map[string]bool)
|
|
189
|
+
remaining := tokenBudget
|
|
190
|
+
|
|
191
|
+
if m.activeEpisode != nil {
|
|
192
|
+
result = append(result, m.activeEpisode)
|
|
193
|
+
included[m.activeEpisode.ID] = true
|
|
194
|
+
remaining -= m.activeEpisode.Tokens
|
|
195
|
+
if remaining <= 0 {
|
|
196
|
+
return result
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for i := len(m.episodeSeq) - 1; i >= 0; i-- {
|
|
201
|
+
ep := m.episodeSeq[i]
|
|
202
|
+
if ep == nil || included[ep.ID] {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
cost := ep.Tokens
|
|
207
|
+
if ep.Status != EpisodeActive {
|
|
208
|
+
if ep.SummaryTokens > 0 {
|
|
209
|
+
cost = ep.SummaryTokens
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if cost > remaining {
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
result = append(result, ep)
|
|
218
|
+
remaining -= cost
|
|
219
|
+
if remaining <= 0 {
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return result
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
func (m *EpisodeManager) shouldCloseEpisodeLocked() bool {
|
|
228
|
+
if m.activeEpisode == nil {
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
if m.config.MaxEpisodeTokens > 0 && m.activeEpisode.Tokens >= m.config.MaxEpisodeTokens {
|
|
232
|
+
return true
|
|
233
|
+
}
|
|
234
|
+
if m.config.MaxEpisodeMessages > 0 && len(m.activeEpisode.MessageIDs) >= m.config.MaxEpisodeMessages {
|
|
235
|
+
return true
|
|
236
|
+
}
|
|
237
|
+
return false
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func (m *EpisodeManager) closeActiveEpisodeLocked() *Episode {
|
|
241
|
+
if m.activeEpisode == nil {
|
|
242
|
+
return nil
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
ep := m.activeEpisode
|
|
246
|
+
if ep.EndTime.IsZero() {
|
|
247
|
+
ep.EndTime = time.Now()
|
|
248
|
+
}
|
|
249
|
+
if ep.Status == EpisodeActive && m.config.AutoCompactAfterClose {
|
|
250
|
+
if strings.TrimSpace(ep.Summary) == "" {
|
|
251
|
+
ep.Summary = fmt.Sprintf("Episode %s (%d messages)", ep.ID, len(ep.MessageIDs))
|
|
252
|
+
}
|
|
253
|
+
ep.SummaryTokens = EstimateTokens(ep.Summary)
|
|
254
|
+
ep.Status = EpisodeCompacted
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
m.activeEpisode = nil
|
|
258
|
+
return ep
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func (m *EpisodeManager) newEpisodeLocked(parentEpisodeID string) *Episode {
|
|
262
|
+
m.nextID++
|
|
263
|
+
ep := &Episode{
|
|
264
|
+
ID: fmt.Sprintf("ep_%s_%d", m.sessionID, m.nextID),
|
|
265
|
+
Title: "",
|
|
266
|
+
MessageIDs: make([]string, 0),
|
|
267
|
+
StartTime: time.Time{},
|
|
268
|
+
EndTime: time.Time{},
|
|
269
|
+
Tokens: 0,
|
|
270
|
+
Summary: "",
|
|
271
|
+
SummaryTokens: 0,
|
|
272
|
+
Status: EpisodeActive,
|
|
273
|
+
Tags: make([]string, 0),
|
|
274
|
+
ParentEpisodeID: parentEpisodeID,
|
|
275
|
+
}
|
|
276
|
+
m.episodes[ep.ID] = ep
|
|
277
|
+
m.episodeSeq = append(m.episodeSeq, ep)
|
|
278
|
+
return ep
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func buildEpisodeTitle(content string) string {
|
|
282
|
+
trimmed := strings.TrimSpace(content)
|
|
283
|
+
if trimmed == "" {
|
|
284
|
+
return "Untitled Episode"
|
|
285
|
+
}
|
|
286
|
+
parts := strings.Fields(trimmed)
|
|
287
|
+
if len(parts) > 8 {
|
|
288
|
+
parts = parts[:8]
|
|
289
|
+
}
|
|
290
|
+
return strings.Join(parts, " ")
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
func buildEpisodeTags(content string) []string {
|
|
294
|
+
trimmed := strings.ToLower(strings.TrimSpace(content))
|
|
295
|
+
if trimmed == "" {
|
|
296
|
+
return nil
|
|
297
|
+
}
|
|
298
|
+
parts := strings.Fields(trimmed)
|
|
299
|
+
if len(parts) > 3 {
|
|
300
|
+
parts = parts[:3]
|
|
301
|
+
}
|
|
302
|
+
out := make([]string, 0, len(parts))
|
|
303
|
+
seen := make(map[string]bool)
|
|
304
|
+
for _, p := range parts {
|
|
305
|
+
p = strings.Trim(p, ",.;:!?()[]{}\"'")
|
|
306
|
+
if p == "" || seen[p] {
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
seen[p] = true
|
|
310
|
+
out = append(out, p)
|
|
311
|
+
}
|
|
312
|
+
return out
|
|
313
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
package rlm
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"reflect"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func episodeTestMsg(id, content string, tokens int, ts time.Time) *StoreMessage {
|
|
11
|
+
return &StoreMessage{
|
|
12
|
+
ID: id,
|
|
13
|
+
Role: RoleUser,
|
|
14
|
+
Content: content,
|
|
15
|
+
Tokens: tokens,
|
|
16
|
+
Timestamp: ts,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func TestEpisodeManager_NewDefaults(t *testing.T) {
|
|
21
|
+
m := NewEpisodeManager("sess-defaults", EpisodeConfig{})
|
|
22
|
+
if m == nil {
|
|
23
|
+
t.Fatal("NewEpisodeManager returned nil")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if m.config.MaxEpisodeTokens != 2000 {
|
|
27
|
+
t.Errorf("MaxEpisodeTokens = %d, want 2000", m.config.MaxEpisodeTokens)
|
|
28
|
+
}
|
|
29
|
+
if m.config.MaxEpisodeMessages != 20 {
|
|
30
|
+
t.Errorf("MaxEpisodeMessages = %d, want 20", m.config.MaxEpisodeMessages)
|
|
31
|
+
}
|
|
32
|
+
if m.config.TopicChangeThreshold != 0.5 {
|
|
33
|
+
t.Errorf("TopicChangeThreshold = %f, want 0.5", m.config.TopicChangeThreshold)
|
|
34
|
+
}
|
|
35
|
+
if !m.config.AutoCompactAfterClose {
|
|
36
|
+
t.Errorf("AutoCompactAfterClose = %v, want true", m.config.AutoCompactAfterClose)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func TestEpisodeManager_AddMessage(t *testing.T) {
|
|
41
|
+
m := NewEpisodeManager("sess-add", EpisodeConfig{
|
|
42
|
+
MaxEpisodeMessages: 100,
|
|
43
|
+
MaxEpisodeTokens: 10000,
|
|
44
|
+
TopicChangeThreshold: 0.5,
|
|
45
|
+
AutoCompactAfterClose: true,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
ts1 := time.Now().Add(-2 * time.Minute)
|
|
49
|
+
ts2 := ts1.Add(1 * time.Minute)
|
|
50
|
+
|
|
51
|
+
m.AddMessage(episodeTestMsg("msg1", "Implement LCM episode manager tests", 7, ts1))
|
|
52
|
+
m.AddMessage(episodeTestMsg("msg2", "Verify episode metadata updates", 5, ts2))
|
|
53
|
+
|
|
54
|
+
ep := m.GetActiveEpisode()
|
|
55
|
+
if ep == nil {
|
|
56
|
+
t.Fatal("GetActiveEpisode returned nil")
|
|
57
|
+
}
|
|
58
|
+
if len(ep.MessageIDs) != 2 {
|
|
59
|
+
t.Fatalf("len(MessageIDs) = %d, want 2", len(ep.MessageIDs))
|
|
60
|
+
}
|
|
61
|
+
if ep.MessageIDs[0] != "msg1" || ep.MessageIDs[1] != "msg2" {
|
|
62
|
+
t.Errorf("MessageIDs = %v, want [msg1 msg2]", ep.MessageIDs)
|
|
63
|
+
}
|
|
64
|
+
if ep.Tokens != 12 {
|
|
65
|
+
t.Errorf("Tokens = %d, want 12", ep.Tokens)
|
|
66
|
+
}
|
|
67
|
+
if !ep.StartTime.Equal(ts1) {
|
|
68
|
+
t.Errorf("StartTime = %v, want %v", ep.StartTime, ts1)
|
|
69
|
+
}
|
|
70
|
+
if !ep.EndTime.Equal(ts2) {
|
|
71
|
+
t.Errorf("EndTime = %v, want %v", ep.EndTime, ts2)
|
|
72
|
+
}
|
|
73
|
+
if strings.TrimSpace(ep.Title) == "" {
|
|
74
|
+
t.Error("Title should be set from first message content")
|
|
75
|
+
}
|
|
76
|
+
if len(ep.Tags) == 0 {
|
|
77
|
+
t.Error("Tags should be set from first message content")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func TestEpisodeManager_AutoRotation(t *testing.T) {
|
|
82
|
+
m := NewEpisodeManager("sess-rotate", EpisodeConfig{
|
|
83
|
+
MaxEpisodeMessages: 3,
|
|
84
|
+
MaxEpisodeTokens: 10000,
|
|
85
|
+
TopicChangeThreshold: 0.5,
|
|
86
|
+
AutoCompactAfterClose: false,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
base := time.Now()
|
|
90
|
+
m.AddMessage(episodeTestMsg("m1", "first", 1, base))
|
|
91
|
+
m.AddMessage(episodeTestMsg("m2", "second", 1, base.Add(time.Second)))
|
|
92
|
+
m.AddMessage(episodeTestMsg("m3", "third", 1, base.Add(2*time.Second)))
|
|
93
|
+
m.AddMessage(episodeTestMsg("m4", "fourth", 1, base.Add(3*time.Second)))
|
|
94
|
+
|
|
95
|
+
episodes := m.GetAllEpisodes()
|
|
96
|
+
if len(episodes) != 2 {
|
|
97
|
+
t.Fatalf("len(GetAllEpisodes()) = %d, want 2", len(episodes))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
first := episodes[0]
|
|
101
|
+
second := episodes[1]
|
|
102
|
+
if len(first.MessageIDs) != 3 {
|
|
103
|
+
t.Errorf("first episode messages = %d, want 3", len(first.MessageIDs))
|
|
104
|
+
}
|
|
105
|
+
if len(second.MessageIDs) != 1 || second.MessageIDs[0] != "m4" {
|
|
106
|
+
t.Errorf("second episode MessageIDs = %v, want [m4]", second.MessageIDs)
|
|
107
|
+
}
|
|
108
|
+
if m.GetActiveEpisode() == nil || m.GetActiveEpisode().ID != second.ID {
|
|
109
|
+
t.Fatalf("active episode should be second episode")
|
|
110
|
+
}
|
|
111
|
+
if second.ParentEpisodeID != first.ID {
|
|
112
|
+
t.Errorf("ParentEpisodeID = %s, want %s", second.ParentEpisodeID, first.ID)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func TestEpisodeManager_CloseActiveEpisode(t *testing.T) {
|
|
117
|
+
m := NewEpisodeManager("sess-close", EpisodeConfig{})
|
|
118
|
+
msg := episodeTestMsg("m1", "close this episode", 4, time.Now())
|
|
119
|
+
m.AddMessage(msg)
|
|
120
|
+
|
|
121
|
+
ep := m.CloseActiveEpisode()
|
|
122
|
+
if ep == nil {
|
|
123
|
+
t.Fatal("CloseActiveEpisode returned nil")
|
|
124
|
+
}
|
|
125
|
+
if ep.Status != EpisodeCompacted {
|
|
126
|
+
t.Errorf("Status = %s, want %s", ep.Status, EpisodeCompacted)
|
|
127
|
+
}
|
|
128
|
+
if strings.TrimSpace(ep.Summary) == "" {
|
|
129
|
+
t.Error("Summary should be auto-generated when auto-compact is enabled")
|
|
130
|
+
}
|
|
131
|
+
if ep.EndTime.IsZero() {
|
|
132
|
+
t.Error("EndTime should be set")
|
|
133
|
+
}
|
|
134
|
+
if m.GetActiveEpisode() != nil {
|
|
135
|
+
t.Error("active episode should be nil after close")
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func TestEpisodeManager_CompactEpisode(t *testing.T) {
|
|
140
|
+
m := NewEpisodeManager("sess-compact", EpisodeConfig{
|
|
141
|
+
MaxEpisodeTokens: 100,
|
|
142
|
+
MaxEpisodeMessages: 10,
|
|
143
|
+
TopicChangeThreshold: 0.5,
|
|
144
|
+
AutoCompactAfterClose: false,
|
|
145
|
+
})
|
|
146
|
+
m.AddMessage(episodeTestMsg("m1", "episode to compact", 3, time.Now()))
|
|
147
|
+
ep := m.CloseActiveEpisode()
|
|
148
|
+
if ep == nil {
|
|
149
|
+
t.Fatal("CloseActiveEpisode returned nil")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
summary := "Concise summary of the episode"
|
|
153
|
+
if err := m.CompactEpisode(ep.ID, summary); err != nil {
|
|
154
|
+
t.Fatalf("CompactEpisode returned error: %v", err)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
updated, ok := m.GetEpisode(ep.ID)
|
|
158
|
+
if !ok {
|
|
159
|
+
t.Fatalf("GetEpisode(%s) returned not found", ep.ID)
|
|
160
|
+
}
|
|
161
|
+
if updated.Summary != summary {
|
|
162
|
+
t.Errorf("Summary = %q, want %q", updated.Summary, summary)
|
|
163
|
+
}
|
|
164
|
+
if updated.SummaryTokens <= 0 {
|
|
165
|
+
t.Errorf("SummaryTokens = %d, want > 0", updated.SummaryTokens)
|
|
166
|
+
}
|
|
167
|
+
if updated.Status != EpisodeCompacted {
|
|
168
|
+
t.Errorf("Status = %s, want %s", updated.Status, EpisodeCompacted)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func TestEpisodeManager_CompactEpisode_NotFound(t *testing.T) {
|
|
173
|
+
m := NewEpisodeManager("sess-compact-missing", EpisodeConfig{})
|
|
174
|
+
err := m.CompactEpisode("ep_missing", "summary")
|
|
175
|
+
if err == nil {
|
|
176
|
+
t.Fatal("CompactEpisode expected error for missing episode")
|
|
177
|
+
}
|
|
178
|
+
if !strings.Contains(err.Error(), "episode not found") {
|
|
179
|
+
t.Errorf("error = %q, want to contain %q", err.Error(), "episode not found")
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func TestEpisodeManager_CompactEpisode_EmptySummary(t *testing.T) {
|
|
184
|
+
m := NewEpisodeManager("sess-compact-empty", EpisodeConfig{
|
|
185
|
+
MaxEpisodeTokens: 100,
|
|
186
|
+
MaxEpisodeMessages: 10,
|
|
187
|
+
TopicChangeThreshold: 0.5,
|
|
188
|
+
AutoCompactAfterClose: false,
|
|
189
|
+
})
|
|
190
|
+
m.AddMessage(episodeTestMsg("m1", "episode to compact", 2, time.Now()))
|
|
191
|
+
ep := m.CloseActiveEpisode()
|
|
192
|
+
if ep == nil {
|
|
193
|
+
t.Fatal("CloseActiveEpisode returned nil")
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
err := m.CompactEpisode(ep.ID, " \n\t")
|
|
197
|
+
if err == nil {
|
|
198
|
+
t.Fatal("CompactEpisode expected error for empty summary")
|
|
199
|
+
}
|
|
200
|
+
if !strings.Contains(err.Error(), "summary cannot be empty") {
|
|
201
|
+
t.Errorf("error = %q, want to contain %q", err.Error(), "summary cannot be empty")
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func TestEpisodeManager_GetEpisodesForContext(t *testing.T) {
|
|
206
|
+
m := NewEpisodeManager("sess-context", EpisodeConfig{
|
|
207
|
+
MaxEpisodeTokens: 10000,
|
|
208
|
+
MaxEpisodeMessages: 1,
|
|
209
|
+
TopicChangeThreshold: 0.5,
|
|
210
|
+
AutoCompactAfterClose: false,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
now := time.Now()
|
|
214
|
+
m.AddMessage(episodeTestMsg("m1", "episode one", 10, now))
|
|
215
|
+
m.AddMessage(episodeTestMsg("m2", "episode two", 8, now.Add(time.Second)))
|
|
216
|
+
m.AddMessage(episodeTestMsg("m3", "episode three", 6, now.Add(2*time.Second)))
|
|
217
|
+
|
|
218
|
+
episodes := m.GetAllEpisodes()
|
|
219
|
+
if len(episodes) != 3 {
|
|
220
|
+
t.Fatalf("len(GetAllEpisodes()) = %d, want 3", len(episodes))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Force deterministic costs for non-active episodes.
|
|
224
|
+
episodes[0].Status = EpisodeCompacted
|
|
225
|
+
episodes[0].SummaryTokens = 3
|
|
226
|
+
episodes[1].Status = EpisodeCompacted
|
|
227
|
+
episodes[1].SummaryTokens = 4
|
|
228
|
+
|
|
229
|
+
selected := m.GetEpisodesForContext(10)
|
|
230
|
+
if len(selected) != 2 {
|
|
231
|
+
t.Fatalf("len(GetEpisodesForContext(10)) = %d, want 2", len(selected))
|
|
232
|
+
}
|
|
233
|
+
if selected[0].ID != episodes[2].ID {
|
|
234
|
+
t.Errorf("selected[0] = %s, want active %s", selected[0].ID, episodes[2].ID)
|
|
235
|
+
}
|
|
236
|
+
if selected[1].ID != episodes[1].ID {
|
|
237
|
+
t.Errorf("selected[1] = %s, want %s", selected[1].ID, episodes[1].ID)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Active episode should still be included even if it exceeds budget.
|
|
241
|
+
smallBudget := m.GetEpisodesForContext(5)
|
|
242
|
+
if len(smallBudget) != 1 {
|
|
243
|
+
t.Fatalf("len(GetEpisodesForContext(5)) = %d, want 1", len(smallBudget))
|
|
244
|
+
}
|
|
245
|
+
if smallBudget[0].ID != episodes[2].ID {
|
|
246
|
+
t.Errorf("smallBudget[0] = %s, want active %s", smallBudget[0].ID, episodes[2].ID)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
func TestEpisodeManager_GetAllEpisodes(t *testing.T) {
|
|
251
|
+
m := NewEpisodeManager("sess-all", EpisodeConfig{
|
|
252
|
+
MaxEpisodeTokens: 10000,
|
|
253
|
+
MaxEpisodeMessages: 1,
|
|
254
|
+
TopicChangeThreshold: 0.5,
|
|
255
|
+
AutoCompactAfterClose: false,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
now := time.Now()
|
|
259
|
+
m.AddMessage(episodeTestMsg("m1", "first", 1, now))
|
|
260
|
+
m.AddMessage(episodeTestMsg("m2", "second", 1, now.Add(time.Second)))
|
|
261
|
+
m.AddMessage(episodeTestMsg("m3", "third", 1, now.Add(2*time.Second)))
|
|
262
|
+
|
|
263
|
+
episodes := m.GetAllEpisodes()
|
|
264
|
+
if len(episodes) != 3 {
|
|
265
|
+
t.Fatalf("len(GetAllEpisodes()) = %d, want 3", len(episodes))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for i, ep := range episodes {
|
|
269
|
+
if len(ep.MessageIDs) != 1 {
|
|
270
|
+
t.Fatalf("episode %d should have 1 message, got %d", i, len(ep.MessageIDs))
|
|
271
|
+
}
|
|
272
|
+
wantMsgID := "m" + string(rune('1'+i))
|
|
273
|
+
if ep.MessageIDs[0] != wantMsgID {
|
|
274
|
+
t.Errorf("episode %d message ID = %s, want %s", i, ep.MessageIDs[0], wantMsgID)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func TestEpisodeManager_ParentChaining(t *testing.T) {
|
|
280
|
+
m := NewEpisodeManager("sess-parent", EpisodeConfig{
|
|
281
|
+
MaxEpisodeTokens: 10000,
|
|
282
|
+
MaxEpisodeMessages: 1,
|
|
283
|
+
TopicChangeThreshold: 0.5,
|
|
284
|
+
AutoCompactAfterClose: false,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
now := time.Now()
|
|
288
|
+
m.AddMessage(episodeTestMsg("m1", "parent one", 1, now))
|
|
289
|
+
m.AddMessage(episodeTestMsg("m2", "parent two", 1, now.Add(time.Second)))
|
|
290
|
+
m.AddMessage(episodeTestMsg("m3", "parent three", 1, now.Add(2*time.Second)))
|
|
291
|
+
|
|
292
|
+
episodes := m.GetAllEpisodes()
|
|
293
|
+
if len(episodes) != 3 {
|
|
294
|
+
t.Fatalf("len(GetAllEpisodes()) = %d, want 3", len(episodes))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if episodes[0].ParentEpisodeID != "" {
|
|
298
|
+
t.Errorf("episodes[0].ParentEpisodeID = %q, want empty", episodes[0].ParentEpisodeID)
|
|
299
|
+
}
|
|
300
|
+
if episodes[1].ParentEpisodeID != episodes[0].ID {
|
|
301
|
+
t.Errorf("episodes[1].ParentEpisodeID = %q, want %q", episodes[1].ParentEpisodeID, episodes[0].ID)
|
|
302
|
+
}
|
|
303
|
+
if episodes[2].ParentEpisodeID != episodes[1].ID {
|
|
304
|
+
t.Errorf("episodes[2].ParentEpisodeID = %q, want %q", episodes[2].ParentEpisodeID, episodes[1].ID)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func TestEpisodeManager_NilMessage(t *testing.T) {
|
|
309
|
+
m := NewEpisodeManager("sess-nil", EpisodeConfig{})
|
|
310
|
+
m.AddMessage(nil)
|
|
311
|
+
|
|
312
|
+
if m.GetActiveEpisode() != nil {
|
|
313
|
+
t.Error("active episode should remain nil after AddMessage(nil)")
|
|
314
|
+
}
|
|
315
|
+
if len(m.GetAllEpisodes()) != 0 {
|
|
316
|
+
t.Errorf("len(GetAllEpisodes()) = %d, want 0", len(m.GetAllEpisodes()))
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
func TestBuildEpisodeTitle(t *testing.T) {
|
|
321
|
+
tests := []struct {
|
|
322
|
+
name string
|
|
323
|
+
content string
|
|
324
|
+
want string
|
|
325
|
+
}{
|
|
326
|
+
{
|
|
327
|
+
name: "empty content",
|
|
328
|
+
content: " ",
|
|
329
|
+
want: "Untitled Episode",
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: "short title",
|
|
333
|
+
content: "Investigate LCM regression",
|
|
334
|
+
want: "Investigate LCM regression",
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: "long title truncates to eight words",
|
|
338
|
+
content: "one two three four five six seven eight nine ten",
|
|
339
|
+
want: "one two three four five six seven eight",
|
|
340
|
+
},
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for _, tt := range tests {
|
|
344
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
345
|
+
got := buildEpisodeTitle(tt.content)
|
|
346
|
+
if got != tt.want {
|
|
347
|
+
t.Errorf("buildEpisodeTitle(%q) = %q, want %q", tt.content, got, tt.want)
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
func TestBuildEpisodeTags(t *testing.T) {
|
|
354
|
+
tests := []struct {
|
|
355
|
+
name string
|
|
356
|
+
content string
|
|
357
|
+
want []string
|
|
358
|
+
}{
|
|
359
|
+
{
|
|
360
|
+
name: "empty",
|
|
361
|
+
content: "",
|
|
362
|
+
want: nil,
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: "lowercase and punctuation stripping",
|
|
366
|
+
content: "Go, go! TEST test cases extra",
|
|
367
|
+
want: []string{"go", "test"},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: "max three input words before cleanup",
|
|
371
|
+
content: "alpha beta gamma delta epsilon",
|
|
372
|
+
want: []string{"alpha", "beta", "gamma"},
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for _, tt := range tests {
|
|
377
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
378
|
+
got := buildEpisodeTags(tt.content)
|
|
379
|
+
if !reflect.DeepEqual(got, tt.want) {
|
|
380
|
+
t.Errorf("buildEpisodeTags(%q) = %v, want %v", tt.content, got, tt.want)
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
}
|