ruby_llm-infinite_context 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE.txt +21 -0
- data/README.md +198 -0
- data/THIRD_PARTY_NOTICES.md +52 -0
- data/lib/generators/ruby_llm/infinite_context/install/install_generator.rb +34 -0
- data/lib/generators/ruby_llm/infinite_context/install/templates/migration.rb.tt +7 -0
- data/lib/ruby_llm/infinite_context/batch_import.rb +163 -0
- data/lib/ruby_llm/infinite_context/cache_mark.rb +76 -0
- data/lib/ruby_llm/infinite_context/chat_options.rb +61 -0
- data/lib/ruby_llm/infinite_context/compactor.rb +114 -0
- data/lib/ruby_llm/infinite_context/engine.rb +12 -0
- data/lib/ruby_llm/infinite_context/fold.rb +83 -0
- data/lib/ruby_llm/infinite_context/manager.rb +93 -0
- data/lib/ruby_llm/infinite_context/model.rb +66 -0
- data/lib/ruby_llm/infinite_context/models.rb +183 -0
- data/lib/ruby_llm/infinite_context/prompts.rb +103 -0
- data/lib/ruby_llm/infinite_context/pump.rb +149 -0
- data/lib/ruby_llm/infinite_context/pump_job.rb +18 -0
- data/lib/ruby_llm/infinite_context/schema.rb +81 -0
- data/lib/ruby_llm/infinite_context/tokens.rb +38 -0
- data/lib/ruby_llm/infinite_context/tools.rb +70 -0
- data/lib/ruby_llm/infinite_context/tree_math.rb +39 -0
- data/lib/ruby_llm/infinite_context/version.rb +7 -0
- data/lib/ruby_llm/infinite_context/zoom.rb +135 -0
- data/lib/ruby_llm/infinite_context.rb +57 -0
- metadata +111 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f3b5d8dd372848f3ae7d47169dc6b2aae612c7ea8642ce02e77dbe3e885f82e7
|
|
4
|
+
data.tar.gz: 77b06484bf8102c34a1180429dbe6edbf80fa2e9d43bc2567d406a1f9b1015da
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: afc5f0a653883822ba4b47c57eec3e96e2db5ce7c3ac8bd7d95baf542341b7d555177ad1442f4b3ae69fd3bd877549850dce6d371aa1e406b3926b1ff086832c
|
|
7
|
+
data.tar.gz: 4b5ae78486a80e821b2dbb43968155f338623b4af356621caacd28a81ebb6ffa3cdd5c48f63a53da7cd0ba3fdf185616ece50bbf2c25f7b0cd54a80be66fba44
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the gem follows
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - not yet released
|
|
8
|
+
|
|
9
|
+
First version. The gem is not on rubygems.org yet.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- The log, the summary tree and the saved views: `ric_entries`, `ric_nodes`, `ric_views`,
|
|
14
|
+
`ric_zoom_events` and `ric_trees` (json columns and row locks, so SQLite and Postgres both
|
|
15
|
+
work). Install with `rails g ruby_llm:infinite_context:install`.
|
|
16
|
+
- The compactor, with up to 5 tries to fit a node under 512 bytes, and the pump that builds
|
|
17
|
+
the tree in parallel (`Pump::Runner`, `PumpJob` for ActiveJob).
|
|
18
|
+
- Saved views that fit a byte budget the caller picks, with the sawtooth merge. Budgets are
|
|
19
|
+
bytes everywhere; `tokens:` and `20_000.tokens` (after
|
|
20
|
+
`using RubyLLM::InfiniteContext::Tokens`) convert once at the API edge at 4 bytes per token.
|
|
21
|
+
- The `zoom`, `read` and `date` tools, with a repeat-zoom guard and a per-turn cap.
|
|
22
|
+
- Counters: `dig_count`, `last_dug_at` and the zoom-event table. No rewrites yet.
|
|
23
|
+
- Tool call and result are summarized as a pair.
|
|
24
|
+
- Prompt-cache marks for providers with explicit breakpoints (Anthropic).
|
|
25
|
+
- `RubyLLM::InfiniteContext.attach` (also `Manager.attach`) and the `Model` concern with
|
|
26
|
+
`has_infinite_context`.
|
|
27
|
+
- `provider:`, `protocol:`, `assume_model_exists:` and `context:` pass-through for the chats the
|
|
28
|
+
gem builds (`chat_options:`, `compactor_options:`, `compactor_chat_options`). RubyLLM 2.1.1
|
|
29
|
+
sends an OpenAI-provider chat to the Responses API by default; Fireworks and other
|
|
30
|
+
OpenAI-compatible services need `protocol: :chat_completions`. See the README.
|
|
31
|
+
- `BatchImport` for compressing an existing long thread at batch prices with `RubyLLM::Batch`.
|
|
32
|
+
- A public, synthetic benchmark in `bench/` (not part of the gem package), and a spend guard
|
|
33
|
+
for paid benchmark rounds. Paid runs are disabled in this release; the guard has only been
|
|
34
|
+
tested over a fake network.
|
|
35
|
+
|
|
36
|
+
### Differences to know about
|
|
37
|
+
|
|
38
|
+
- `has_infinite_context` is a plain concern: the model needs `acts_as_chat` as well. The record
|
|
39
|
+
names the model, and each turn builds a fresh chat from it. The record's own message history
|
|
40
|
+
is not sent; the tree is the memory.
|
|
41
|
+
- The `20_000.tokens` literal needs `using RubyLLM::InfiniteContext::Tokens`. The `tokens:`
|
|
42
|
+
keyword does not.
|
|
43
|
+
- The agent tools are `zoom`, `read` and `date`.
|
|
44
|
+
- The action on repeated digs ships as `:none` only. The counters are there so a later release
|
|
45
|
+
can decide from data.
|
|
46
|
+
|
|
47
|
+
### Known limits
|
|
48
|
+
|
|
49
|
+
- Token budgets use a rough 4 bytes per token. For exact budgets, pass bytes.
|
|
50
|
+
- Nothing in this release has been run against a live model provider by its authors' test
|
|
51
|
+
suite; the suite uses a fake network.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christian Genco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# ruby_llm-infinite_context
|
|
2
|
+
|
|
3
|
+
Endless memory for [RubyLLM](https://github.com/crmne/ruby_llm) 2.x chats and agents.
|
|
4
|
+
|
|
5
|
+
Every message is logged. A cheap model compresses the log into a binary tree of one-line
|
|
6
|
+
summaries. The chat sees a saved "view" of the tree that fits a budget you pick (for example
|
|
7
|
+
20,000 tokens): recent messages one per line, older ones in progressively coarser lines. The
|
|
8
|
+
agent gets tools to zoom into any line, down to the original message.
|
|
9
|
+
|
|
10
|
+
## Status
|
|
11
|
+
|
|
12
|
+
0.1.0 is the first version and has not been released yet. It has been tested end to end on three
|
|
13
|
+
real models: DeepSeek V4.1 Flash (11 of 12 questions right, 33 zooms), Claude Haiku 4.5
|
|
14
|
+
(10 of 12 right, 31 zooms) and GLM-5.3 Flash (10 of 12 right, 26 zooms), each as both
|
|
15
|
+
compactor and reader, on a small synthetic log (14 entries, 12 questions). That is a smoke
|
|
16
|
+
test that the whole path works, not a benchmark.
|
|
17
|
+
Benchmark results (quality and cost across models and view budgets) come later; the benchmark
|
|
18
|
+
itself is in `bench/`.
|
|
19
|
+
|
|
20
|
+
## Credits
|
|
21
|
+
|
|
22
|
+
This gem is based on **OptChat / UniiChat**, Victor Taelin's design for one endless chat whose
|
|
23
|
+
log is summarized into a binary tree:
|
|
24
|
+
|
|
25
|
+
- OptChat/UniiChat gist: https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449
|
|
26
|
+
- [pi-optchat](https://github.com/jonaslsaa/pi-optchat) (Jonas, MIT), a Pi extension that
|
|
27
|
+
implements the gist, and its npm package
|
|
28
|
+
[pi-optchat](https://www.npmjs.com/package/pi-optchat).
|
|
29
|
+
|
|
30
|
+
The design ideas come from the gist and pi-optchat; the prompts in this gem are our own words.
|
|
31
|
+
See `THIRD_PARTY_NOTICES.md`.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
gem "ruby_llm-infinite_context"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then create the tables:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
rails g ruby_llm:infinite_context:install
|
|
43
|
+
rails db:migrate
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
### With a chat
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
chat = RubyLLM.chat(model: "claude-sonnet-5-5")
|
|
52
|
+
memory = RubyLLM::InfiniteContext.attach(chat, key: "conv-1",
|
|
53
|
+
compactor: "claude-haiku-4-5",
|
|
54
|
+
tokens: 20_000)
|
|
55
|
+
memory.turn("...")
|
|
56
|
+
memory.tree.view_lines(budget: 80_000) # the saved view, "id+n|text" lines
|
|
57
|
+
memory.tree.stats.hot_nodes(limit: 10) # the most opened lines
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
One `Chat` per turn; the tree is the only memory between turns. Each turn logs the user's
|
|
61
|
+
message and the reply, shows the view as instructions, hands the agent `zoom`, `read` and
|
|
62
|
+
`date` tools, and then pumps the compactor for nodes that are not built yet.
|
|
63
|
+
|
|
64
|
+
### Fireworks and other OpenAI-compatible services
|
|
65
|
+
|
|
66
|
+
**RubyLLM 2.1.1 sends an OpenAI-provider chat to the Responses API by default**
|
|
67
|
+
(`POST {base}/responses`, with `store: false`). Many OpenAI-compatible services only offer
|
|
68
|
+
chat completions (`POST {base}/chat/completions`). Fireworks offers both, but its Responses
|
|
69
|
+
API has storing features, so use chat completions there. For any of these, build the chat
|
|
70
|
+
with `protocol: :chat_completions`, for both your chat and the compactor:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
fireworks = RubyLLM.context do |config|
|
|
74
|
+
config.openai_api_key = Rails.application.credentials.fireworks_api_key
|
|
75
|
+
config.openai_api_base = "https://api.fireworks.ai/inference/v1"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
wire = { provider: :openai, protocol: :chat_completions,
|
|
79
|
+
assume_model_exists: true, context: fireworks }
|
|
80
|
+
|
|
81
|
+
memory = RubyLLM::InfiniteContext.attach(
|
|
82
|
+
"accounts/fireworks/models/deepseek-v4p1-flash", # a model NAME: the gem builds the chat
|
|
83
|
+
key: "conv-1",
|
|
84
|
+
compactor: "accounts/fireworks/models/glm-5p3-flash",
|
|
85
|
+
chat_options: wire, # how the gem builds your chat from the name
|
|
86
|
+
compactor_options: wire # how it builds the compactor's chats
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The options are the ones `RubyLLM.chat` takes to pick the wire: `provider:`, `protocol:`,
|
|
91
|
+
`assume_model_exists:` and `context:`. An unknown key raises, so a typo such as `protcol:`
|
|
92
|
+
cannot quietly pick the wrong API. The same `chat_options:` is accepted by
|
|
93
|
+
`Compactor.new`, `BatchImport.new` and `Pump::Runner.new`. If you pass a ready `Chat` (or a
|
|
94
|
+
lambda that returns one), you build it yourself:
|
|
95
|
+
`RubyLLM.chat(model: ..., provider: :openai, protocol: :chat_completions, ...)`; only the
|
|
96
|
+
compactor then needs `compactor_options:`.
|
|
97
|
+
|
|
98
|
+
The pump that runs inside an ActiveJob (`pump: :job`, `PumpJob`) cannot carry per-call
|
|
99
|
+
options, so set the compactor's default once at boot:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
RubyLLM::InfiniteContext.compactor_chat_options = wire
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
For a Rails model, see below: set `protocol`, `provider` and `context` on the record, and
|
|
106
|
+
pass `compactor_options:` to `has_infinite_context`.
|
|
107
|
+
|
|
108
|
+
### Tokens or bytes
|
|
109
|
+
|
|
110
|
+
Every budget inside the gem is UTF-8 bytes. You can ask in tokens: pass `tokens:`
|
|
111
|
+
(`tokens: 20_000`), or write `budget: 20_000.tokens` after
|
|
112
|
+
`using RubyLLM::InfiniteContext::Tokens`. Both convert once at the API edge with a rough
|
|
113
|
+
estimator of 4 bytes per token, which is right for English prose; text with code or
|
|
114
|
+
non-ASCII runs heavier. For exact budgets, pass `budget:` in bytes.
|
|
115
|
+
|
|
116
|
+
### With a Rails model
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
class Conversation < ApplicationRecord
|
|
120
|
+
acts_as_chat
|
|
121
|
+
include RubyLLM::InfiniteContext::Model
|
|
122
|
+
has_infinite_context compactor: "claude-haiku-4-5"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
memory = conversation.with_infinite_context(tokens: 20_000)
|
|
126
|
+
memory.turn("...")
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The record names the model. Each turn builds a fresh RubyLLM chat from the record's
|
|
130
|
+
`model_id`, `provider`, `protocol`, `assume_model_exists` and `context` (the attributes
|
|
131
|
+
`acts_as_chat` gives it). The record's own message history is not sent: the tree is the
|
|
132
|
+
memory between turns, so the turn does not grow with the thread. For Fireworks, set
|
|
133
|
+
`conversation.protocol = :chat_completions` (the attributes `protocol`, `assume_model_exists`
|
|
134
|
+
and `context` are not saved; set them after loading the record) and use
|
|
135
|
+
`has_infinite_context compactor: "...", compactor_options: wire`.
|
|
136
|
+
|
|
137
|
+
### Bulk import of an existing thread
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
importer = RubyLLM::InfiniteContext::BatchImport.new(tree)
|
|
141
|
+
submission = importer.submit(messages.map { |m| { role: m.role, content: m.content } })
|
|
142
|
+
# persist submission.id; later:
|
|
143
|
+
batch = RubyLLM::Batch.find(submission.id, provider: submission.provider)
|
|
144
|
+
importer.collect!(submission, batch) if batch.refresh.complete?
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Compresses an existing long thread at batch prices with `RubyLLM::Batch` (`ask_later`).
|
|
148
|
+
Call `submit` again after `collect!` when the thread is longer than one batch covers:
|
|
149
|
+
later levels become ready as their children finish.
|
|
150
|
+
|
|
151
|
+
## How it works
|
|
152
|
+
|
|
153
|
+
- **Log** (`ric_entries`): append-only, never updated or deleted.
|
|
154
|
+
- **Tree** (`ric_nodes`): node `(level, i)` covers entries `[i·2^level, (i+1)·2^level)`.
|
|
155
|
+
A source that fits in 512 bytes is its own node with no model call; otherwise the compactor
|
|
156
|
+
model compresses or merges it, with the view as context, up to 5 tries to fit under 512
|
|
157
|
+
bytes (the shortest try wins). Built once, never recomputed.
|
|
158
|
+
- **Views** (`ric_views`): one saved view per budget. The view only appends at the end and
|
|
159
|
+
merges the most due adjacent pair whose parent is built; it never splits, so older stretches
|
|
160
|
+
sit in coarser and coarser lines (the sawtooth). A saved view is never rebuilt from the log.
|
|
161
|
+
The compactor works against its own 24 KB view, separate from whatever the consumer sees.
|
|
162
|
+
- **Zoom** (`ric_zoom_events`): `zoom(id, n)` opens a line into the two lines it was built
|
|
163
|
+
from; `zoom(id, 1)` shows the message, paged when long (`read(id, page)`). Opening a line
|
|
164
|
+
the agent already opened this turn returns "you already opened this line; its text is
|
|
165
|
+
above" instead of the text again, and a per-turn cap (12 by default) bounds runaway zooming.
|
|
166
|
+
- **Counters, not rewrites**: nodes count `dig_count` and `last_dug_at`; zoom events record
|
|
167
|
+
the leaf a dig reached, the steps it cost, and the turn. No parent rewrite, no fold boost:
|
|
168
|
+
the shipped action on repeated digs is `:none` (do nothing) until data supports one.
|
|
169
|
+
- **Tool pairs**: a tool result is summarized with its call as context, so the pair survives
|
|
170
|
+
as one fact.
|
|
171
|
+
- **Prompt caching** (`CacheMark`): for providers that accept explicit cache breakpoints
|
|
172
|
+
(Anthropic), the view is sent as fixed blocks of 8 parts with one mark on the part the next
|
|
173
|
+
messages will not change. Other providers rely on implicit prefix caching.
|
|
174
|
+
|
|
175
|
+
## SQLite and Postgres
|
|
176
|
+
|
|
177
|
+
The tables use `json` columns and row locks, not `jsonb` or advisory locks, so both work.
|
|
178
|
+
|
|
179
|
+
## Benchmarks
|
|
180
|
+
|
|
181
|
+
`bench/` holds a public, synthetic benchmark: a generator that writes agent sessions with
|
|
182
|
+
known gold facts, and a runner that scores compactor quality (fit rate, ruler-copy rate,
|
|
183
|
+
retention up the tree) and end-to-end accuracy at several view budgets against cut-off and
|
|
184
|
+
rolling-compaction baselines, with cost in USD. `bench/dry_run.rb` exercises the whole
|
|
185
|
+
machinery with no API calls. No client data is used anywhere in the corpus. See
|
|
186
|
+
`bench/README.md` to reproduce.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
bin/setup
|
|
192
|
+
rake test
|
|
193
|
+
rake standard
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT. See `THIRD_PARTY_NOTICES.md` for third-party credits.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
This gem includes no third-party code, but its design is derived from these projects:
|
|
4
|
+
|
|
5
|
+
## OptChat / UniiChat (Victor Taelin)
|
|
6
|
+
|
|
7
|
+
The core design — an append-only message log summarized into a binary tree of one-line
|
|
8
|
+
summaries, a byte-budget view over that tree, and tools to zoom from a summary line down to
|
|
9
|
+
the original messages — comes from Victor Taelin's "OptChat" gist (titled "UniiChat").
|
|
10
|
+
|
|
11
|
+
- https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449
|
|
12
|
+
|
|
13
|
+
The prompts in this gem are original text written for this gem; they implement the same ideas
|
|
14
|
+
(message kinds, the value order of what to keep, the byte limit, tagging items with their
|
|
15
|
+
kind) but are not copied from the gist.
|
|
16
|
+
|
|
17
|
+
## pi-optchat (Jonas) — MIT
|
|
18
|
+
|
|
19
|
+
An independent implementation of the same gist as a Pi extension, used as the up-to-date
|
|
20
|
+
reference for behavior. Its third-party notices file is the model for this file.
|
|
21
|
+
|
|
22
|
+
- https://github.com/jonaslsaa/pi-optchat
|
|
23
|
+
- https://www.npmjs.com/package/pi-optchat
|
|
24
|
+
|
|
25
|
+
Its license text:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
MIT License
|
|
29
|
+
|
|
30
|
+
Copyright (c) 2026 Jonas Silva
|
|
31
|
+
|
|
32
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
33
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
34
|
+
in the Software without restriction, including without limitation the rights
|
|
35
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
36
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
37
|
+
furnished to do so, subject to the following conditions:
|
|
38
|
+
|
|
39
|
+
The above copyright notice and this permission notice shall be included in all
|
|
40
|
+
copies or substantial portions of the Software.
|
|
41
|
+
|
|
42
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
43
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
44
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
45
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
46
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
47
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
48
|
+
SOFTWARE.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
(License text from pi-optchat's LICENSE file, read from the published npm tarball on
|
|
52
|
+
2026-11-09.)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
require "rails/generators/active_record"
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module InfiniteContext
|
|
9
|
+
module Generators
|
|
10
|
+
# `rails g ruby_llm:infinite_context:install` copies the migration
|
|
11
|
+
# that creates the ric_ tables.
|
|
12
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
13
|
+
include ::Rails::Generators::Migration
|
|
14
|
+
|
|
15
|
+
# Set by hand: Rails would derive "ruby_l_l_m:..." from the class
|
|
16
|
+
# name (RubyLLM), and `rails g ruby_llm:infinite_context:install`
|
|
17
|
+
# would not be found.
|
|
18
|
+
namespace "ruby_llm:infinite_context:install"
|
|
19
|
+
|
|
20
|
+
source_root File.expand_path("templates", __dir__)
|
|
21
|
+
desc "Creates the ric_ tables for ruby_llm-infinite_context"
|
|
22
|
+
|
|
23
|
+
# Rails::Generators::Migration leaves this to the including class.
|
|
24
|
+
def self.next_migration_number(dir)
|
|
25
|
+
::ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def copy_migration
|
|
29
|
+
migration_template "migration.rb.tt", "db/migrate/create_ruby_llm_infinite_context_tables.rb"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module InfiniteContext
|
|
5
|
+
# Bulk import: compress an existing long thread with RubyLLM::Batch.
|
|
6
|
+
# Appends the thread to the log, then submits one batched request per
|
|
7
|
+
# node that needs a model call (long entries and merges).
|
|
8
|
+
#
|
|
9
|
+
# messages = existing_thread.map { |m| { role: m.role, content: m.content } }
|
|
10
|
+
# submission = RubyLLM::InfiniteContext::BatchImport.new(tree).submit(messages)
|
|
11
|
+
# submission.id # persist this
|
|
12
|
+
# ...
|
|
13
|
+
# batch = RubyLLM::Batch.find(submission.id, provider: submission.provider)
|
|
14
|
+
# importer.collect!(submission, batch) if batch.refresh.complete?
|
|
15
|
+
class BatchImport
|
|
16
|
+
include TreeMath
|
|
17
|
+
|
|
18
|
+
# nodes: the [level, i] pairs in the batch; id: the provider batch id;
|
|
19
|
+
# provider: the provider name, for RubyLLM::Batch.find.
|
|
20
|
+
Submission = Data.define(:nodes, :id, :provider)
|
|
21
|
+
|
|
22
|
+
# chat_options: see Compactor (provider:, protocol:, ...).
|
|
23
|
+
def initialize(tree, model: nil, chat_factory: nil, batch_class: nil, chat_options: nil)
|
|
24
|
+
@tree = tree
|
|
25
|
+
@model = model || tree.compactor_model
|
|
26
|
+
@chat_options = chat_options && ChatOptions.normalize(chat_options)
|
|
27
|
+
@chat_factory = chat_factory
|
|
28
|
+
@batch_class = batch_class || (RubyLLM::Batch if defined?(RubyLLM::Batch))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Appends `messages` (role/content pairs) to the log and submits the
|
|
32
|
+
# ready nodes as one batch. Returns nil when nothing needs a model
|
|
33
|
+
# call. When RubyLLM::Batch is unavailable, use Pump::Runner instead.
|
|
34
|
+
def submit(messages)
|
|
35
|
+
import_messages(messages)
|
|
36
|
+
chats = []
|
|
37
|
+
nodes = []
|
|
38
|
+
batchable_nodes.each do |level, i|
|
|
39
|
+
prompt = prompt_for(level, i)
|
|
40
|
+
next unless prompt
|
|
41
|
+
|
|
42
|
+
chat = new_chat
|
|
43
|
+
chat.ask_later(prompt)
|
|
44
|
+
chats << chat
|
|
45
|
+
nodes << [level, i]
|
|
46
|
+
end
|
|
47
|
+
return nil if chats.empty?
|
|
48
|
+
|
|
49
|
+
raise Error, "RubyLLM::Batch is not available" unless @batch_class
|
|
50
|
+
|
|
51
|
+
batch = @batch_class.submit(chats)
|
|
52
|
+
Submission.new(nodes:, id: batch.id, provider: provider_name(batch))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Stores the batch's answers on their nodes. Validates the whole batch
|
|
56
|
+
# FIRST: a missing, empty or short result fails the whole collect and
|
|
57
|
+
# stores nothing, so a partial batch never half-populates the tree.
|
|
58
|
+
def collect!(submission, batch)
|
|
59
|
+
raise Error, "batch is not complete (#{batch.status})" unless batch.complete?
|
|
60
|
+
|
|
61
|
+
messages = batch.messages
|
|
62
|
+
problems = []
|
|
63
|
+
if messages.size != submission.nodes.size
|
|
64
|
+
problems << "expected #{submission.nodes.size} results, got #{messages.size}"
|
|
65
|
+
end
|
|
66
|
+
messages.zip(submission.nodes).each_with_index do |(message, node), i|
|
|
67
|
+
text = message&.content.to_s.strip
|
|
68
|
+
problems << "result #{i} for node #{TreeMath.label(*node)} is missing or empty" if text.empty?
|
|
69
|
+
end
|
|
70
|
+
return 0 if submission.nodes.empty?
|
|
71
|
+
raise Error, "batch results incomplete, nothing stored: #{problems.join("; ")}" unless problems.empty?
|
|
72
|
+
|
|
73
|
+
stored = 0
|
|
74
|
+
messages.zip(submission.nodes).each do |message, node|
|
|
75
|
+
@tree.store_node!(node[0], node[1], message.content.to_s.strip, tries: 1)
|
|
76
|
+
stored += 1
|
|
77
|
+
end
|
|
78
|
+
stored
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def import_messages(messages)
|
|
84
|
+
messages.each do |m|
|
|
85
|
+
role = (m[:role] || m["role"] || :user).to_s
|
|
86
|
+
kind = if role == "assistant"
|
|
87
|
+
:assistant
|
|
88
|
+
else
|
|
89
|
+
(if role == "tool"
|
|
90
|
+
:echo
|
|
91
|
+
else
|
|
92
|
+
((role == "tool_call") ? :tool : role)
|
|
93
|
+
end)
|
|
94
|
+
end
|
|
95
|
+
kind = :user unless KINDS.include?(kind.to_s)
|
|
96
|
+
@tree.append(kind, (m[:content] || m["content"]).to_s)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def total = @tree.entry_count
|
|
101
|
+
|
|
102
|
+
# The nodes that can be batched now: long level-0 entries, then merges
|
|
103
|
+
# whose children are built. Unlike the pump's rule, a level-0 node does
|
|
104
|
+
# not wait for the lines before it: in a batch its context is
|
|
105
|
+
# best-effort. Call `submit` again after `collect!` for the next wave.
|
|
106
|
+
def batchable_nodes
|
|
107
|
+
total = @tree.entry_count
|
|
108
|
+
sizes = @tree.node_sizes
|
|
109
|
+
out = []
|
|
110
|
+
total.times do |i|
|
|
111
|
+
out << [0, i] if !sizes.key?([0, i]) && TreeMath.bytes(@tree.entry(i).text) > NODE
|
|
112
|
+
end
|
|
113
|
+
l = 1
|
|
114
|
+
while (1 << l) <= total
|
|
115
|
+
(0...(total >> l)).each do |i|
|
|
116
|
+
next if sizes.key?([l, i])
|
|
117
|
+
next unless sizes.key?([l - 1, 2 * i]) && sizes.key?([l - 1, 2 * i + 1])
|
|
118
|
+
|
|
119
|
+
joined = "#{@tree.node_text(l - 1, 2 * i)}\n#{@tree.node_text(l - 1, 2 * i + 1)}"
|
|
120
|
+
out << [l, i] if TreeMath.bytes(joined) > NODE
|
|
121
|
+
end
|
|
122
|
+
l += 1
|
|
123
|
+
end
|
|
124
|
+
out
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The batched prompt for one node, or nil when no model call is needed
|
|
128
|
+
# (a short entry is its own node; a joined pair under NODE is a node).
|
|
129
|
+
def prompt_for(level, i)
|
|
130
|
+
if level.zero?
|
|
131
|
+
entry = @tree.entry(i)
|
|
132
|
+
return nil if TreeMath.bytes(entry.text) <= NODE
|
|
133
|
+
|
|
134
|
+
Compactor.compress_prompt(entry.kind, entry.text, call: @tree.paired_call(i)&.text)
|
|
135
|
+
else
|
|
136
|
+
a = @tree.node_text(level - 1, 2 * i)
|
|
137
|
+
b = @tree.node_text(level - 1, 2 * i + 1)
|
|
138
|
+
return nil unless a && b
|
|
139
|
+
|
|
140
|
+
joined = "#{a}\n#{b}"
|
|
141
|
+
return nil if TreeMath.bytes(joined) <= NODE
|
|
142
|
+
|
|
143
|
+
Compactor.merge_prompt(a, b)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def new_chat
|
|
148
|
+
chat = if @chat_factory
|
|
149
|
+
@chat_factory.call
|
|
150
|
+
else
|
|
151
|
+
ChatOptions.build(@model, @chat_options || InfiniteContext.compactor_chat_options)
|
|
152
|
+
end
|
|
153
|
+
chat.with_instructions(Prompts::COMPACT_SYSTEM)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def provider_name(batch)
|
|
157
|
+
batch.provider&.respond_to?(:slug) ? batch.provider.slug : nil
|
|
158
|
+
rescue
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module InfiniteContext
|
|
5
|
+
# How to send the view so that consecutive turns read it from the
|
|
6
|
+
# provider's prompt cache. Only providers that accept explicit cache
|
|
7
|
+
# breakpoints use this (Anthropic); others rely on implicit prefix
|
|
8
|
+
# caching, and some (OpenAI) reject explicit breakpoints outright.
|
|
9
|
+
#
|
|
10
|
+
# A cache entry matches on the exact block layout, so:
|
|
11
|
+
# - The view goes as blocks of GRID parts each (a fixed grid by part
|
|
12
|
+
# index). Where two views agree, their blocks are byte-identical.
|
|
13
|
+
# - The mark goes on the last grid cut inside the part of the view that
|
|
14
|
+
# the next messages will not change. The fold is deterministic, so we
|
|
15
|
+
# run it HORIZON messages ahead (with lines of a typical size) and
|
|
16
|
+
# stop before the first part it would merge. Under budget that is the
|
|
17
|
+
# whole view.
|
|
18
|
+
class CacheMark
|
|
19
|
+
GRID = 8
|
|
20
|
+
HORIZON = 8 # messages ahead: about two turns
|
|
21
|
+
|
|
22
|
+
# cuts: part indexes where blocks end (the last block runs to the end);
|
|
23
|
+
# mark: the cut whose block gets the cache mark, or nil.
|
|
24
|
+
Plan = Data.define(:cuts, :mark)
|
|
25
|
+
|
|
26
|
+
# parts: the view's parts; sizes: [level, i] => bytes of built nodes;
|
|
27
|
+
# previous_mark: the parts prefix the previous turn marked.
|
|
28
|
+
def self.plan(parts:, total:, sizes:, previous_mark: [], budget: DEFAULT_VIEW_BUDGET, horizon: HORIZON, grid: GRID, recall: nil)
|
|
29
|
+
cuts = (grid...parts.size).step(grid).to_a
|
|
30
|
+
stable = stable_prefix(parts, total, sizes, budget, horizon, recall)
|
|
31
|
+
mark = cuts.select { |c| c <= stable }.max
|
|
32
|
+
kept = previous_mark.size
|
|
33
|
+
mark = kept if kept.positive? && cuts.include?(kept) && (mark.nil? || kept > mark) && parts.first(kept) == previous_mark
|
|
34
|
+
Plan.new(cuts:, mark:)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# How many leading parts stay the same after `horizon` more messages.
|
|
38
|
+
def self.stable_prefix(parts, total, sizes, budget, horizon, recall = nil)
|
|
39
|
+
return 0 if parts.empty?
|
|
40
|
+
|
|
41
|
+
typical = typical_line(parts, sizes)
|
|
42
|
+
guess = lambda do |l, i|
|
|
43
|
+
next sizes[[l, i]] if sizes.key?([l, i])
|
|
44
|
+
next typical if l.zero? && i >= total # a message that does not exist yet
|
|
45
|
+
|
|
46
|
+
a = sizes[[l - 1, 2 * i]]
|
|
47
|
+
b = sizes[[l - 1, 2 * i + 1]]
|
|
48
|
+
[a + b, NODE].min if a && b # a parent the pump will build soon
|
|
49
|
+
end
|
|
50
|
+
fold = Fold.new(parts:, budget:, recall:, &guess)
|
|
51
|
+
horizon.times { |h| fold.append(total + h).fit(total + h + 1) }
|
|
52
|
+
parts.zip(fold.parts).take_while { |a, b| a == b }.size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.typical_line(parts, sizes)
|
|
56
|
+
recent = parts.last(50).filter_map { |l, i| sizes[[l, i]] if l.zero? }
|
|
57
|
+
recent.empty? ? NODE / 2 : recent.sum / recent.size
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Block = Data.define(:text, :mark)
|
|
61
|
+
|
|
62
|
+
# The view as text blocks: "<chat>\n" opens the first block, every line
|
|
63
|
+
# ends with "\n", and "</chat>" is a block of its own, so the text of a
|
|
64
|
+
# block never depends on where the view ends.
|
|
65
|
+
def self.blocks(lines, plan)
|
|
66
|
+
bounds = [0, *plan.cuts, lines.size].uniq
|
|
67
|
+
pieces = bounds.each_cons(2).map do |a, b|
|
|
68
|
+
text = lines[a...b].map { |l| "#{l}\n" }.join
|
|
69
|
+
Block.new(text: a.zero? ? "<chat>\n#{text}" : text, mark: b == plan.mark)
|
|
70
|
+
end
|
|
71
|
+
pieces = [Block.new(text: "<chat>\n", mark: false)] if pieces.empty?
|
|
72
|
+
[*pieces, Block.new(text: "</chat>", mark: false)]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module InfiniteContext
|
|
5
|
+
# How the gem builds the chats it creates itself: the compactor's chats
|
|
6
|
+
# (the pump, BatchImport) and a chat that `attach` builds from a model
|
|
7
|
+
# name. The options are the ones RubyLLM.chat takes for picking the wire:
|
|
8
|
+
#
|
|
9
|
+
# provider: :openai
|
|
10
|
+
# protocol: :chat_completions
|
|
11
|
+
# assume_model_exists: true
|
|
12
|
+
# context: a RubyLLM context (its own keys and base URL)
|
|
13
|
+
#
|
|
14
|
+
# RubyLLM 2.1.1 sends an OpenAI-provider chat to the Responses API
|
|
15
|
+
# (POST {base}/responses) by default. Fireworks and most other
|
|
16
|
+
# OpenAI-compatible services only offer chat completions, so pass
|
|
17
|
+
# `protocol: :chat_completions` for them. See the README.
|
|
18
|
+
module ChatOptions
|
|
19
|
+
KEYS = %i[provider protocol assume_model_exists context].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Returns a frozen copy of `options` with symbol keys. Unknown keys raise
|
|
24
|
+
# (a typo such as `protcol:` would silently pick the wrong API).
|
|
25
|
+
def normalize(options)
|
|
26
|
+
options = (options || {}).to_h.transform_keys(&:to_sym)
|
|
27
|
+
unknown = options.keys - KEYS
|
|
28
|
+
unless unknown.empty?
|
|
29
|
+
raise ArgumentError, "unknown chat option(s) #{unknown.inspect}; known: #{KEYS.inspect}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
options.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# RubyLLM.chat (or context.chat) with the options passed through.
|
|
36
|
+
def build(model, options = nil)
|
|
37
|
+
options = normalize(options).dup
|
|
38
|
+
context = options.delete(:context)
|
|
39
|
+
(context || RubyLLM).chat(model: model, **options)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# Default options for the compactor's chats when a Compactor,
|
|
45
|
+
# BatchImport or the pump is built without its own `chat_options:`.
|
|
46
|
+
# The pump's default builder and PumpJob (ActiveJob) have nowhere else
|
|
47
|
+
# to read them from, so set this once at boot for those paths:
|
|
48
|
+
#
|
|
49
|
+
# RubyLLM::InfiniteContext.compactor_chat_options =
|
|
50
|
+
# {provider: :openai, protocol: :chat_completions, assume_model_exists: true}
|
|
51
|
+
def compactor_chat_options = @compactor_chat_options ||= {}.freeze
|
|
52
|
+
|
|
53
|
+
def compactor_chat_options=(options)
|
|
54
|
+
@compactor_chat_options = ChatOptions.normalize(options)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Attaches a chat to a memory tree; see Manager.attach.
|
|
58
|
+
def attach(...) = Manager.attach(...)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|