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,257 @@
|
|
|
1
|
+
package rlm
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// ─── Infinite Delegation Guard ──────────────────────────────────────────────
|
|
9
|
+
// Implements the scope-reduction invariant from the LCM paper (Section 3.2).
|
|
10
|
+
//
|
|
11
|
+
// When a sub-agent spawns a further sub-agent, it must declare:
|
|
12
|
+
// - delegated_scope: the specific slice of work being handed off
|
|
13
|
+
// - kept_work: the work the caller will still perform itself
|
|
14
|
+
//
|
|
15
|
+
// If the caller cannot articulate what it's retaining (i.e., it would delegate
|
|
16
|
+
// its entire responsibility), the call is rejected. This forces each level of
|
|
17
|
+
// delegation to represent a strict reduction in responsibility.
|
|
18
|
+
//
|
|
19
|
+
// Exemptions:
|
|
20
|
+
// - Root agent (depth 0): no parent to recurse with
|
|
21
|
+
// - Read-only agents: cannot spawn further sub-agents
|
|
22
|
+
// - Parallel decomposition (sibling tasks): not nested delegation
|
|
23
|
+
|
|
24
|
+
// DelegationRequest represents a request to delegate work to a sub-agent.
|
|
25
|
+
type DelegationRequest struct {
|
|
26
|
+
// Prompt is the task description for the sub-agent.
|
|
27
|
+
Prompt string `json:"prompt"`
|
|
28
|
+
|
|
29
|
+
// DelegatedScope describes the specific slice of work being handed off.
|
|
30
|
+
// Required for non-root agents.
|
|
31
|
+
DelegatedScope string `json:"delegated_scope"`
|
|
32
|
+
|
|
33
|
+
// KeptWork describes the work the caller retains for itself.
|
|
34
|
+
// Required for non-root agents. Must be non-empty and distinct from DelegatedScope.
|
|
35
|
+
KeptWork string `json:"kept_work"`
|
|
36
|
+
|
|
37
|
+
// ReadOnly indicates this is a read-only exploration agent (exempt from guard).
|
|
38
|
+
ReadOnly bool `json:"read_only"`
|
|
39
|
+
|
|
40
|
+
// Parallel indicates this is parallel decomposition (exempt from guard).
|
|
41
|
+
Parallel bool `json:"parallel"`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// DelegationGuard enforces the scope-reduction invariant.
|
|
45
|
+
type DelegationGuard struct {
|
|
46
|
+
observer *Observer
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// NewDelegationGuard creates a new delegation guard.
|
|
50
|
+
func NewDelegationGuard(observer *Observer) *DelegationGuard {
|
|
51
|
+
return &DelegationGuard{observer: observer}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// DelegationError is returned when a delegation request violates the scope-reduction invariant.
|
|
55
|
+
type DelegationError struct {
|
|
56
|
+
Reason string `json:"reason"`
|
|
57
|
+
Suggestion string `json:"suggestion"`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func (e *DelegationError) Error() string {
|
|
61
|
+
return fmt.Sprintf("delegation rejected: %s. %s", e.Reason, e.Suggestion)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ValidateDelegation checks if a delegation request is allowed at the given depth.
|
|
65
|
+
// Returns nil if allowed, or a DelegationError explaining why it was rejected.
|
|
66
|
+
func (g *DelegationGuard) ValidateDelegation(depth int, req DelegationRequest) error {
|
|
67
|
+
// Root agent (depth 0) is always allowed to delegate
|
|
68
|
+
if depth == 0 {
|
|
69
|
+
g.observer.Debug("lcm.delegation", "Root agent delegation allowed (depth 0)")
|
|
70
|
+
return nil
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Read-only agents are exempt (they can't spawn further sub-agents)
|
|
74
|
+
if req.ReadOnly {
|
|
75
|
+
g.observer.Debug("lcm.delegation", "Read-only agent delegation allowed")
|
|
76
|
+
return nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Parallel decomposition is exempt (sibling, not nested)
|
|
80
|
+
if req.Parallel {
|
|
81
|
+
g.observer.Debug("lcm.delegation", "Parallel decomposition delegation allowed")
|
|
82
|
+
return nil
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Non-root agents must declare scope reduction
|
|
86
|
+
if strings.TrimSpace(req.DelegatedScope) == "" {
|
|
87
|
+
g.observer.Debug("lcm.delegation", "Delegation rejected: no delegated_scope at depth %d", depth)
|
|
88
|
+
return &DelegationError{
|
|
89
|
+
Reason: "sub-agent must declare delegated_scope",
|
|
90
|
+
Suggestion: "Describe the specific slice of work being handed off, or perform the work directly.",
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if strings.TrimSpace(req.KeptWork) == "" {
|
|
95
|
+
g.observer.Debug("lcm.delegation", "Delegation rejected: no kept_work at depth %d", depth)
|
|
96
|
+
return &DelegationError{
|
|
97
|
+
Reason: "sub-agent must declare kept_work (what the caller retains)",
|
|
98
|
+
Suggestion: "If you cannot articulate what you're retaining, perform the work directly instead of delegating.",
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check for full delegation (delegated_scope ≈ entire task)
|
|
103
|
+
if isTotalDelegation(req.DelegatedScope, req.KeptWork) {
|
|
104
|
+
g.observer.Debug("lcm.delegation", "Delegation rejected: total delegation detected at depth %d", depth)
|
|
105
|
+
return &DelegationError{
|
|
106
|
+
Reason: "delegated_scope appears to encompass the entire task; kept_work is trivial",
|
|
107
|
+
Suggestion: "Break the task into meaningful subtasks where you retain substantial work, or perform it directly.",
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
g.observer.Debug("lcm.delegation", "Delegation allowed at depth %d: scope=%q, kept=%q",
|
|
112
|
+
depth, truncateStr(req.DelegatedScope, 80), truncateStr(req.KeptWork, 80))
|
|
113
|
+
return nil
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// isTotalDelegation detects when an agent is trying to delegate its entire responsibility.
|
|
117
|
+
// This is a heuristic check — it catches obvious cases of trivial kept_work.
|
|
118
|
+
func isTotalDelegation(delegatedScope, keptWork string) bool {
|
|
119
|
+
kept := strings.TrimSpace(strings.ToLower(keptWork))
|
|
120
|
+
|
|
121
|
+
// Trivial kept_work patterns that indicate full delegation
|
|
122
|
+
trivialPatterns := []string{
|
|
123
|
+
"none",
|
|
124
|
+
"nothing",
|
|
125
|
+
"n/a",
|
|
126
|
+
"na",
|
|
127
|
+
"",
|
|
128
|
+
"will wait",
|
|
129
|
+
"waiting",
|
|
130
|
+
"just wait",
|
|
131
|
+
"aggregate",
|
|
132
|
+
"collect results",
|
|
133
|
+
"return results",
|
|
134
|
+
"pass through",
|
|
135
|
+
"forward",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for _, pattern := range trivialPatterns {
|
|
139
|
+
if kept == pattern {
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Check if kept_work is suspiciously short compared to delegated_scope
|
|
145
|
+
// (less than 10% of the delegated scope's length and under 20 chars)
|
|
146
|
+
if len(kept) < 20 && len(kept) < len(delegatedScope)/10 {
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Integration with RLM Engine ────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
// DelegateTask validates and executes a delegation request through the RLM engine.
|
|
156
|
+
// This is the main entry point for task delegation with the infinite recursion guard.
|
|
157
|
+
func (r *RLM) DelegateTask(req DelegationRequest) (string, RLMStats, error) {
|
|
158
|
+
// Create or use existing delegation guard
|
|
159
|
+
guard := NewDelegationGuard(r.observer)
|
|
160
|
+
|
|
161
|
+
// Validate the delegation
|
|
162
|
+
if err := guard.ValidateDelegation(r.currentDepth, req); err != nil {
|
|
163
|
+
return "", RLMStats{}, err
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Create sub-agent
|
|
167
|
+
subConfig := Config{
|
|
168
|
+
RecursiveModel: r.recursiveModel,
|
|
169
|
+
APIBase: r.apiBase,
|
|
170
|
+
APIKey: r.apiKey,
|
|
171
|
+
MaxDepth: r.maxDepth,
|
|
172
|
+
MaxIterations: r.maxIterations,
|
|
173
|
+
TimeoutSeconds: r.timeoutSeconds,
|
|
174
|
+
UseMetacognitive: r.useMetacognitive,
|
|
175
|
+
ExtraParams: r.extraParams,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
subRLM := New(r.recursiveModel, subConfig)
|
|
179
|
+
subRLM.currentDepth = r.currentDepth + 1
|
|
180
|
+
subRLM.observer = r.observer
|
|
181
|
+
defer subRLM.Shutdown()
|
|
182
|
+
|
|
183
|
+
r.observer.Debug("lcm.delegation", "Spawning sub-agent at depth %d for: %s",
|
|
184
|
+
r.currentDepth+1, truncateStr(req.Prompt, 100))
|
|
185
|
+
|
|
186
|
+
result, stats, err := subRLM.Completion(req.Prompt, "")
|
|
187
|
+
return result, stats, err
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// DelegateTasks validates and executes multiple parallel delegation requests.
|
|
191
|
+
// This implements the Tasks() tool from the LCM paper (Appendix C.3).
|
|
192
|
+
// Parallel decomposition is exempt from the recursion guard.
|
|
193
|
+
func (r *RLM) DelegateTasks(tasks []DelegationRequest) ([]string, []RLMStats, error) {
|
|
194
|
+
if len(tasks) < 2 {
|
|
195
|
+
return nil, nil, fmt.Errorf("DelegateTasks requires at least 2 tasks for parallel decomposition")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
guard := NewDelegationGuard(r.observer)
|
|
199
|
+
|
|
200
|
+
// Mark all as parallel (exempt from guard) but still validate basic structure
|
|
201
|
+
for i := range tasks {
|
|
202
|
+
tasks[i].Parallel = true
|
|
203
|
+
if err := guard.ValidateDelegation(r.currentDepth, tasks[i]); err != nil {
|
|
204
|
+
return nil, nil, fmt.Errorf("task %d validation failed: %w", i, err)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
r.observer.Debug("lcm.delegation", "Spawning %d parallel sub-agents at depth %d",
|
|
209
|
+
len(tasks), r.currentDepth+1)
|
|
210
|
+
|
|
211
|
+
type taskResult struct {
|
|
212
|
+
index int
|
|
213
|
+
result string
|
|
214
|
+
stats RLMStats
|
|
215
|
+
err error
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
results := make(chan taskResult, len(tasks))
|
|
219
|
+
|
|
220
|
+
for i, task := range tasks {
|
|
221
|
+
go func(idx int, t DelegationRequest) {
|
|
222
|
+
subConfig := Config{
|
|
223
|
+
RecursiveModel: r.recursiveModel,
|
|
224
|
+
APIBase: r.apiBase,
|
|
225
|
+
APIKey: r.apiKey,
|
|
226
|
+
MaxDepth: r.maxDepth,
|
|
227
|
+
MaxIterations: r.maxIterations,
|
|
228
|
+
TimeoutSeconds: r.timeoutSeconds,
|
|
229
|
+
UseMetacognitive: r.useMetacognitive,
|
|
230
|
+
ExtraParams: r.extraParams,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
subRLM := New(r.recursiveModel, subConfig)
|
|
234
|
+
subRLM.currentDepth = r.currentDepth + 1
|
|
235
|
+
subRLM.observer = r.observer
|
|
236
|
+
defer subRLM.Shutdown()
|
|
237
|
+
|
|
238
|
+
result, stats, err := subRLM.Completion(t.Prompt, "")
|
|
239
|
+
results <- taskResult{index: idx, result: result, stats: stats, err: err}
|
|
240
|
+
}(i, task)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Collect results in order
|
|
244
|
+
resultSlice := make([]string, len(tasks))
|
|
245
|
+
statsSlice := make([]RLMStats, len(tasks))
|
|
246
|
+
|
|
247
|
+
for range tasks {
|
|
248
|
+
tr := <-results
|
|
249
|
+
if tr.err != nil {
|
|
250
|
+
return nil, nil, fmt.Errorf("parallel task %d failed: %w", tr.index, tr.err)
|
|
251
|
+
}
|
|
252
|
+
resultSlice[tr.index] = tr.result
|
|
253
|
+
statsSlice[tr.index] = tr.stats
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return resultSlice, statsSlice, nil
|
|
257
|
+
}
|
|
@@ -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
|
+
}
|