samagotchi 0.38.0 → 0.40.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +58 -2
  5. data/docs/hooks.md +1 -0
  6. data/docs/internals/background-tasks.md +1 -1
  7. data/docs/internals/context-telemetry.md +16 -0
  8. data/docs/internals/llm-context-bench.md +133 -0
  9. data/docs/internals/llm-context-forget.md +45 -0
  10. data/docs/internals/prompt-caching.md +3 -2
  11. data/docs/plugins.md +4 -3
  12. data/docs/testing.md +8 -0
  13. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  15. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -10
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +4 -0
  19. data/lib/samagotchi/config.rb +43 -4
  20. data/lib/samagotchi/context_note.rb +7 -3
  21. data/lib/samagotchi/context_status.rb +112 -11
  22. data/lib/samagotchi/engine.rb +36 -9
  23. data/lib/samagotchi/guardrails/targets.rb +15 -3
  24. data/lib/samagotchi/host_registry.rb +8 -3
  25. data/lib/samagotchi/kernel_loop.rb +142 -14
  26. data/lib/samagotchi/llm/chat_loop.rb +57 -18
  27. data/lib/samagotchi/llm/turn_settings.rb +5 -4
  28. data/lib/samagotchi/llm_context_apply.rb +165 -0
  29. data/lib/samagotchi/llm_context_edit.rb +102 -0
  30. data/lib/samagotchi/llm_context_forget.rb +258 -0
  31. data/lib/samagotchi/llm_context_stale.rb +271 -0
  32. data/lib/samagotchi/llm_context_strategy.rb +177 -0
  33. data/lib/samagotchi/llm_context_view.rb +201 -0
  34. data/lib/samagotchi/log_subscriber.rb +2 -1
  35. data/lib/samagotchi/prompt.rb +4 -11
  36. data/lib/samagotchi/session_manager.rb +9 -1
  37. data/lib/samagotchi/session_metrics.rb +18 -1
  38. data/lib/samagotchi/system_prompt.rb +20 -15
  39. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  40. data/lib/samagotchi/token_usage.rb +3 -0
  41. data/lib/samagotchi/tool_activity.rb +18 -0
  42. data/lib/samagotchi/tool_declarations.rb +60 -4
  43. data/lib/samagotchi/tool_ids.rb +84 -0
  44. data/lib/samagotchi/tool_response.rb +67 -6
  45. data/lib/samagotchi/tools/builtin_calls.rb +3 -2
  46. data/lib/samagotchi/tools/builtins.rb +15 -5
  47. data/lib/samagotchi/tools/context_read.rb +27 -10
  48. data/lib/samagotchi/tools/forget_outputs.rb +62 -0
  49. data/lib/samagotchi/tools/registry.rb +25 -8
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/public/app.js +1 -1
  52. data/lib/samagotchi/worker.rb +4 -0
  53. metadata +11 -1
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # One edit of what the model is sent (LLMContextView): a tool output,
5
+ # named by its id (ToolIds), sent as a stub instead of its text. Saved
6
+ # on the tool_response entry it changes, under +edits+ (plan D3), keyed
7
+ # by the id, as plain JSON: {"t42" => {"kind" => "stale", "note" => …,
8
+ # "by" => …, "staged_at" => …, "applied_at" => …}}, so it goes wherever
9
+ # the entry goes and a save and load keeps it as written.
10
+ #
11
+ # Copies of an entry share its edits Hash: a rollback checkpoint
12
+ # (Engine#clone_messages), the kernel's duplicate_conversation and a
13
+ # plugin's sessions.fork copy each entry's top level only. So the edits
14
+ # are replaced, never changed in place (.store), or an edit would reach
15
+ # a checkpoint it was rolled back from.
16
+ #
17
+ # kind: :stale (chi saw a later read or edit supersede it) or :forget
18
+ # (the model forgot it, its note keeping the finding); note: what the
19
+ # stub says; by: who made it ("chi", the model); staged_at/applied_at:
20
+ # ISO 8601 times, applied_at nil while it waits to reach the prompt;
21
+ # keep: the output's lines a forget keeps ([[first, last], …], as the
22
+ # model named them; saved only when there are some), sent under the
23
+ # stub; keep_offset: what those numbers are past the output's own lines
24
+ # (a ranged read's start_line - 1: a read's keep names file lines; saved
25
+ # only when not 0).
26
+ LLMContextEdit = Data.define(:id, :kind, :note, :by, :staged_at, :applied_at, :keep, :keep_offset) do
27
+ def initialize(keep: [], keep_offset: 0, **fields) = super
28
+
29
+ def applied? = !applied_at.nil?
30
+
31
+ def keep? = !keep.empty?
32
+
33
+ # What the model reads instead of the output, after the output's
34
+ # "[name]" lead.
35
+ def stub
36
+ kind == :forget ? "(forgotten) #{note}" : note.to_s
37
+ end
38
+
39
+ # The saved form, without the id (it is the key).
40
+ def to_h
41
+ saved = { "kind" => kind.to_s, "note" => note, "by" => by, "staged_at" => staged_at, "applied_at" => applied_at }
42
+ saved = saved.merge("keep" => keep) if keep?
43
+ keep_offset.zero? ? saved : saved.merge("keep_offset" => keep_offset)
44
+ end
45
+ end
46
+
47
+ class LLMContextEdit
48
+ KINDS = %i[stale forget].freeze
49
+
50
+ # +hash+ as saved (string or symbol keys), or nil for one of no known
51
+ # kind.
52
+ def self.from_h(id, hash)
53
+ return nil unless hash.is_a?(Hash)
54
+
55
+ field = ->(key) { hash.key?(key.to_s) ? hash[key.to_s] : hash[key] }
56
+ kind = field.call(:kind).to_s.to_sym
57
+ return nil unless KINDS.include?(kind)
58
+
59
+ new(id: id.to_s, kind: kind, note: field.call(:note).to_s, by: field.call(:by), staged_at: field.call(:staged_at),
60
+ applied_at: field.call(:applied_at), keep: ranges(field.call(:keep)),
61
+ keep_offset: field.call(:keep_offset).is_a?(Integer) ? [field.call(:keep_offset), 0].max : 0)
62
+ end
63
+
64
+ # Saved keep ranges as [[first, last], …]: pairs of positive Integers,
65
+ # first <= last; anything else is dropped.
66
+ def self.ranges(raw)
67
+ Array(raw).filter_map do |pair|
68
+ first, last = pair if pair.is_a?(Array) && pair.size == 2
69
+ [first, last] if first.is_a?(Integer) && last.is_a?(Integer) && first.positive? && first <= last
70
+ end
71
+ end
72
+
73
+ # +entry+ without the edit +id+ (a restore): a new edits Hash, as
74
+ # .store; no edits key when none is left.
75
+ def self.remove(entry, id)
76
+ return entry unless entry[:edits].is_a?(Hash) && entry[:edits].key?(id)
77
+
78
+ left = entry[:edits].except(id)
79
+ left.empty? ? entry.delete(:edits) : entry[:edits] = left
80
+ entry
81
+ end
82
+
83
+ # +entry+ with +edit+ saved on it: a new edits Hash, never the old one
84
+ # changed (copies of the entry share that one).
85
+ def self.store(entry, edit)
86
+ entry[:edits] = (entry[:edits].is_a?(Hash) ? entry[:edits] : {}).merge(edit.id => edit.to_h)
87
+ entry
88
+ end
89
+
90
+ # The edits saved on +entry+, by id.
91
+ # @return [Hash{String => LLMContextEdit}]
92
+ def self.on(entry)
93
+ saved = entry[:edits]
94
+ return {} unless saved.is_a?(Hash)
95
+
96
+ saved.each_with_object({}) do |(id, hash), edits|
97
+ edit = from_h(id, hash)
98
+ edits[edit.id] = edit if edit
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "tool_response"
5
+ require_relative "tool_ids"
6
+ require_relative "token_usage"
7
+ require_relative "llm_context_edit"
8
+ require_relative "llm_context_stale"
9
+ require_relative "llm_context_apply"
10
+ require_relative "llm_context_view"
11
+ require_relative "tools/forget_outputs"
12
+
13
+ module Samagotchi
14
+ # The forget layer (LLMContextStrategy :forget, experimental): the
15
+ # model's forget_outputs call on the running turn's conversation. A
16
+ # forget saves an LLMContextEdit of kind :forget on each output's entry
17
+ # (LLMContextEdit.store), staged (no applied_at); the apply rule
18
+ # (LLMContextApply, run at once by +apply+) then sends the batch at the
19
+ # next request or holds it for the turn's end, and the result says which.
20
+ # A restore takes a forget off (staged or applied); it goes out with the
21
+ # next request.
22
+ #
23
+ # Only the model's own tool outputs, named by their stored ids
24
+ # (ToolIds): never the system prompt, a user message, a steer or a
25
+ # context note (they have no id), never a legacy entry's derived id, and
26
+ # the call stays with its result (only the result's text is stubbed). Per
27
+ # id, refused with a reason:
28
+ # - an output from the last +protect_steps+ steps (the model may still
29
+ # be working with it);
30
+ # - a read of a file an edit or write touched in those steps
31
+ # (LLMContextStale.protected_ids), unless the forget keeps some of its
32
+ # lines (keep): wiping what the model edits against makes it redo the
33
+ # read (CLM's lesson);
34
+ # - a keep that holds all of the output, an output too small for a stub
35
+ # to free anything (SMALL), an output already stubbed or forgotten, an
36
+ # id nothing in context has.
37
+ # A read is never restored: reading the file again is its restore, and
38
+ # the file may have changed.
39
+ module LLMContextForget
40
+ BY = "model"
41
+ READ = "read"
42
+ # An output this short (its text after the lead, in chars) frees
43
+ # nothing as a stub ("(forgotten with t41: see its note) [restore: t42]").
44
+ SMALL = 80
45
+
46
+ # The turn a forget_outputs call runs in: its conversation (the
47
+ # entries the edits are saved on) and its ContextStatus (nil: none).
48
+ Turn = Data.define(:conversation, :context)
49
+
50
+ # One output a call may name: its entry, its ToolIds ref, its tool,
51
+ # its text (without the "[name]" lead) and its step; +offset+: what
52
+ # turns a line of its text into the line a keep names (a read's file
53
+ # lines: start_line - 1), +preview+: a read that came back as a big
54
+ # file's head/tail preview (no file lines to keep).
55
+ Output = Data.define(:index, :ref, :name, :body, :step, :offset, :preview) do
56
+ def read? = name == READ
57
+
58
+ # The lines a keep may name: [first, last].
59
+ def lines = [offset + 1, offset + body.lines.size]
60
+ end
61
+
62
+ module_function
63
+
64
+ # Runs a forget_outputs +call+ on +turn+, under the turn's
65
+ # +llm_context+ (LLMContextStrategy::Resolved: its layers and
66
+ # protect_steps). The conversation's last step is the one holding the
67
+ # call (both loops add the model's entry before its calls run).
68
+ # @param apply [#call] → LLMContextApply::Outcome: the apply rule at a
69
+ # request, on the turn's conversation (KernelLoop#apply_llm_context!)
70
+ # @return [String] the tool result
71
+ def call(turn, call, llm_context:, apply:, root: Dir.pwd, now: Time.now.utc.iso8601(3))
72
+ request = Tools::ForgetOutputs.parse(call)
73
+ conversation = turn.conversation
74
+ return restore(turn, request.restore, llm_context) if request.restore? && request.ids.empty?
75
+ return "Error: forget_outputs needs ids to forget (or restore: ids to bring back)" if request.ids.empty?
76
+ if request.note.empty?
77
+ return "Error: forget_outputs needs a note: what you learned from these outputs (facts, file:line, " \
78
+ "what's ruled out, NEXT), since it is all that stays of them"
79
+ end
80
+
81
+ forgotten, refused = forget(conversation, request, llm_context.protect_steps, root, now)
82
+ lines = if forgotten.empty?
83
+ ["nothing forgotten"]
84
+ else
85
+ [applied_line(conversation, forgotten, apply.call, llm_context.active_layers)]
86
+ end
87
+ lines << restore(turn, request.restore, llm_context) if request.restore?
88
+ lines.concat(refused_lines(refused))
89
+ lines.join("\n")
90
+ end
91
+
92
+ # Saves a staged forget on each output +request+ names that may go.
93
+ # @return [Array(Array<String>, Hash{String => String})] the ids
94
+ # forgotten, and the refused ones with why
95
+ def forget(conversation, request, protect_steps, root, now)
96
+ outputs = outputs(conversation)
97
+ last = LLMContextStale.step_count(conversation)
98
+ # The steps before the call's own: protected_ids counts that one too.
99
+ kept_reads = LLMContextStale.protected_ids(conversation, steps: protect_steps.positive? ? protect_steps + 1 : 0,
100
+ root: root)
101
+ refused = {}
102
+ forgotten = request.ids.select do |id|
103
+ why = refusal(conversation, outputs[id], request.keep[id], last, protect_steps, kept_reads)
104
+ refused[id] = why if why
105
+ why.nil?
106
+ end
107
+ forgotten.each do |id|
108
+ output = outputs[id]
109
+ keep = request.keep.fetch(id, [])
110
+ edit = LLMContextEdit.new(id: id, kind: :forget, note: request.note, by: BY, staged_at: now, applied_at: nil,
111
+ keep: keep, keep_offset: keep.empty? ? 0 : output.offset)
112
+ LLMContextEdit.store(conversation[output.index], edit)
113
+ end
114
+ [forgotten, refused]
115
+ end
116
+
117
+ # Why +output+ can't go (a String for the result), nil when it can.
118
+ # +last+: the call's own step; the protect_steps steps before it are
119
+ # the model's latest outputs.
120
+ def refusal(conversation, output, keep, last, protect_steps, kept_reads)
121
+ return "no output with that id in context (ids look like t42)" unless output
122
+ return "already forgotten or stubbed" if LLMContextEdit.on(conversation[output.index]).key?(output.ref.id)
123
+ if protect_steps.positive? && output.step >= last - protect_steps
124
+ return "from your last #{protect_steps} step#{"s" unless protect_steps == 1}; you may still need it, forget it later"
125
+ end
126
+ return keep_refusal(output, keep) if keep
127
+
128
+ if kept_reads.include?(output.ref.id)
129
+ first, = output.lines
130
+ return "a read of a file you edited in your last #{protect_steps} steps; keep the lines you edit against " \
131
+ "(keep: [\"#{output.ref.id}:#{first}-#{first + 20}\"]) or forget it later"
132
+ end
133
+ return "too small: its stub would free nothing" if output.body.length <= SMALL
134
+
135
+ nil
136
+ end
137
+
138
+ # Why +keep+ can't be kept of +output+, nil when it can: a preview has
139
+ # no file lines, a range must fall in the output's lines, and some of
140
+ # it must go.
141
+ def keep_refusal(output, keep)
142
+ return "a preview of a big file: keep needs its lines; read a range of it instead" if output.preview
143
+
144
+ first, last = output.lines
145
+ outside = keep.reject { |from, to| from <= last && to >= first }
146
+ unless outside.empty?
147
+ shown = outside.map { |from, to| "#{from}-#{to}" }.join(", ")
148
+ return "keep #{shown} is outside its lines (#{first}-#{last}#{", the file's" if output.read?})"
149
+ end
150
+ kept = keep.flat_map { |from, to| ([from, first].max..[to, last].min).to_a }.uniq.size
151
+ return "keep holds all of it; nothing to forget" if kept >= last - first + 1
152
+
153
+ nil
154
+ end
155
+
156
+ # Takes the forgets of +ids+ off: any that hasn't reached the prompt
157
+ # yet, and one that has unless it is a read's (reading the file again
158
+ # is its restore). The next request sends them whole, whatever the
159
+ # apply rule (the model wants the text now), so the context estimate
160
+ # starts over (ContextStatus#edited!).
161
+ # @return [String] the result's line(s)
162
+ def restore(turn, ids, llm_context)
163
+ conversation = turn.conversation
164
+ outputs = outputs(conversation)
165
+ restored = []
166
+ refused = {}
167
+ sent = false
168
+ ids.each do |id|
169
+ output = outputs[id]
170
+ edit = output && LLMContextEdit.on(conversation[output.index])[id]
171
+ if edit.nil? || edit.kind != :forget
172
+ refused[id] = "not forgotten"
173
+ elsif output.read? && edit.applied?
174
+ refused[id] = "a read: read the file again instead (it may have changed)"
175
+ else
176
+ LLMContextEdit.remove(conversation[output.index], id)
177
+ restored << output
178
+ sent ||= edit.applied?
179
+ end
180
+ end
181
+ lines = restored.empty? ? [] : [restored_line(turn, restored, sent, llm_context)]
182
+ (lines + refused_lines(refused)).join("\n")
183
+ end
184
+
185
+ def restored_line(turn, restored, sent, llm_context)
186
+ ids = restored.map { |output| output.ref.id }.join(", ")
187
+ return "restored #{ids}: its stub was never sent" unless sent
188
+
189
+ turn.context&.edited!
190
+ view = LLMContextView.new(strategy: llm_context.active_layers)
191
+ tail = LLMContextView.chars(view.messages(turn.conversation)[restored.map(&:index).min..])
192
+ "restored #{ids}: the next request sends it whole again, now whatever llm_context.apply says (the server " \
193
+ "reads the #{k(tail)} from it on again)"
194
+ end
195
+
196
+ # Every output in +conversation+ a stored id names and that pairs with
197
+ # its call for sure (LLMContextStale.paired_runs), by id.
198
+ # @return [Hash{String => Output}]
199
+ def outputs(conversation)
200
+ LLMContextStale.paired_runs(conversation).each_with_object({}) do |run, found|
201
+ next if run.ref.derived?
202
+
203
+ name = run.call[:name].to_s
204
+ body = run.text.sub(ToolResponse::LEAD, "")
205
+ found[run.ref.id] = Output.new(index: run.index, ref: run.ref, name: name, body: body, step: run.step,
206
+ offset: name == READ ? read_offset(run.call) : 0,
207
+ preview: name == READ && LLMContextStale.partial?(body))
208
+ end
209
+ end
210
+
211
+ # A read's first line, less one (start_line; 1 without one).
212
+ def read_offset(call)
213
+ first = Integer(call[:start_line].to_s.strip, 10)
214
+ first.positive? ? first - 1 : 0
215
+ rescue ArgumentError, TypeError
216
+ 0
217
+ end
218
+
219
+ # What the apply rule did with the batch the forgotten ids are in.
220
+ def applied_line(conversation, forgotten, outcome, layers)
221
+ ids = forgotten.join(", ")
222
+ applied = outcome.applied.map(&:id)
223
+ if forgotten.all? { |id| applied.include?(id) }
224
+ return "forgot #{ids}: applied, the next request sends the stubs (#{frees(outcome.freed_chars)}; the " \
225
+ "server reads the #{k(outcome.tail_chars)} after them again)"
226
+ end
227
+
228
+ freed, = LLMContextApply.weigh(conversation, layers: layers, edits: staged(conversation))
229
+ "forgot #{ids}: staged until the turn ends (the staged stubs: #{frees(freed)} then)"
230
+ end
231
+
232
+ # The edits saved unapplied, as [entry index, edit] pairs.
233
+ def staged(conversation)
234
+ conversation.each_with_index.flat_map do |entry, index|
235
+ next [] unless entry[:role].to_s == "tool_response"
236
+
237
+ LLMContextEdit.on(entry).values.reject(&:applied?).map { |edit| [index, edit] }
238
+ end
239
+ end
240
+
241
+ def refused_lines(refused)
242
+ return [] if refused.empty?
243
+
244
+ ["not done:"] + refused.map { |id, why| "- #{id}: #{why}" }
245
+ end
246
+
247
+ def frees(chars) = chars.positive? ? "frees #{k(chars)}" : "frees nothing, the stubs are as long"
248
+
249
+ # "12.4k tokens", chars/4.
250
+ def k(chars)
251
+ tokens = (chars / TokenUsage::CHARS_PER_TOKEN).ceil
252
+ tokens >= 1000 ? format("%.1fk tokens", tokens / 1000.0) : "#{tokens} tokens"
253
+ end
254
+
255
+ private_class_method :forget, :refusal, :keep_refusal, :restored_line, :read_offset, :applied_line, :staged,
256
+ :refused_lines, :frees, :k
257
+ end
258
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "tool_response"
5
+ require_relative "tool_ids"
6
+ require_relative "llm_context_edit"
7
+ require_relative "model_profile"
8
+ require_relative "tool_call_parser"
9
+ require_relative "tools/builtin_calls"
10
+ require_relative "tools/tool_path"
11
+
12
+ module Samagotchi
13
+ # The stale layer (LLMContextStrategy :stale): a file read that a later
14
+ # read superseded is sent as a stub, "[read] lib/x.rb lines 1-200:
15
+ # superseded by a later read". Deterministic: no model call, no note of
16
+ # the model's. Superseded means a later read of the same file covering
17
+ # the read's lines, or (#found's +changes+) a successful edit or write of
18
+ # the file: "superseded by a later edit". A run that failed (its output
19
+ # an "Error") or holds only part of what it asked for supersedes nothing.
20
+ # Edit-driven stubs are opt-in (llm_context.stale_edits); when one may be
21
+ # sent is LLMContextApply's rule (the user, 2026-10-07: never mid-edit),
22
+ # and no read of a file the last steps edited is stubbed
23
+ # (#protected_ids). Shell reads (cat, sed -n) don't count.
24
+ #
25
+ # Nothing per run says what a built-in call read, so the calls are worked
26
+ # out from the model entries, in both formats: a chat entry's tool_calls,
27
+ # paired with each result by tool_call_id; a native entry's call markup
28
+ # (Qwen's <tool_call>, Gemma's <|tool_call>, whichever the entry holds: a
29
+ # session may have switched models), paired in order with the runs of the
30
+ # joined result after it. A result whose runs don't open with their calls'
31
+ # "[name]" leads (ToolResponse.runs_named; a corrected call's "ran as:"
32
+ # line) is left alone.
33
+ #
34
+ # A stub names its run by its ToolIds id (ToolIds.refs_at: stored, or
35
+ # derived for a legacy entry); LLMContextApply saves it on the entry
36
+ # (LLMContextEdit.store) when its rule lets it through. A run with any
37
+ # edit already keeps it.
38
+ module LLMContextStale
39
+ BY = "chi"
40
+ READ = "read"
41
+ CHANGES = %w[edit write].freeze
42
+ WHOLE = (1..Float::INFINITY)
43
+
44
+ # A file call's run: the entry it sits in (+index+) and its ToolIds
45
+ # ref, the call's tool, the path as the model wrote it (+shown+) and
46
+ # expanded, the lines a read asked for (WHOLE for all), whether its
47
+ # output is an error, whether it holds only part of what was asked
48
+ # (#partial?), the output's length, and its step (1 for the
49
+ # conversation's first model entry with calls, one more per such
50
+ # entry).
51
+ FileRun = Data.define(:index, :ref, :name, :shown, :path, :lines, :failed, :partial, :size, :step) do
52
+ def read? = name == READ
53
+
54
+ # +other+, an earlier read of the same file, is stale after this
55
+ # run: a read covering its lines, or (+changes+) an edit or write.
56
+ def supersedes?(other, changes: false)
57
+ return false if failed || partial || path != other.path
58
+ return changes unless read?
59
+
60
+ lines.begin <= other.lines.begin && lines.end >= other.lines.end
61
+ end
62
+ end
63
+
64
+ # A read to stub (#found). #change? when only an edit or write
65
+ # superseded it (+changes+), no later read.
66
+ Found = Data.define(:run, :by, :note) do
67
+ def change? = !by.read?
68
+ end
69
+
70
+ module_function
71
+
72
+ # The reads to stub that have no edit yet: each read, the first later
73
+ # run that superseded it (+by+; the replay benchmark applies the edit
74
+ # at the request after it, as chi does) and the stub's note. A stub
75
+ # that wouldn't be shorter than the output is left out. +changes+: a
76
+ # successful edit or write of the file supersedes a read too, when no
77
+ # later read does (a later read is +by+ whenever there is one, so
78
+ # Found#change? holds only for a read nothing but a change superseded).
79
+ # Whether such a stub may be sent yet is LLMContextApply's call (the
80
+ # user, 2026-10-07: right after an edit the model is usually still
81
+ # editing against the read): at turn end, or when it pays off, and
82
+ # never the read #protected_ids names.
83
+ # @return [Array<Found>]
84
+ def found(conversation, root: Dir.pwd, changes: false)
85
+ runs = file_runs(conversation, root)
86
+ runs.each_with_index.filter_map do |run, at|
87
+ next unless run.read?
88
+ next if LLMContextEdit.on(conversation[run.index]).key?(run.ref.id)
89
+
90
+ later = runs[(at + 1)..]
91
+ by = later.find { |other| other.read? && other.supersedes?(run) } ||
92
+ (changes && later.find { |other| other.supersedes?(run, changes: true) })
93
+ next unless by
94
+
95
+ note = note(run, by)
96
+ Found.new(run: run, by: by, note: note) if run.size > "[#{run.name}] #{note}".length
97
+ end
98
+ end
99
+
100
+ # The ids of the reads stale never stubs: every read of each file an
101
+ # edit or write (successful or not: a failed one is retried) touched in
102
+ # the conversation's last +steps+ steps (protect_steps; 0: none). The
103
+ # model is likely still editing against them, and any of them may hold
104
+ # the lines it edits (an earlier range, or the only whole copy when a
105
+ # later read came back cut). Steps count across the conversation.
106
+ # @return [Set<String>]
107
+ def protected_ids(conversation, steps:, root: Dir.pwd)
108
+ return Set.new unless steps.positive?
109
+
110
+ runs = file_runs(conversation, root)
111
+ last = step_count(conversation)
112
+ edited = runs.select { |run| !run.read? && run.step > last - steps }.to_set(&:path)
113
+ runs.select { |run| run.read? && edited.include?(run.path) }.to_set { |run| run.ref.id }
114
+ end
115
+
116
+ # The conversation's steps: its model entries with calls.
117
+ def step_count(conversation)
118
+ conversation.count { |entry| entry[:role].to_s == "model" && !calls(entry).empty? }
119
+ end
120
+
121
+ # What the stub says after the "[read]" lead.
122
+ def note(run, by)
123
+ lines = if run.lines == WHOLE
124
+ ""
125
+ else
126
+ " lines #{run.lines.begin}-#{run.lines.end.infinite? ? "end" : run.lines.end}"
127
+ end
128
+ "#{run.shown}#{lines}: superseded by a later #{by.name}"
129
+ end
130
+
131
+ # One run of a tool_response entry paired with its call for sure
132
+ # (#results: by tool_call_id, or the batch's "[name]" leads in order):
133
+ # the entry's index, its ToolIds ref, the call, its text and its step.
134
+ PairedRun = Data.define(:index, :ref, :call, :text, :step)
135
+
136
+ # Every run of +conversation+ that pairs with its call, in order. A
137
+ # result whose runs don't open with their calls' leads (a "ran as:"
138
+ # line, an output holding the SEPARATOR and a lead) has none: what
139
+ # names a run (forget_outputs, the view's ids) leaves it alone.
140
+ # @return [Array<PairedRun>]
141
+ def paired_runs(conversation)
142
+ batch = []
143
+ step = 0
144
+ conversation.each_with_index.with_object([]) do |(entry, index), runs|
145
+ case entry[:role].to_s
146
+ when "model"
147
+ batch = calls(entry)
148
+ step += 1 unless batch.empty?
149
+ when "tool_response"
150
+ results(conversation, index, batch).each do |ref, call, text|
151
+ runs << PairedRun.new(index: index, ref: ref, call: call, text: text, step: step)
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ # The read, edit and write runs of +conversation+, in order.
158
+ # @return [Array<FileRun>]
159
+ def file_runs(conversation, root)
160
+ paired_runs(conversation).filter_map do |run|
161
+ file_run(run.index, run.ref, run.call, run.text, root, run.step)
162
+ end
163
+ end
164
+
165
+ # A model entry's calls, each [tool_call_id (nil for a native one), the
166
+ # call as dispatched (Tools::BuiltinCalls.build)].
167
+ def calls(entry)
168
+ if entry[:tool_calls].is_a?(Array) && !entry[:tool_calls].empty?
169
+ return entry[:tool_calls].map do |call|
170
+ [field(call, :id), Tools::BuiltinCalls.build(field(call, :name).to_s, arguments(field(call, :arguments)))]
171
+ end
172
+ end
173
+
174
+ content = entry[:content].to_s
175
+ parser = native_parser(content)
176
+ return [] unless parser
177
+
178
+ parser.read(parser.strip_thought(content)).map do |call|
179
+ [nil, Tools::BuiltinCalls.build(call[:name], call[:args], raw: call[:raw])]
180
+ end
181
+ end
182
+
183
+ # A tool_response entry's runs, each [its ref, its call, its text]; none
184
+ # when they can't be paired for sure.
185
+ def results(conversation, index, batch)
186
+ entry = conversation[index]
187
+ refs = ToolIds.refs_at(conversation, index)
188
+ if entry[:tool_call_id]
189
+ # Paired by an id its batch holds once: a provider may send an
190
+ # empty or repeated id, and a wrong pairing names the wrong file.
191
+ id = entry[:tool_call_id].to_s
192
+ matches = id.empty? ? [] : batch.select { |call_id, _| call_id.to_s == id }
193
+ call = matches.one? ? matches.first.last : nil
194
+ texts = call && refs.size == 1 ? ToolResponse.runs_named(entry[:content], [call[:name]]) : nil
195
+ return texts ? [[refs.first, call, texts.first.text]] : []
196
+ end
197
+
198
+ calls = batch.map(&:last)
199
+ texts = ToolResponse.runs_named(entry[:content], calls.map { |call| call[:name] })
200
+ return [] unless texts && texts.size == refs.size
201
+
202
+ refs.zip(calls, texts.map(&:text))
203
+ end
204
+
205
+ def file_run(index, ref, call, text, root, step)
206
+ name = call[:name].to_s
207
+ return nil unless name == READ || CHANGES.include?(name)
208
+
209
+ shown = (name == READ ? call[:content] : call[:path]).to_s.strip
210
+ return nil if shown.empty?
211
+
212
+ body = text.sub(ToolResponse::LEAD, "")
213
+ FileRun.new(index: index, ref: ref, name: name, shown: shown,
214
+ path: File.expand_path(Tools::ToolPath.normalize(shown), root),
215
+ lines: name == READ ? lines(call) : WHOLE, failed: body.lstrip.start_with?("Error"),
216
+ partial: partial?(body), size: text.length, step: step)
217
+ end
218
+
219
+ # The read tool's head/tail preview of a big file (Tools::Read) or an
220
+ # output ToolRunner cut at max_tool_output_chars: it doesn't hold all
221
+ # the lines asked for, so it supersedes nothing.
222
+ PREVIEW = "[TRUNCATED_PREVIEW_HEAD]"
223
+ CUT = /\[cut: \d+ of \d+ chars; read it in parts\]\z/
224
+
225
+ def partial?(body)
226
+ body.start_with?("truncated=true") || body.include?(PREVIEW) || body.match?(CUT)
227
+ end
228
+
229
+ # The lines a read asked for: from start_line (1) to end_line (the end).
230
+ def lines(call)
231
+ first = line(call[:start_line]) || 1
232
+ last = line(call[:end_line]) || Float::INFINITY
233
+ first..last
234
+ end
235
+
236
+ def line(value)
237
+ number = value.is_a?(Integer) ? value : Integer(value.to_s.strip, 10)
238
+ number.positive? ? number : nil
239
+ rescue ArgumentError, TypeError
240
+ nil
241
+ end
242
+
243
+ def field(hash, key)
244
+ return nil unless hash.is_a?(Hash)
245
+
246
+ hash.key?(key) ? hash[key] : hash[key.to_s]
247
+ end
248
+
249
+ def arguments(value)
250
+ return value if value.is_a?(Hash)
251
+
252
+ parsed = JSON.parse(value.to_s)
253
+ parsed.is_a?(Hash) ? parsed : {}
254
+ rescue JSON::ParserError
255
+ {}
256
+ end
257
+
258
+ QWEN_CALL = "<tool_call>"
259
+ GEMMA_CALL = "<|tool_call>"
260
+
261
+ def native_parser(content)
262
+ if content.include?(GEMMA_CALL)
263
+ @gemma ||= ToolCallParser::Gemma.new(ModelProfile.gemma4)
264
+ elsif content.include?(QWEN_CALL)
265
+ @qwen ||= ToolCallParser::Qwen.new(ModelProfile.qwen36)
266
+ end
267
+ end
268
+
269
+ private_class_method :file_runs, :results, :file_run, :lines, :line, :field, :arguments, :native_parser
270
+ end
271
+ end