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.
@@ -0,0 +1,216 @@
1
+ package rlm
2
+
3
+ import (
4
+ "strings"
5
+ "sync"
6
+ "sync/atomic"
7
+
8
+ "github.com/cespare/xxhash/v2"
9
+ tiktoken "github.com/pkoukk/tiktoken-go"
10
+ )
11
+
12
+ // ─── Tokenizer Interface ─────────────────────────────────────────────────────
13
+ // Provides accurate token counting with model-specific BPE encoding.
14
+ // Replaces the heuristic ~3.5 chars/token estimation with real tokenization.
15
+
16
+ // Tokenizer counts tokens in text strings.
17
+ type Tokenizer interface {
18
+ CountTokens(text string) int
19
+ }
20
+
21
+ // ─── Tiktoken BPE Tokenizer ──────────────────────────────────────────────────
22
+
23
+ // TiktokenTokenizer uses the tiktoken BPE encoding for accurate token counting.
24
+ type TiktokenTokenizer struct {
25
+ encoding *tiktoken.Tiktoken
26
+ name string
27
+ }
28
+
29
+ // modelEncodingMap maps model name prefixes to their tiktoken encoding names.
30
+ var modelEncodingMap = map[string]string{
31
+ // OpenAI o200k_base models
32
+ "gpt-4o": "o200k_base",
33
+ "gpt-4o-": "o200k_base",
34
+ "o1": "o200k_base",
35
+ "o3": "o200k_base",
36
+ "o4": "o200k_base",
37
+ // OpenAI cl100k_base models
38
+ "gpt-4-": "cl100k_base",
39
+ "gpt-4": "cl100k_base",
40
+ "gpt-3.5": "cl100k_base",
41
+ // Anthropic (closest approximation)
42
+ "claude": "cl100k_base",
43
+ // Meta Llama
44
+ "llama": "cl100k_base",
45
+ // Mistral
46
+ "mistral": "cl100k_base",
47
+ "mixtral": "cl100k_base",
48
+ // Qwen
49
+ "qwen": "cl100k_base",
50
+ }
51
+
52
+ // encodingForModel returns the tiktoken encoding name for a given model.
53
+ func encodingForModel(model string) string {
54
+ lower := strings.ToLower(model)
55
+
56
+ // Try exact match first
57
+ if enc, ok := modelEncodingMap[lower]; ok {
58
+ return enc
59
+ }
60
+
61
+ // Try prefix matching (longest prefix wins)
62
+ bestMatch := ""
63
+ bestEnc := "cl100k_base"
64
+ for prefix, enc := range modelEncodingMap {
65
+ if strings.HasPrefix(lower, prefix) && len(prefix) > len(bestMatch) {
66
+ bestMatch = prefix
67
+ bestEnc = enc
68
+ }
69
+ }
70
+
71
+ return bestEnc
72
+ }
73
+
74
+ // NewTiktokenTokenizer creates a tokenizer using the appropriate BPE encoding for the model.
75
+ // Returns nil and an error if the encoding cannot be loaded.
76
+ func NewTiktokenTokenizer(model string) (*TiktokenTokenizer, error) {
77
+ encName := encodingForModel(model)
78
+ enc, err := tiktoken.GetEncoding(encName)
79
+ if err != nil {
80
+ return nil, err
81
+ }
82
+ return &TiktokenTokenizer{
83
+ encoding: enc,
84
+ name: encName,
85
+ }, nil
86
+ }
87
+
88
+ // CountTokens returns the exact BPE token count for the text.
89
+ func (t *TiktokenTokenizer) CountTokens(text string) int {
90
+ if len(text) == 0 {
91
+ return 0
92
+ }
93
+ tokens := t.encoding.Encode(text, nil, nil)
94
+ return len(tokens)
95
+ }
96
+
97
+ // EncodingName returns the name of the encoding used.
98
+ func (t *TiktokenTokenizer) EncodingName() string {
99
+ return t.name
100
+ }
101
+
102
+ // ─── Heuristic Tokenizer (Fallback) ──────────────────────────────────────────
103
+
104
+ // HeuristicTokenizer uses the character-to-token ratio heuristic.
105
+ // This is the original EstimateTokens logic, kept as a fallback
106
+ // when tiktoken encodings are unavailable.
107
+ type HeuristicTokenizer struct{}
108
+
109
+ // CountTokens provides a fast approximation of token count.
110
+ // Uses ~3.5 chars/token ratio, intentionally conservative (over-estimates).
111
+ func (h *HeuristicTokenizer) CountTokens(text string) int {
112
+ if len(text) == 0 {
113
+ return 0
114
+ }
115
+ return (len(text)*10 + 34) / 35
116
+ }
117
+
118
+ // ─── Cached Tokenizer ────────────────────────────────────────────────────────
119
+
120
+ const maxCacheSize = 10000
121
+
122
+ // CachedTokenizer wraps another Tokenizer with an LRU-style cache.
123
+ // Uses xxhash for fast key hashing and sync.Map for concurrent access.
124
+ type CachedTokenizer struct {
125
+ inner Tokenizer
126
+ cache sync.Map // map[uint64]int
127
+ size atomic.Int64
128
+ }
129
+
130
+ // NewCachedTokenizer wraps a tokenizer with caching.
131
+ func NewCachedTokenizer(inner Tokenizer) *CachedTokenizer {
132
+ return &CachedTokenizer{
133
+ inner: inner,
134
+ }
135
+ }
136
+
137
+ // CountTokens returns the cached token count, computing and caching on miss.
138
+ func (c *CachedTokenizer) CountTokens(text string) int {
139
+ if len(text) == 0 {
140
+ return 0
141
+ }
142
+
143
+ // Hash the text for cache key
144
+ key := xxhash.Sum64String(text)
145
+
146
+ // Check cache
147
+ if val, ok := c.cache.Load(key); ok {
148
+ return val.(int)
149
+ }
150
+
151
+ // Compute
152
+ count := c.inner.CountTokens(text)
153
+
154
+ // Store if under max size; evict all if over (simple strategy)
155
+ if c.size.Load() < maxCacheSize {
156
+ if _, loaded := c.cache.LoadOrStore(key, count); !loaded {
157
+ c.size.Add(1)
158
+ }
159
+ } else {
160
+ // Simple eviction: clear the cache when full
161
+ c.cache.Clear()
162
+ c.size.Store(0)
163
+ c.cache.Store(key, count)
164
+ c.size.Add(1)
165
+ }
166
+
167
+ return count
168
+ }
169
+
170
+ // CacheSize returns the current number of cached entries.
171
+ func (c *CachedTokenizer) CacheSize() int64 {
172
+ return c.size.Load()
173
+ }
174
+
175
+ // Inner returns the underlying tokenizer.
176
+ func (c *CachedTokenizer) Inner() Tokenizer {
177
+ return c.inner
178
+ }
179
+
180
+ // ─── Global Default Tokenizer ────────────────────────────────────────────────
181
+
182
+ var (
183
+ defaultTokenizer Tokenizer = &HeuristicTokenizer{}
184
+ tokenizerMu sync.RWMutex
185
+ )
186
+
187
+ // SetDefaultTokenizer configures the global tokenizer for a given model.
188
+ // Tries tiktoken BPE first, falls back to heuristic on failure.
189
+ // The tokenizer is wrapped with caching for performance.
190
+ func SetDefaultTokenizer(model string) {
191
+ tokenizerMu.Lock()
192
+ defer tokenizerMu.Unlock()
193
+
194
+ tok, err := NewTiktokenTokenizer(model)
195
+ if err != nil {
196
+ // Fall back to heuristic with caching
197
+ defaultTokenizer = NewCachedTokenizer(&HeuristicTokenizer{})
198
+ return
199
+ }
200
+
201
+ defaultTokenizer = NewCachedTokenizer(tok)
202
+ }
203
+
204
+ // GetTokenizer returns the current global tokenizer.
205
+ func GetTokenizer() Tokenizer {
206
+ tokenizerMu.RLock()
207
+ defer tokenizerMu.RUnlock()
208
+ return defaultTokenizer
209
+ }
210
+
211
+ // ResetDefaultTokenizer resets to the heuristic tokenizer (used in tests).
212
+ func ResetDefaultTokenizer() {
213
+ tokenizerMu.Lock()
214
+ defer tokenizerMu.Unlock()
215
+ defaultTokenizer = &HeuristicTokenizer{}
216
+ }
@@ -0,0 +1,305 @@
1
+ package rlm
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ // ─── Tiktoken BPE Tokenizer Tests ────────────────────────────────────────────
9
+
10
+ func TestTiktokenTokenizer_English(t *testing.T) {
11
+ tok, err := NewTiktokenTokenizer("gpt-4o")
12
+ if err != nil {
13
+ t.Fatalf("failed to create tokenizer: %v", err)
14
+ }
15
+
16
+ text := "Hello, world! This is a test of the tokenizer."
17
+ bpeCount := tok.CountTokens(text)
18
+ heuristic := (&HeuristicTokenizer{}).CountTokens(text)
19
+
20
+ t.Logf("English text: BPE=%d, Heuristic=%d", bpeCount, heuristic)
21
+
22
+ if bpeCount <= 0 {
23
+ t.Error("BPE count should be > 0")
24
+ }
25
+ // BPE and heuristic should give different counts for most text
26
+ // (they may coincidentally match for very short strings, so just verify BPE is reasonable)
27
+ if bpeCount > len(text) {
28
+ t.Errorf("BPE count %d should not exceed character count %d", bpeCount, len(text))
29
+ }
30
+ }
31
+
32
+ func TestTiktokenTokenizer_Code(t *testing.T) {
33
+ tok, err := NewTiktokenTokenizer("gpt-4o")
34
+ if err != nil {
35
+ t.Fatalf("failed to create tokenizer: %v", err)
36
+ }
37
+
38
+ code := `func main() {
39
+ fmt.Println("Hello, World!")
40
+ for i := 0; i < 100; i++ {
41
+ result = append(result, processItem(items[i]))
42
+ }
43
+ }`
44
+ bpeCount := tok.CountTokens(code)
45
+ heuristic := (&HeuristicTokenizer{}).CountTokens(code)
46
+
47
+ t.Logf("Code: BPE=%d, Heuristic=%d (chars=%d)", bpeCount, heuristic, len(code))
48
+
49
+ if bpeCount <= 0 {
50
+ t.Error("BPE count should be > 0 for code")
51
+ }
52
+ }
53
+
54
+ func TestTiktokenTokenizer_JSON(t *testing.T) {
55
+ tok, err := NewTiktokenTokenizer("gpt-4o")
56
+ if err != nil {
57
+ t.Fatalf("failed to create tokenizer: %v", err)
58
+ }
59
+
60
+ jsonData := `{"name": "test", "values": [1, 2, 3, 4, 5], "nested": {"key": "value", "count": 42}}`
61
+ bpeCount := tok.CountTokens(jsonData)
62
+ heuristic := (&HeuristicTokenizer{}).CountTokens(jsonData)
63
+
64
+ t.Logf("JSON: BPE=%d, Heuristic=%d (chars=%d)", bpeCount, heuristic, len(jsonData))
65
+
66
+ if bpeCount <= 0 {
67
+ t.Error("BPE count should be > 0 for JSON")
68
+ }
69
+ }
70
+
71
+ func TestTiktokenTokenizer_CJK(t *testing.T) {
72
+ tok, err := NewTiktokenTokenizer("gpt-4o")
73
+ if err != nil {
74
+ t.Fatalf("failed to create tokenizer: %v", err)
75
+ }
76
+
77
+ // Chinese, Japanese, and Korean text
78
+ cjkText := "这是一个测试。日本語のテスト。한국어 테스트입니다。"
79
+ bpeCount := tok.CountTokens(cjkText)
80
+ heuristic := (&HeuristicTokenizer{}).CountTokens(cjkText)
81
+
82
+ t.Logf("CJK text: BPE=%d, Heuristic=%d (chars=%d, bytes=%d)", bpeCount, heuristic, len([]rune(cjkText)), len(cjkText))
83
+
84
+ if bpeCount <= 0 {
85
+ t.Error("BPE count should be > 0 for CJK")
86
+ }
87
+ // CJK text has ~1.5 chars per token but heuristic assumes ~3.5
88
+ // So BPE should count MORE tokens than heuristic for CJK
89
+ if bpeCount <= heuristic {
90
+ t.Logf("WARNING: BPE (%d) should typically be > heuristic (%d) for CJK text", bpeCount, heuristic)
91
+ }
92
+ }
93
+
94
+ func TestTiktokenTokenizer_Empty(t *testing.T) {
95
+ tok, err := NewTiktokenTokenizer("gpt-4o")
96
+ if err != nil {
97
+ t.Fatalf("failed to create tokenizer: %v", err)
98
+ }
99
+
100
+ if tok.CountTokens("") != 0 {
101
+ t.Error("empty string should return 0 tokens")
102
+ }
103
+ }
104
+
105
+ func TestTiktokenTokenizer_EncodingSelection(t *testing.T) {
106
+ tests := []struct {
107
+ model string
108
+ expected string
109
+ }{
110
+ {"gpt-4o", "o200k_base"},
111
+ {"gpt-4o-mini", "o200k_base"},
112
+ {"gpt-4o-mini-2024-07-18", "o200k_base"},
113
+ {"gpt-4", "cl100k_base"},
114
+ {"gpt-4-turbo", "cl100k_base"},
115
+ {"gpt-3.5-turbo", "cl100k_base"},
116
+ {"claude-3-opus", "cl100k_base"},
117
+ {"claude-sonnet-4", "cl100k_base"},
118
+ {"o1", "o200k_base"},
119
+ {"o3-mini", "o200k_base"},
120
+ {"llama-3.1", "cl100k_base"},
121
+ {"unknown-model", "cl100k_base"},
122
+ }
123
+
124
+ for _, tt := range tests {
125
+ t.Run(tt.model, func(t *testing.T) {
126
+ enc := encodingForModel(tt.model)
127
+ if enc != tt.expected {
128
+ t.Errorf("encodingForModel(%q) = %q, want %q", tt.model, enc, tt.expected)
129
+ }
130
+ })
131
+ }
132
+ }
133
+
134
+ // ─── Heuristic Tokenizer Tests ───────────────────────────────────────────────
135
+
136
+ func TestHeuristicTokenizer_Fallback(t *testing.T) {
137
+ h := &HeuristicTokenizer{}
138
+
139
+ if h.CountTokens("") != 0 {
140
+ t.Error("empty string should be 0")
141
+ }
142
+
143
+ // "Hello" = 5 chars, ceil(5/3.5) = 2
144
+ count := h.CountTokens("Hello")
145
+ if count <= 0 {
146
+ t.Error("should count > 0 tokens for 'Hello'")
147
+ }
148
+
149
+ // Longer text
150
+ longText := strings.Repeat("word ", 1000)
151
+ longCount := h.CountTokens(longText)
152
+ expected := (len(longText)*10 + 34) / 35
153
+ if longCount != expected {
154
+ t.Errorf("heuristic count %d != expected %d", longCount, expected)
155
+ }
156
+ }
157
+
158
+ // ─── Cached Tokenizer Tests ─────────────────────────────────────────────────
159
+
160
+ func TestCachedTokenizer_CacheHit(t *testing.T) {
161
+ callCount := 0
162
+ inner := &countingTokenizer{fn: func(text string) int {
163
+ callCount++
164
+ return len(text) / 4
165
+ }}
166
+
167
+ cached := NewCachedTokenizer(inner)
168
+
169
+ text := "This is a test string for caching"
170
+
171
+ // First call: cache miss
172
+ count1 := cached.CountTokens(text)
173
+ if callCount != 1 {
174
+ t.Errorf("expected 1 inner call, got %d", callCount)
175
+ }
176
+
177
+ // Second call: cache hit (inner should NOT be called again)
178
+ count2 := cached.CountTokens(text)
179
+ if callCount != 1 {
180
+ t.Errorf("expected still 1 inner call after cache hit, got %d", callCount)
181
+ }
182
+
183
+ if count1 != count2 {
184
+ t.Errorf("cache returned different values: %d vs %d", count1, count2)
185
+ }
186
+
187
+ if cached.CacheSize() != 1 {
188
+ t.Errorf("cache size should be 1, got %d", cached.CacheSize())
189
+ }
190
+ }
191
+
192
+ func TestCachedTokenizer_Empty(t *testing.T) {
193
+ inner := &HeuristicTokenizer{}
194
+ cached := NewCachedTokenizer(inner)
195
+
196
+ if cached.CountTokens("") != 0 {
197
+ t.Error("empty string should return 0 without caching")
198
+ }
199
+ if cached.CacheSize() != 0 {
200
+ t.Error("cache should not store empty strings")
201
+ }
202
+ }
203
+
204
+ func TestCachedTokenizer_DifferentStrings(t *testing.T) {
205
+ inner := &HeuristicTokenizer{}
206
+ cached := NewCachedTokenizer(inner)
207
+
208
+ cached.CountTokens("string one")
209
+ cached.CountTokens("string two")
210
+ cached.CountTokens("string three")
211
+
212
+ if cached.CacheSize() != 3 {
213
+ t.Errorf("cache size should be 3, got %d", cached.CacheSize())
214
+ }
215
+ }
216
+
217
+ func TestCachedTokenizer_Inner(t *testing.T) {
218
+ inner := &HeuristicTokenizer{}
219
+ cached := NewCachedTokenizer(inner)
220
+
221
+ if cached.Inner() != inner {
222
+ t.Error("Inner() should return the wrapped tokenizer")
223
+ }
224
+ }
225
+
226
+ // countingTokenizer tracks how many times CountTokens is called.
227
+ type countingTokenizer struct {
228
+ fn func(string) int
229
+ }
230
+
231
+ func (c *countingTokenizer) CountTokens(text string) int {
232
+ return c.fn(text)
233
+ }
234
+
235
+ // ─── Global Default Tokenizer Tests ──────────────────────────────────────────
236
+
237
+ func TestSetDefaultTokenizer_KnownModel(t *testing.T) {
238
+ defer ResetDefaultTokenizer()
239
+
240
+ SetDefaultTokenizer("gpt-4o")
241
+ tok := GetTokenizer()
242
+
243
+ // Should be a CachedTokenizer wrapping a TiktokenTokenizer
244
+ cached, ok := tok.(*CachedTokenizer)
245
+ if !ok {
246
+ t.Fatalf("expected CachedTokenizer, got %T", tok)
247
+ }
248
+
249
+ inner, ok := cached.Inner().(*TiktokenTokenizer)
250
+ if !ok {
251
+ t.Fatalf("expected inner TiktokenTokenizer, got %T", cached.Inner())
252
+ }
253
+
254
+ if inner.EncodingName() != "o200k_base" {
255
+ t.Errorf("expected o200k_base encoding, got %s", inner.EncodingName())
256
+ }
257
+
258
+ // Verify it actually counts tokens
259
+ count := tok.CountTokens("Hello, world!")
260
+ if count <= 0 {
261
+ t.Error("tokenizer should count > 0 tokens")
262
+ }
263
+ }
264
+
265
+ func TestSetDefaultTokenizer_UnknownModel(t *testing.T) {
266
+ defer ResetDefaultTokenizer()
267
+
268
+ // Even unknown models should work because we default to cl100k_base
269
+ SetDefaultTokenizer("totally-unknown-model-xyz")
270
+ tok := GetTokenizer()
271
+
272
+ count := tok.CountTokens("Hello, world!")
273
+ if count <= 0 {
274
+ t.Error("tokenizer should count > 0 tokens even for unknown model")
275
+ }
276
+ }
277
+
278
+ func TestEstimateTokens_UsesDefaultTokenizer(t *testing.T) {
279
+ defer ResetDefaultTokenizer()
280
+
281
+ // With heuristic default
282
+ heuristicCount := EstimateTokens("Hello, world! This is a test.")
283
+
284
+ // Switch to BPE
285
+ SetDefaultTokenizer("gpt-4o")
286
+ bpeCount := EstimateTokens("Hello, world! This is a test.")
287
+
288
+ t.Logf("EstimateTokens: heuristic=%d, bpe=%d", heuristicCount, bpeCount)
289
+
290
+ // Both should be > 0
291
+ if heuristicCount <= 0 || bpeCount <= 0 {
292
+ t.Errorf("both counts should be > 0: heuristic=%d, bpe=%d", heuristicCount, bpeCount)
293
+ }
294
+ }
295
+
296
+ func TestResetDefaultTokenizer(t *testing.T) {
297
+ SetDefaultTokenizer("gpt-4o")
298
+ ResetDefaultTokenizer()
299
+
300
+ tok := GetTokenizer()
301
+ _, ok := tok.(*HeuristicTokenizer)
302
+ if !ok {
303
+ t.Errorf("after reset, expected HeuristicTokenizer, got %T", tok)
304
+ }
305
+ }
package/go/rlm/types.go CHANGED
@@ -6,10 +6,13 @@ import (
6
6
  )
7
7
 
8
8
  type RLMStats struct {
9
- LlmCalls int `json:"llm_calls"`
10
- Iterations int `json:"iterations"`
11
- Depth int `json:"depth"`
12
- ParsingRetries int `json:"parsing_retries,omitempty"`
9
+ LlmCalls int `json:"llm_calls"`
10
+ Iterations int `json:"iterations"`
11
+ Depth int `json:"depth"`
12
+ ParsingRetries int `json:"parsing_retries,omitempty"`
13
+ TotalTokens int `json:"total_tokens,omitempty"`
14
+ PromptTokens int `json:"prompt_tokens,omitempty"`
15
+ CompletionTokens int `json:"completion_tokens,omitempty"`
13
16
  }
14
17
 
15
18
  type JSONSchema struct {
@@ -67,6 +70,7 @@ type Config struct {
67
70
  MetaAgent *MetaAgentConfig
68
71
  Observability *ObservabilityConfig
69
72
  ContextOverflow *ContextOverflowConfig
73
+ LCM *LCMConfig // Lossless Context Management configuration
70
74
  }
71
75
 
72
76
  func ConfigFromMap(config map[string]interface{}) Config {
@@ -123,6 +127,27 @@ func ConfigFromMap(config map[string]interface{}) Config {
123
127
  parsed.ContextOverflow = &co
124
128
  }
125
129
 
130
+ // Extract LCM config
131
+ if lcmConfig, ok := config["lcm"].(map[string]interface{}); ok {
132
+ lcm := DefaultLCMConfig()
133
+ if v, ok := lcmConfig["enabled"].(bool); ok {
134
+ lcm.Enabled = v
135
+ }
136
+ if v, ok := toInt(lcmConfig["soft_threshold"]); ok {
137
+ lcm.SoftThreshold = v
138
+ }
139
+ if v, ok := toInt(lcmConfig["hard_threshold"]); ok {
140
+ lcm.HardThreshold = v
141
+ }
142
+ if v, ok := toInt(lcmConfig["compaction_block_size"]); ok {
143
+ lcm.CompactionBlockSize = v
144
+ }
145
+ if v, ok := toInt(lcmConfig["summary_target_tokens"]); ok {
146
+ lcm.SummaryTargetTokens = v
147
+ }
148
+ parsed.LCM = &lcm
149
+ }
150
+
126
151
  for key, value := range config {
127
152
  switch key {
128
153
  case "recursive_model":
@@ -156,7 +181,7 @@ func ConfigFromMap(config map[string]interface{}) Config {
156
181
  "trace_endpoint", "service_name", "log_output",
157
182
  "langfuse_enabled", "langfuse_public_key",
158
183
  "langfuse_secret_key", "langfuse_host",
159
- "context_overflow":
184
+ "context_overflow", "lcm":
160
185
  // ignore bridge-only config, meta_agent, observability, context_overflow (handled above/separately)
161
186
  default:
162
187
  parsed.ExtraParams[key] = value
package/go/rlm.test ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recursive-llm-ts",
3
- "version": "4.8.0",
3
+ "version": "5.0.0",
4
4
  "description": "TypeScript bridge for recursive-llm: Recursive Language Models for unbounded context processing with structured outputs",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -48,12 +48,12 @@
48
48
  "license": "MIT",
49
49
  "repository": {
50
50
  "type": "git",
51
- "url": "git+https://github.com/jbeck018/recursive-llm-ts.git"
51
+ "url": "git+https://github.com/howlerops/recursive-llm-ts.git"
52
52
  },
53
53
  "bugs": {
54
- "url": "https://github.com/jbeck018/recursive-llm-ts/issues"
54
+ "url": "https://github.com/howlerops/recursive-llm-ts/issues"
55
55
  },
56
- "homepage": "https://github.com/jbeck018/recursive-llm-ts#readme",
56
+ "homepage": "https://github.com/howlerops/recursive-llm-ts#readme",
57
57
  "dependencies": {
58
58
  "zod": "^4.3.6"
59
59
  },