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 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRubyLlmInfiniteContextTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ RubyLLM::InfiniteContext::Schema.migration(self)
6
+ end
7
+ 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