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.
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module InfiniteContext
5
+ # The compactor's scheduler. Builds every node that is not built, has its
6
+ # sources, and has its whole context summarized (every view line before
7
+ # the node's end is built). Up to JOBS at once; a failed node is retried
8
+ # up to `retries` times.
9
+ module Pump
10
+ # The nodes to start now, in order: levels from 0 up, and in each
11
+ # level from the left. `built` and `busy` are sets of [level, i].
12
+ def self.ready_nodes(total:, first:, built:, busy:, slots:)
13
+ out = []
14
+ l = 0
15
+ while (1 << l) <= total
16
+ i = 0
17
+ while ((i + 1) << l) <= total
18
+ return out if out.size >= slots
19
+
20
+ node = [l, i]
21
+ finish = l.zero? ? i : (i + 1) << l
22
+ if !built.include?(node) && !busy.include?(node) && finish <= first &&
23
+ (l.zero? || (built.include?([l - 1, 2 * i]) && built.include?([l - 1, 2 * i + 1])))
24
+ out << node
25
+ end
26
+ i += 1
27
+ end
28
+ l += 1
29
+ end
30
+ out
31
+ end
32
+
33
+ # Runs a worker thread's body inside the Rails executor when a Rails
34
+ # app is running, else plain.
35
+ def self.wrap_thread(&block)
36
+ executor = (defined?(::Rails) && ::Rails.respond_to?(:application)) ? ::Rails.application&.executor : nil
37
+ executor ? executor.wrap(&block) : block.call
38
+ end
39
+
40
+ # Runs a blocking wait for worker threads so that they may autoload
41
+ # constants meanwhile (the Rails load interlock, active when reloading
42
+ # is on). Plain when ActiveSupport::Dependencies has no interlock.
43
+ def self.waiting(&block)
44
+ if defined?(::ActiveSupport::Dependencies) && ::ActiveSupport::Dependencies.respond_to?(:interlock)
45
+ ::ActiveSupport::Dependencies.interlock.permit_concurrent_loads(&block)
46
+ else
47
+ block.call
48
+ end
49
+ end
50
+
51
+ # Runs passes over the tree until no more nodes can start. Each pass
52
+ # starts up to `jobs` builder threads and waits for them. `builder`
53
+ # must answer build(level, i) and return [text, tries].
54
+ class Runner
55
+ include TreeMath
56
+
57
+ # chat_options: for the default Compactor (ignored when `builder:` is given).
58
+ def initialize(tree, builder: nil, jobs: JOBS, retries: 3, logger: nil, chat_options: nil)
59
+ @tree = tree
60
+ @builder = builder ||
61
+ Compactor.new(tree, model: tree.compactor_model, chat_options: chat_options)
62
+ @jobs = jobs
63
+ @retries = retries
64
+ @logger = logger
65
+ end
66
+
67
+ # Returns the number of nodes built.
68
+ #
69
+ # The workers write on their own connections, so this thread must
70
+ # read the database fresh: inside a Rails executor (rails runner, a
71
+ # request, a job) the Active Record query cache is on, and a cached
72
+ # read of the node sizes never shows a node a worker just stored, so
73
+ # the same node was built forever (found by bin/install_check). The
74
+ # cache is bypassed for the run and cleared at the end, so the
75
+ # caller's next read (the view) is fresh too.
76
+ def run
77
+ built = 0
78
+ attempts = Hash.new(0)
79
+ ::ActiveRecord::Base.uncached do
80
+ loop do
81
+ nodes = startable(Set.new)
82
+ nodes.reject! { |n| attempts[n] >= @retries }
83
+ break if nodes.empty?
84
+
85
+ run_pass(nodes).each do |node, ok|
86
+ if ok
87
+ built += 1
88
+ else
89
+ attempts[node] += 1
90
+ end
91
+ end
92
+ end
93
+ end
94
+ built
95
+ ensure
96
+ if ::ActiveRecord::Base.respond_to?(:clear_query_caches_for_current_thread)
97
+ ::ActiveRecord::Base.clear_query_caches_for_current_thread
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def startable(busy)
104
+ @tree.with_view_lock do
105
+ sizes = @tree.node_sizes
106
+ built = sizes.keys.to_set
107
+ total = @tree.entry_count
108
+ view = @tree.view(COMPACT_VIEW_BUDGET)
109
+ first = Fold.new(parts: view.parts) { |l, i| sizes[[l, i]] }.first_unbuilt(total)
110
+ Pump.ready_nodes(total:, first:, built:, busy:, slots: @jobs)
111
+ end
112
+ end
113
+
114
+ def run_pass(nodes)
115
+ done = Queue.new
116
+ threads = nodes.map do |node|
117
+ Thread.new do
118
+ ok = false
119
+ # In a Rails app each worker thread runs inside the executor (it
120
+ # returns the thread's database connection when it ends).
121
+ Pump.wrap_thread do
122
+ text, tries = @builder.build(*node)
123
+ @tree.store_node!(*node, text, tries:)
124
+ ok = true
125
+ rescue => e
126
+ log("node #{TreeMath.label(*node)} failed: #{e.class}: #{e.message}")
127
+ end
128
+ done << [node, ok]
129
+ end
130
+ end
131
+ # While this thread waits, the workers may autoload constants. With
132
+ # reloading on (Rails development) that needs the load interlock, so
133
+ # the wait must permit concurrent loads or the pass deadlocks.
134
+ Pump.waiting do
135
+ results = nodes.map { done.pop }
136
+ threads.each(&:join)
137
+ results
138
+ end
139
+ end
140
+
141
+ def log(message)
142
+ return unless @logger
143
+
144
+ (@logger.respond_to?(:call) ? @logger.call(message) : @logger.error("[infinite_context] #{message}"))
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ if defined?(ActiveJob::Base)
4
+ module RubyLLM
5
+ module InfiniteContext
6
+ # The pump as an ActiveJob: app enqueue it with `set(wait:)` and it
7
+ # works with any queue backend.
8
+ class PumpJob < ActiveJob::Base
9
+ queue_as :default
10
+
11
+ def perform(tree_id)
12
+ tree = Tree.find(tree_id)
13
+ Pump::Runner.new(tree, logger: Rails.logger).run
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module InfiniteContext
5
+ # The tables, created by the install generator's migration. `json`
6
+ # columns (not jsonb) and row locks keep SQLite and Postgres both working.
7
+ module Schema
8
+ TABLES = %w[ric_trees ric_entries ric_nodes ric_views ric_zoom_events].freeze
9
+
10
+ def self.migration(migrator)
11
+ migrator.create_table :ric_trees do |t|
12
+ t.string :key, null: false
13
+ t.string :compactor_model
14
+ t.json :options, null: false, default: {}
15
+ t.timestamps
16
+ end
17
+ migrator.add_index :ric_trees, :key, unique: true
18
+
19
+ # The append-only log. Never updated or deleted.
20
+ migrator.create_table :ric_entries do |t|
21
+ t.references :tree, null: false, index: false
22
+ t.integer :idx, null: false
23
+ t.string :kind, null: false
24
+ t.text :text, null: false
25
+ t.integer :bytes, null: false
26
+ t.datetime :created_at, null: false
27
+ end
28
+ migrator.add_index :ric_entries, [:tree_id, :idx], unique: true
29
+
30
+ # The summary tree. Node (level, i) covers entries
31
+ # [i * 2^level, (i + 1) * 2^level). Built once, never recomputed.
32
+ # dig_count and last_dug_at count how often the agent opened it.
33
+ migrator.create_table :ric_nodes do |t|
34
+ t.references :tree, null: false, index: false
35
+ t.integer :level, null: false
36
+ t.integer :i, null: false
37
+ t.text :text, null: false
38
+ t.integer :bytes, null: false
39
+ t.integer :tries, null: false, default: 0
40
+ t.integer :dig_count, null: false, default: 0
41
+ t.datetime :last_dug_at
42
+ t.datetime :created_at, null: false
43
+ end
44
+ migrator.add_index :ric_nodes, [:tree_id, :level, :i], unique: true
45
+
46
+ # One saved view per budget.
47
+ migrator.create_table :ric_views do |t|
48
+ t.references :tree, null: false, index: false
49
+ t.integer :budget, null: false
50
+ t.json :parts, null: false, default: []
51
+ t.integer :message_count, null: false, default: 0
52
+ t.timestamps
53
+ end
54
+ migrator.add_index :ric_views, [:tree_id, :budget], unique: true
55
+
56
+ # One row per zoom: the line opened, the leaf the dig reached (when
57
+ # it went all the way down), the steps it cost, and the turn.
58
+ migrator.create_table :ric_zoom_events do |t|
59
+ t.references :tree, null: false, index: false
60
+ t.string :session, null: false
61
+ t.integer :level, null: false
62
+ t.integer :i, null: false
63
+ t.integer :leaf_idx
64
+ t.integer :steps, null: false, default: 0
65
+ t.datetime :created_at, null: false
66
+ end
67
+ migrator.add_index :ric_zoom_events, [:tree_id, :session]
68
+ migrator.add_index :ric_zoom_events, [:tree_id, :level, :i]
69
+ end
70
+
71
+ # For tests and for apps that manage their own schema (no migration
72
+ # file needed).
73
+ def self.create!(connection = ::ActiveRecord::Base.connection)
74
+ shim = Object.new
75
+ shim.define_singleton_method(:create_table) { |*a, **o, &b| connection.create_table(*a, **o, &b) }
76
+ shim.define_singleton_method(:add_index) { |*a, **o| connection.add_index(*a, **o) }
77
+ migration(shim)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module InfiniteContext
5
+ # Budgets are UTF-8 bytes everywhere inside the gem. Callers think in
6
+ # tokens, so two doors:
7
+ #
8
+ # - `20_000.tokens` via the refinement (`using RubyLLM::InfiniteContext::Tokens`),
9
+ # - a `tokens:` keyword on attach, with_infinite_context and has_infinite_context.
10
+ #
11
+ # The estimator is a rough 4 bytes per token, right for English prose;
12
+ # log text with code or non-ASCII runs heavier. Callers who need exact
13
+ # token counts should pass `budget:` in bytes instead.
14
+ BYTES_PER_TOKEN = 4
15
+
16
+ def self.token_bytes(tokens)
17
+ tokens * BYTES_PER_TOKEN
18
+ end
19
+
20
+ module Tokens
21
+ refine Integer do
22
+ def tokens = RubyLLM::InfiniteContext.token_bytes(self)
23
+ end
24
+ end
25
+
26
+ # Resolves the budget from (bytes, tokens) arguments; bytes win when
27
+ # both are given.
28
+ def self.resolve_budget(budget: nil, tokens: nil)
29
+ if budget
30
+ budget
31
+ elsif tokens
32
+ token_bytes(tokens)
33
+ else
34
+ DEFAULT_VIEW_BUDGET
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module InfiniteContext
5
+ # The agent's tools, as RubyLLM::Tool classes. Instances carry the tree
6
+ # and the turn, so one Turn guards and caps the zooms of one reply.
7
+ require "ruby_llm/tool"
8
+
9
+ module Tools
10
+ # zoom(id, n): open the line covering messages id..id+n-1 into the two
11
+ # lines it was built from; zoom(id, 1) shows message id itself.
12
+ class ZoomTool < ::RubyLLM::Tool
13
+ # RubyLLM derives a name from the namespaced class
14
+ # (ruby_llm--infinite_context--tools--zoom), but the prompts and the
15
+ # view guide say "zoom". The model must see the name we teach it.
16
+ def self.tool_name = "zoom"
17
+
18
+ description "Open a line of the conversation view. zoom(id, n) returns the two " \
19
+ "half-size lines the line covering messages id..id+n-1 was built from. " \
20
+ "zoom(id, 1) shows message id in full. Zoom before you guess: when a " \
21
+ "summary only mentions something you need, open the line that mentions it."
22
+ parameter :id, type: "integer", description: "Start message index of the line"
23
+ parameter :n, type: "integer", description: "Number of messages the line covers (a power of 2); 1 for a single message"
24
+
25
+ def initialize(handler)
26
+ @handler = handler
27
+ end
28
+
29
+ def execute(id:, n:) = @handler.zoom(id, n)
30
+ end
31
+
32
+ # read(id, page): the next page of a long message.
33
+ class ReadTool < ::RubyLLM::Tool
34
+ def self.tool_name = "read"
35
+
36
+ description "Read a further page of a long message. zoom(id, 1) shows its first page."
37
+ parameter :id, type: "integer", description: "Message index"
38
+ parameter :page, type: "integer", description: "Page number, starting at 2"
39
+
40
+ def initialize(handler)
41
+ @handler = handler
42
+ end
43
+
44
+ def execute(id:, page: 2) = @handler.read(id, page)
45
+ end
46
+
47
+ # date(id): when a message was written.
48
+ class DateTool < ::RubyLLM::Tool
49
+ def self.tool_name = "date"
50
+
51
+ description "Date and time a message was written."
52
+ parameter :id, type: "integer", description: "Message index"
53
+
54
+ def initialize(handler)
55
+ @handler = handler
56
+ end
57
+
58
+ def execute(id:) = @handler.date(id)
59
+ end
60
+
61
+ # The tool set for one turn: fresh instances per turn, because the
62
+ # guard and the cap are per turn.
63
+ def self.for_turn(zoom_handler, turn)
64
+ [ZoomTool.new(Zoom.new(zoom_handler, turn)),
65
+ ReadTool.new(Zoom.new(zoom_handler, turn)),
66
+ DateTool.new(Zoom.new(zoom_handler, turn))]
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module InfiniteContext
5
+ # Shared node math and byte helpers.
6
+ #
7
+ # Node (level, i) covers entries [first, last) of the append-only log,
8
+ # where first = i << level and last = (i + 1) << level. Its label,
9
+ # "id+n", names the stretch in the view and in the zoom tools.
10
+ module TreeMath
11
+ module_function
12
+
13
+ def first(level, i) = i << level
14
+
15
+ def last(level, i) = (i + 1) << level
16
+
17
+ def label(level, i) = "#{first(level, i)}+#{1 << level}"
18
+
19
+ def bytes(text) = text.to_s.bytesize
20
+
21
+ # First `limit` bytes of text, never splitting a UTF-8 character.
22
+ def cut(text, limit)
23
+ text.byteslice(0, limit).scrub("").delete_suffix("\uFFFD")
24
+ end
25
+
26
+ # One line: whitespace runs around a newline become one space.
27
+ def flat(text) = text.to_s.gsub(/\s*\n\s*/, " ")
28
+
29
+ # Head and tail of an over-long text, with a note of what was cut.
30
+ def cap(text, limit = CAP)
31
+ return text if text.length <= limit
32
+
33
+ half = limit / 2
34
+ cut_chars = text.length - 2 * half
35
+ "#{text[0, half]}\n[... #{cut_chars} characters cut ...]\n#{text[-half..]}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module InfiniteContext
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module InfiniteContext
5
+ # Per-turn state for the zoom tools: the lines opened so far, the zoom
6
+ # count, and the turn's id (the `session` of a zoom event).
7
+ class Turn
8
+ attr_reader :id, :opened
9
+
10
+ def initialize(id = nil, cap: ZOOM_CAP_PER_TURN)
11
+ @id = id || SecureRandom.uuid
12
+ @cap = cap
13
+ @opened = {}
14
+ @zooms = 0
15
+ end
16
+
17
+ # True when the agent opened this label earlier in the turn.
18
+ def repeat?(label) = @opened.key?(label)
19
+
20
+ def open!(label, text)
21
+ @opened[label] = text
22
+ @zooms += 1
23
+ end
24
+
25
+ def cap_reached? = @zooms >= @cap
26
+ def remaining = @cap - @zooms
27
+
28
+ attr_reader :zooms
29
+ end
30
+
31
+ # The agent's memory tools: zoom, read (paging through a long message)
32
+ # and date. `#zoom` refuses repeats ("you already opened this line") and
33
+ # enforces the per-turn cap, and records counters and zoom events.
34
+ class Zoom
35
+ include TreeMath
36
+
37
+ def initialize(tree, turn)
38
+ @tree = tree
39
+ @turn = turn
40
+ end
41
+
42
+ def zoom(id, n)
43
+ id = Integer(id)
44
+ n = Integer(n)
45
+ total = @tree.entry_count
46
+ return "No line #{id}+#{n}." unless valid?(id, n, total)
47
+
48
+ return leaf(id) if n == 1
49
+
50
+ label = TreeMath.label(level_of(n), left_of(id, n))
51
+ return already_opened(label) if @turn.repeat?(label)
52
+ return cap_message if @turn.cap_reached?
53
+
54
+ @turn.open!(label, true)
55
+ level = level_of(n)
56
+ left = left_of(id, n)
57
+ text = [left, left + 1].map do |i|
58
+ child = @tree.node_text(level, i)
59
+ child ? "#{TreeMath.label(level, i)}|#{TreeMath.flat(child)}" : "#{TreeMath.label(level, i)}|#{PLACEHOLDER}"
60
+ end.join("\n")
61
+ @tree.record_zoom!(level:, i: left, leaf_idx: nil, steps: @turn.zooms, session: @turn.id)
62
+ text
63
+ rescue ArgumentError, TypeError
64
+ "No line #{id}+#{n}."
65
+ end
66
+
67
+ # A page of one message, for messages longer than one page.
68
+ def read(id, page = 1)
69
+ id = Integer(id)
70
+ page = Integer(page)
71
+ entry = @tree.entry(id)
72
+ return "No message #{id}." unless entry && page.positive?
73
+
74
+ text = entry.text.to_s
75
+ pages = (TreeMath.bytes(text).to_f / PAGE).ceil
76
+ return "Message #{id} has 1 page; it is shown in full by zoom(#{id}, 1)." if pages <= 1 || page > pages
77
+
78
+ slice = text.byteslice((page - 1) * PAGE, PAGE).to_s
79
+ suffix = (page < pages) ? "\n[page #{page} of #{pages}; read(#{id}, #{page + 1}) for more]" : "\n[end of message #{id}]"
80
+ "#{slice}#{suffix}"
81
+ rescue ArgumentError, TypeError
82
+ "No message #{id}."
83
+ end
84
+
85
+ def date(id)
86
+ entry = @tree.entry(Integer(id))
87
+ return "No message #{id}." unless entry
88
+
89
+ entry.created_at.strftime("%a %Y-%m-%d %H:%M %Z")
90
+ rescue ArgumentError, TypeError
91
+ "No message #{id}."
92
+ end
93
+
94
+ private
95
+
96
+ # One message in full (paged when long). Reaching the leaf is what the
97
+ # counters count: the leaf reached and the steps the dig cost.
98
+ def leaf(id)
99
+ label = TreeMath.label(0, id)
100
+ return already_opened(label) if @turn.repeat?(label)
101
+ return cap_message if @turn.cap_reached?
102
+
103
+ entry = @tree.entry(id)
104
+ return "No line #{id}+1." unless entry
105
+
106
+ @turn.open!(label, true)
107
+ @tree.record_zoom!(level: 0, i: id, leaf_idx: id, steps: @turn.zooms, session: @turn.id)
108
+ text = entry.text.to_s
109
+ if TreeMath.bytes(text) > PAGE
110
+ pages = (TreeMath.bytes(text).to_f / PAGE).ceil
111
+ "#{text.byteslice(0, PAGE)}\n[page 1 of #{pages}; read(#{id}, 2) for more]"
112
+ else
113
+ text
114
+ end
115
+ end
116
+
117
+ def already_opened(label)
118
+ "You already opened line #{label} this turn; its text is above. Use what you have."
119
+ end
120
+
121
+ def cap_message
122
+ "Zoom cap reached for this turn (#{@turn.opened.size} lines opened). " \
123
+ "Work with the lines you have and say what you still need in your reply."
124
+ end
125
+
126
+ def valid?(id, n, total)
127
+ n.positive? && (n & (n - 1)).zero? && (id % n).zero? && id >= 0 && id + n <= total
128
+ end
129
+
130
+ def level_of(n) = n.bit_length - 2
131
+
132
+ def left_of(id, n) = 2 * id / n
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ruby_llm-infinite_context: endless memory for RubyLLM 2.x chats and
4
+ # agents. Every message is logged, a cheap model compresses the log into a
5
+ # binary tree of one-line summaries, and the chat sees a saved view of the
6
+ # tree that fits a byte budget the caller picks. The agent gets tools to
7
+ # zoom into any line, down to the original message.
8
+ #
9
+ # Based on Victor Taelin's OptChat/UniiChat design
10
+ # (https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449)
11
+ # and on pi-optchat (https://github.com/jonaslsaa/pi-optchat). See README.md.
12
+
13
+ require_relative "infinite_context/version"
14
+
15
+ require "ruby_llm" # the full library: RubyLLM.chat and friends
16
+
17
+ module RubyLLM
18
+ module InfiniteContext
19
+ class Error < StandardError; end
20
+
21
+ NODE = 512 # target size of one summary line
22
+ DEFAULT_VIEW_BUDGET = 128_000 # default consumer view budget, bytes
23
+ COMPACT_VIEW_BUDGET = 24_000 # the compactor's own view, 16-32 KB
24
+ JOBS = 8 # compactor calls at once
25
+ MARGIN = 64 # bytes a non-Claude compactor is asked to stay under NODE
26
+ TRIES = 5 # attempts per node to get under NODE
27
+ CAP = 30_000 # max characters of one stubbed tool result (head and tail)
28
+ PAGE = 4_000 # bytes per page when reading a long message
29
+ PLACEHOLDER = "(not summarized yet: zoom it)"
30
+ ZOOM_CAP_PER_TURN = 12 # max lines the agent may open in one turn
31
+ KINDS = %w[user assistant tool echo note].freeze
32
+ end
33
+ end
34
+
35
+ require_relative "infinite_context/tree_math"
36
+ require_relative "infinite_context/schema"
37
+ require_relative "infinite_context/tokens"
38
+ require_relative "infinite_context/fold"
39
+ require_relative "infinite_context/prompts"
40
+ require_relative "infinite_context/chat_options"
41
+ require_relative "infinite_context/compactor"
42
+ require_relative "infinite_context/pump"
43
+ require_relative "infinite_context/cache_mark"
44
+ require_relative "infinite_context/zoom"
45
+ require_relative "infinite_context/tools"
46
+ require_relative "infinite_context/batch_import"
47
+ require_relative "infinite_context/manager"
48
+
49
+ begin
50
+ require "rails"
51
+ require_relative "infinite_context/models"
52
+ require_relative "infinite_context/pump_job"
53
+ require_relative "infinite_context/model"
54
+ require_relative "infinite_context/engine"
55
+ rescue LoadError
56
+ # Not a Rails app: the Active Record models load lazily on first use.
57
+ end