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
|
@@ -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,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
|