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,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Which LLM context strategy a turn runs under (LLMContextView): none,
5
+ # the default, or a list of layers (stale, forget). Set by
6
+ # llm_context.strategy, and per model or host by the flat key
7
+ # llm_context_strategy (models.<key>, hosts.<name>), each a layer name,
8
+ # "|"-separated names or a YAML list.
9
+ #
10
+ # Resolution, first set wins: the session's own (a later per-session
11
+ # override; nothing sets it yet), models.<key> (by the model's lookup
12
+ # names, as window_tokens), hosts.<name>, then llm_context.strategy.
13
+ # An unknown layer warns and is none (every layer is built: stale, and
14
+ # forget, experimental, with its tool forget_outputs).
15
+ #
16
+ # When a layer's edits reach the prompt (LLMContextApply) is set by
17
+ # llm_context.apply, and per model or host by the flat key
18
+ # llm_context_apply: payoff (the default), next_request or turn_end,
19
+ # resolved as the strategy is (the session's own, the model's, the
20
+ # host's, then llm_context.apply), each layer on its own: a model may
21
+ # set the strategy and its host the apply rule. An unknown value warns
22
+ # and is payoff. llm_context.protect_steps (3) and llm_context.stale_edits
23
+ # (false: edit-driven stale stubs are opt-in, experimental) are global only.
24
+ #
25
+ # A soft context budget, llm_context.budget_tokens, and per model or host
26
+ # the flat key llm_context_budget_tokens (a positive number of tokens),
27
+ # resolved as the apply rule is, each on its own: off (nil) by default.
28
+ # When set, ContextStatus counts its bands against it instead of the
29
+ # window (the smaller of the two), so the forget layer's offers come
30
+ # under it.
31
+ module LLMContextStrategy
32
+ SETTING = "llm_context.strategy"
33
+ KEY = "llm_context_strategy"
34
+ NONE = :none
35
+ LAYERS = %i[stale forget].freeze
36
+ # The layers a turn can run under so far.
37
+ BUILT = %i[stale forget].freeze
38
+ # The sentence forget_outputs' description carries (global only):
39
+ # the plan's D7 line, adapted from CLM's steering example.
40
+ POLICY_SETTING = "llm_context.policy"
41
+ DEFAULT_POLICY = "Tidy at subtask boundaries: once a subtask is done, forget its tool outputs and note what it " \
42
+ "established; keep anything you'll still edit against."
43
+
44
+ APPLY_SETTING = "llm_context.apply"
45
+ APPLY_KEY = "llm_context_apply"
46
+ APPLIES = %i[payoff next_request turn_end].freeze
47
+ DEFAULT_APPLY = :payoff
48
+ PROTECT_SETTING = "llm_context.protect_steps"
49
+ DEFAULT_PROTECT_STEPS = 3
50
+ STALE_EDITS_SETTING = "llm_context.stale_edits"
51
+ BUDGET_SETTING = "llm_context.budget_tokens"
52
+ BUDGET_KEY = "llm_context_budget_tokens"
53
+
54
+ # A turn's strategy: the layers as configured, the strategy the view
55
+ # gets (NONE, or the layers), and where it was set (:session,
56
+ # :model_setting, :host_setting, :config); the apply rule
57
+ # (LLMContextApply), the steps whose edited files' reads are kept
58
+ # (protect_steps), whether an edit or write makes a read stale
59
+ # (stale_edits; off: later reads only), and the context budget in
60
+ # tokens (budget_tokens; nil: off).
61
+ Resolved = Data.define(:layers, :strategy, :source, :apply, :protect_steps, :stale_edits, :budget_tokens) do
62
+ def initialize(apply: DEFAULT_APPLY, protect_steps: DEFAULT_PROTECT_STEPS, stale_edits: false, budget_tokens: nil,
63
+ **fields) = super
64
+
65
+ def forget? = active_layers.include?(:forget)
66
+
67
+ # The layers the turn runs under: none of them under none (an
68
+ # unbuilt or unknown layer's strategy too).
69
+ def active_layers = strategy == NONE ? [] : Array(strategy)
70
+ end
71
+
72
+ module_function
73
+
74
+ # +raw+ as written (a String, "|"-separated, or a YAML list) as layers;
75
+ # [] for none, nil for unset. An unknown name warns, naming +where+,
76
+ # and is none.
77
+ # @return [Array<Symbol>, nil]
78
+ def parse(raw, where)
79
+ return nil if raw.nil?
80
+
81
+ names = (raw.is_a?(Array) ? raw : raw.to_s.split("|")).map { |name| name.to_s.strip.downcase }.reject(&:empty?)
82
+ return [] if names.empty? || names == [NONE.to_s]
83
+
84
+ unknown = names - LAYERS.map(&:to_s)
85
+ unless unknown.empty?
86
+ warn_once "Warning: #{where}: unknown llm_context strategy #{unknown.join(", ")} (none, or a list of " \
87
+ "#{LAYERS.join(", ")}); using none"
88
+ return []
89
+ end
90
+ names.uniq.map(&:to_sym)
91
+ end
92
+
93
+ # +raw+ as written, an apply rule; nil for unset. An unknown one warns,
94
+ # naming +where+, and is payoff.
95
+ # @return [Symbol, nil]
96
+ def parse_apply(raw, where)
97
+ return nil if raw.nil? || raw.to_s.strip.empty?
98
+
99
+ rule = raw.to_s.strip.downcase.to_sym
100
+ return rule if APPLIES.include?(rule)
101
+
102
+ warn_once "Warning: #{where}: unknown llm_context apply #{raw} (#{APPLIES.join(", ")}); using #{DEFAULT_APPLY}"
103
+ DEFAULT_APPLY
104
+ end
105
+
106
+ # +raw+ as written, a context budget in tokens; nil for unset or 0
107
+ # (off). Anything but a number of tokens warns, naming +where+, and is
108
+ # unset.
109
+ # @return [Integer, nil]
110
+ def parse_budget(raw, where)
111
+ return nil if raw.nil? || raw.to_s.strip.empty?
112
+
113
+ tokens = Integer(raw.to_s.strip, exception: false)
114
+ return nil if tokens&.zero?
115
+ return tokens if tokens&.positive?
116
+
117
+ warn_once "Warning: #{where}: llm_context budget_tokens must be a positive number of tokens; ignored"
118
+ nil
119
+ end
120
+
121
+ # The strategy for +target+'s turn.
122
+ # @param target [HostRegistry::ModelTarget, nil]
123
+ # @param names [Array<String>] HostRegistry#lookup_names
124
+ # @param models [Hash, nil] ConfigFile.model_settings (specs)
125
+ # @param session [Array<Symbol>, nil] the session's own layers (none yet)
126
+ # @param session_apply [Symbol, nil] the session's own apply rule (none yet)
127
+ # @param session_budget [Integer, nil] the session's own budget (none yet)
128
+ # @return [Resolved]
129
+ def resolve(target, names:, models: nil, session: nil, session_apply: nil, session_budget: nil)
130
+ models ||= ConfigFile.model_settings
131
+ layers, source, where = layers_for(target, names, models, session)
132
+ resolved(layers, source, where).with(apply: apply_for(target, names, models, session_apply),
133
+ protect_steps: protect_steps, stale_edits: Config.get(STALE_EDITS_SETTING) == true,
134
+ budget_tokens: budget_for(target, names, models, session_budget))
135
+ end
136
+
137
+ def budget_for(target, names, models, session_budget)
138
+ session_budget || ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models)&.last ||
139
+ target&.entry&.llm_context_budget_tokens || parse_budget(Config.get(BUDGET_SETTING), BUDGET_SETTING)
140
+ end
141
+
142
+ def layers_for(target, names, models, session)
143
+ return [session, :session, "the session"] if session
144
+
145
+ key, layers = ConfigFile.model_setting(names, :llm_context_strategy, models: models)
146
+ return [layers, :model_setting, "models: #{key}"] if layers
147
+
148
+ layers = target&.entry&.llm_context_strategy
149
+ return [layers, :host_setting, "hosts entry '#{target.entry.name}'"] if layers
150
+
151
+ [parse(Config.get(SETTING), SETTING) || [], :config, SETTING]
152
+ end
153
+
154
+ def apply_for(target, names, models, session_apply)
155
+ session_apply || ConfigFile.model_setting(names, :llm_context_apply, models: models)&.last ||
156
+ target&.entry&.llm_context_apply || parse_apply(Config.get(APPLY_SETTING), APPLY_SETTING) || DEFAULT_APPLY
157
+ end
158
+
159
+ # llm_context.protect_steps: 0 or more (a negative one is the default).
160
+ def protect_steps
161
+ steps = Config.get(PROTECT_SETTING)
162
+ steps.is_a?(Integer) && !steps.negative? ? steps : DEFAULT_PROTECT_STEPS
163
+ end
164
+
165
+ def resolved(layers, source, where)
166
+ unbuilt = layers - BUILT
167
+ unless unbuilt.empty?
168
+ warn_once "Warning: #{where}: llm_context strategy #{unbuilt.join(", ")} is not built yet; using none"
169
+ end
170
+ Resolved.new(layers: layers, strategy: layers.empty? || !unbuilt.empty? ? NONE : layers, source: source)
171
+ end
172
+
173
+ def warn_once(message) = ConfigFile.warn_once(message)
174
+
175
+ private_class_method :resolved, :warn_once, :layers_for, :apply_for, :budget_for
176
+ end
177
+ end
@@ -0,0 +1,201 @@
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 "llm_context_stale"
8
+
9
+ module Samagotchi
10
+ # What the model is sent of the stored conversation: the one step
11
+ # between a session's messages and the three places that format them
12
+ # for a request (KernelLoop#format_prompt and #warmup_prompt, the native
13
+ # prompt; ChatLoop#wire_messages, the chat messages). A strategy may
14
+ # change what is sent; the session keeps the originals.
15
+ #
16
+ # Under +none+ (the default) nothing changes: #messages returns the
17
+ # conversation it was given, the same Array of the same entries, so the
18
+ # prompt is byte for byte what it was before the view existed.
19
+ #
20
+ # Under a strategy of layers (:stale, :forget), each applied edit saved
21
+ # on a tool_response entry (LLMContextEdit) whose kind is one of the
22
+ # layers sends that run's output as its stub. The entry stays where it
23
+ # is, with its role and tool_call_id, so a call keeps its response and
24
+ # the image plan (which indexes by entry) lines up. A stub keeps the
25
+ # output's "[name]" lead (Gemma's prompt writes response:NAME from it),
26
+ # and the run's images go with its text (images and image_counts kept
27
+ # consistent). An entry the view can't split for sure (its runs don't
28
+ # match its ids, or its images can't be told apart by run) is sent whole.
29
+ # The view only counts the runs: whatever saves an edit on a run checks
30
+ # the entry's runs against its batch's call names first
31
+ # (ToolResponse.runs_named), and the entry's content never changes.
32
+ #
33
+ # With the forget layer on, every output with a stored id is sent with
34
+ # it after its lead, "[read]\n[#t41] …" (plan D2: only then), so the
35
+ # model can name it to forget_outputs; a legacy entry's derived ids are
36
+ # never shown (they can't be forgotten), nor is the id of a run that
37
+ # doesn't pair with its call for sure (LLMContextStale.paired_runs: a
38
+ # "ran as:" line, an output holding a run separator), which
39
+ # forget_outputs can't name either. A forget's stub carries its
40
+ # note once: the outputs one forget_outputs call forgot that follow one
41
+ # another (no other output between them) point to the first one's
42
+ # instead, and a forget's kept lines (LLMContextEdit#keep) follow its
43
+ # stub. A forgotten output that isn't a read says how to restore it
44
+ # (re-reading the file is a read's restore).
45
+ class LLMContextView
46
+ NONE = :none
47
+ LAYERS = LLMContextEdit::KINDS
48
+
49
+ # @return [Array<Symbol>] the layers; empty under none
50
+ attr_reader :layers
51
+
52
+ # What +entries+ send, in chars: their contents (a parts list's text
53
+ # parts) and their calls' names and arguments. The chat loop's context
54
+ # estimate and the apply rule's payoff count so.
55
+ def self.chars(entries)
56
+ entries.sum do |entry|
57
+ content = entry[:content]
58
+ chars = if content.is_a?(Array)
59
+ content.sum { |part| part.is_a?(Hash) ? (part[:text] || part["text"]).to_s.length : 0 }
60
+ else
61
+ content.to_s.length
62
+ end
63
+ chars + Array(entry[:tool_calls]).sum do |call|
64
+ call.is_a?(Hash) ? call[:name].to_s.length + call[:arguments].to_json.length : 0
65
+ end
66
+ end
67
+ end
68
+
69
+ # @param strategy [Symbol, Array<Symbol>] :none, or the layers
70
+ def initialize(strategy: NONE)
71
+ @layers = strategy == NONE ? [] : Array(strategy).map(&:to_sym) & LAYERS
72
+ end
73
+
74
+ def none? = layers.empty?
75
+
76
+ def strategy = none? ? NONE : layers
77
+
78
+ # @param conversation [Array<Hash>] the stored entries, in order
79
+ # @return [Array<Hash>] the entries to format
80
+ def messages(conversation)
81
+ return conversation if none?
82
+
83
+ group = Group.new
84
+ group.paired = forget? ? LLMContextStale.paired_runs(conversation).to_set { |run| run.ref.id } : Set.new
85
+ conversation.each_with_index.map { |entry, index| edited(conversation, entry, index, group) }
86
+ end
87
+
88
+ def forget? = layers.include?(:forget)
89
+
90
+ # The forget whose stub carries the note the next forgotten output of
91
+ # the same call points to: its first id and its note, author and stamp.
92
+ # +paired+: the ids of the runs that pair with their calls.
93
+ Group = Struct.new(:first, :key, :paired)
94
+ private_constant :Group
95
+
96
+ READ = "read"
97
+
98
+ private
99
+
100
+ def edited(conversation, entry, index, group)
101
+ return entry unless entry[:role].to_s == "tool_response"
102
+
103
+ edits = LLMContextEdit.on(entry).select { |_id, edit| edit.applied? && layers.include?(edit.kind) }
104
+ return entry if edits.empty? && !forget?
105
+
106
+ refs = ToolIds.refs_at(conversation, index)
107
+ stubbed = refs.each_index.select { |run| edits.key?(refs[run].id) }
108
+ shown = forget? ? refs.each_index.select { |run| group.paired.include?(refs[run].id) && !refs[run].derived? } : []
109
+ return whole(entry, group) if stubbed.empty? && shown.empty?
110
+
111
+ texts = ToolResponse.runs(entry[:content], refs.size)
112
+ return whole(entry, group) unless texts.size == refs.size
113
+
114
+ images = stubbed.empty? ? :kept : kept_images(entry, refs.size, stubbed)
115
+ return whole(entry, group) if images.nil?
116
+
117
+ texts = texts.each_with_index.map do |text, run|
118
+ edit = edits[refs[run].id] if stubbed.include?(run)
119
+ text = edit ? stub(text, edit, refs[run], group) : whole(text, group)
120
+ shown.include?(run) ? with_id(text, refs[run].id) : text
121
+ end
122
+ sent = entry.merge(content: texts.map(&:text).join(ToolResponse::SEPARATOR))
123
+ images == :kept ? sent : with_images(sent, images)
124
+ end
125
+
126
+ # +sent+ as it is: an output between two forgotten ones ends their run.
127
+ def whole(sent, group)
128
+ group.key = nil
129
+ sent
130
+ end
131
+
132
+ # The run's text with its id after its lead.
133
+ def with_id(text, id)
134
+ text.with(body: "[##{id}] #{text.body}")
135
+ end
136
+
137
+ def stub(text, edit, ref, group)
138
+ lead = text.name ? "[#{text.name}] " : ""
139
+ ToolResponse::RunText.new(name: text.name, lead: lead, body: stub_body(text, edit, ref, group))
140
+ end
141
+
142
+ # A stale stub's note; a forget's note (or, after the first of its
143
+ # call's forgotten outputs in a row, a pointer to that one), its
144
+ # restore hint (not for a derived id: it names nothing the model can
145
+ # restore) and its kept lines.
146
+ def stub_body(text, edit, ref, group)
147
+ return whole(edit.stub, group) unless edit.kind == :forget
148
+
149
+ key = [edit.note, edit.by, edit.staged_at]
150
+ if group.key == key
151
+ head = "(forgotten with #{group.first}: see its note)"
152
+ else
153
+ group.first = ref.id
154
+ group.key = key
155
+ head = edit.stub
156
+ end
157
+ head += " [restore: #{ref.id}]" unless text.name == READ || ref.derived?
158
+ [head, *kept_lines(text.body, edit.keep, edit.keep_offset)].join("\n")
159
+ end
160
+
161
+ # The kept ranges of +body+'s lines, each under a "lines A-B kept:"
162
+ # line, A and B as the model named them (+offset+ past the body's own:
163
+ # a ranged read's file lines); a range is cut to the body, one wholly
164
+ # outside it goes.
165
+ def kept_lines(body, keep, offset)
166
+ lines = body.lines
167
+ keep.filter_map do |first, last|
168
+ from = [first - offset, 1].max
169
+ to = [last - offset, lines.size].min
170
+ next if from > to
171
+
172
+ "lines #{from + offset}-#{to + offset} kept:\n#{lines[(from - 1)..(to - 1)].join.chomp}"
173
+ end
174
+ end
175
+
176
+ # The entry's [images, image_counts] without the stubbed runs' ones;
177
+ # nil when they can't be told apart by run.
178
+ def kept_images(entry, run_count, stubbed)
179
+ images = Array(entry[:images])
180
+ return [[], nil] if images.empty?
181
+ return [[], nil] if run_count == 1
182
+
183
+ counts = entry[:image_counts]
184
+ return nil unless counts.is_a?(Array) && counts.size == run_count && counts.sum == images.size
185
+
186
+ offset = 0
187
+ kept = counts.each_with_index.flat_map do |count, run|
188
+ slice = images[offset, count]
189
+ offset += count
190
+ stubbed.include?(run) ? [] : slice
191
+ end
192
+ [kept, counts.each_with_index.map { |count, run| stubbed.include?(run) ? 0 : count }]
193
+ end
194
+
195
+ def with_images(entry, (images, counts))
196
+ return entry.except(:images, :image_counts) if images.empty?
197
+
198
+ entry.merge(images: images, image_counts: counts)
199
+ end
200
+ end
201
+ end
@@ -107,7 +107,8 @@ module Samagotchi
107
107
  content_length: event[:content_length],
108
108
  thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason],
109
109
  stopped_by: event[:stopped_by], prompt: event[:prompt_tokens],
110
- cached: event[:cached_tokens], cache_write: event[:cache_write_tokens])
110
+ cached: event[:cached_tokens], cache_write: event[:cache_write_tokens],
111
+ reprefill: event[:reprefill_tokens])
111
112
  return unless event[:stopped_by]
112
113
 
113
114
  # A plugin (stop_generation) or a steer (Engine#cut_for_steer, by
@@ -112,18 +112,11 @@ module Samagotchi
112
112
 
113
113
  # A native tool_response entry (ToolResponse.joined: "[name]" outputs
114
114
  # joined by its SEPARATOR) as Gemma's template writes the results: one
115
- # response:NAME{value:<|"|>…<|"|>} block per call. A part that doesn't
116
- # open with a "[name]" is the previous output's own text.
115
+ # response:NAME{value:<|"|>…<|"|>} block per call (ToolResponse.split:
116
+ # a part that doesn't open with a "[name]" is the previous output's own
117
+ # text).
117
118
  def self.tool_response_blocks(content, profile)
118
- runs = content.split(ToolResponse::SEPARATOR).each_with_object([]) do |part, acc|
119
- if (match = part.match(/\A\[([^\]\s]+)\](?:\n| |\z)/))
120
- acc << [match[1], match.post_match]
121
- elsif acc.empty?
122
- acc << ["unknown", part]
123
- else
124
- acc.last[1] = "#{acc.last[1]}#{ToolResponse::SEPARATOR}#{part}"
125
- end
126
- end
119
+ runs = ToolResponse.split(content).map { |run| [run.name || "unknown", run.body] }
127
120
  runs = [["unknown", ""]] if runs.empty?
128
121
  q = profile.string_delim
129
122
  runs.map do |name, body|
@@ -178,7 +178,8 @@ module Samagotchi
178
178
  # is when the worker starts); opts[:env] adds to it (merged, not
179
179
  # replaced) what it can't read there: this `chi`'s CLI settings
180
180
  # (Config.cli_env), the hosts and the absolute log file. It unsets
181
- # SAMAGOTCHI_GUARDRAILS_*. XDG_CONFIG_HOME passes: whoever starts or
181
+ # SAMAGOTCHI_GUARDRAILS_* and the session markers (SESSION_MARKER_ENV).
182
+ # XDG_CONFIG_HOME passes: whoever starts or
182
183
  # wakes the worker picks which config.yml it reads (docs/guardrails.md).
183
184
  private_class_method def self.spawn_options(session)
184
185
  # Own process group: workers outlive `chi web`, and a Ctrl-C in its
@@ -218,6 +219,11 @@ module Samagotchi
218
219
  # key mapped to nil. config.yml no longer reads them; this keeps an
219
220
  # older or future env-exposed one out as well.
220
221
  ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
222
+ # The markers a session's execute exports (Tools::Builtins.parent_env)
223
+ # are that session's: a worker started from its commands (chi send)
224
+ # is another session. Its execute and tasks set its own; nothing else
225
+ # it runs (a user's !cmd, an MCP server, a context command) has one.
226
+ SESSION_MARKER_ENV.each { |key| child_env[key] = nil }
221
227
  # A smoke's fake "newest installed" is for this process's notices only.
222
228
  child_env[InstalledVersions::ENV_KEY] = nil
223
229
  # A gem-mode worker activates the newest installed chi itself: under
@@ -238,6 +244,8 @@ module Samagotchi
238
244
  end
239
245
 
240
246
  GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
247
+ # Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
248
+ SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
241
249
 
242
250
  # List all sessions, reading status from persisted session.json files.
243
251
  # +project_root+: only that project's sessions (nil: every session).
@@ -6,6 +6,7 @@ require "time"
6
6
  require "fileutils"
7
7
  require "securerandom"
8
8
  require_relative "atomic_file"
9
+ require_relative "token_usage"
9
10
  require_relative "session"
10
11
 
11
12
  module Samagotchi
@@ -104,6 +105,9 @@ module Samagotchi
104
105
  # decode one came from (server / estimate).
105
106
  :cached_sum,
106
107
  :cache_write_sum,
108
+ # What the server prefilled again of the previous request's prompt
109
+ # (KernelLoop#reprefilled_tokens; an LLM context edit's cache break).
110
+ :reprefill_sum,
107
111
  :reasoning_sum,
108
112
  :cost_sum,
109
113
  :decode_ms_sum,
@@ -273,6 +277,7 @@ module Samagotchi
273
277
  when :generation_completed, :generation_cancelled
274
278
  record_served_model(event)
275
279
  count_cut(event)
280
+ count_reprefill(event)
276
281
  record_generation_completed
277
282
  when :generation_retrying
278
283
  # The retry streams from the start: its counts replace the ones so far.
@@ -397,6 +402,7 @@ module Samagotchi
397
402
  completion_sum: add.call(:completion_sum),
398
403
  cached_sum: add.call(:cached_sum),
399
404
  cache_write_sum: add.call(:cache_write_sum),
405
+ reprefill_sum: add.call(:reprefill_sum),
400
406
  reasoning_sum: add.call(:reasoning_sum),
401
407
  cost_sum: add.call(:cost_sum),
402
408
  decode_ms_sum: decode_ms.round,
@@ -456,6 +462,7 @@ module Samagotchi
456
462
  token_sources: [],
457
463
  cached_sum: 0,
458
464
  cache_write_sum: 0,
465
+ reprefill_sum: 0,
459
466
  reasoning_sum: 0,
460
467
  cost_sum: nil,
461
468
  decode_ms_sum: 0,
@@ -527,6 +534,14 @@ module Samagotchi
527
534
  end
528
535
  end
529
536
 
537
+ # A generation's re-prefilled tokens (its :generation_completed's), into the turn.
538
+ def count_reprefill(event)
539
+ tokens = event[:reprefill_tokens]
540
+ return unless tokens.is_a?(Integer) && tokens >= TokenUsage::REPREFILL_MIN_TOKENS
541
+
542
+ @mutex.synchronize { @turn.reprefill_sum += tokens if @turn }
543
+ end
544
+
530
545
  def record_generation_completed
531
546
  @mutex.synchronize { close_generation if @turn&.gen_open }
532
547
  end
@@ -654,6 +669,7 @@ module Samagotchi
654
669
  tps_source: turn.tps_source
655
670
  }
656
671
  fields[:cache_write_tokens_sum] = turn.cache_write_sum if turn.cache_write_sum.positive?
672
+ fields[:reprefill_tokens_sum] = turn.reprefill_sum if turn.reprefill_sum.positive?
657
673
  fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
658
674
  fields
659
675
  end
@@ -891,7 +907,7 @@ module Samagotchi
891
907
  def reset_totals
892
908
  @totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
893
909
  gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
894
- reasoning_sum: 0,
910
+ reprefill_sum: 0, reasoning_sum: 0,
895
911
  cost_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
896
912
  last_prefill_tps: nil, tps_source: nil }
897
913
  end
@@ -908,6 +924,7 @@ module Samagotchi
908
924
  totals[:capped] += number(record[:capped])
909
925
  totals[:cached_sum] += number(record[:cached_tokens_sum])
910
926
  totals[:cache_write_sum] += number(record[:cache_write_tokens_sum])
927
+ totals[:reprefill_sum] += number(record[:reprefill_tokens_sum])
911
928
  totals[:reasoning_sum] += number(record[:reasoning_tokens])
912
929
  totals[:cost_sum] += number(record[:cost])
913
930
  totals[:decode_ms_sum] += number(record[:decode_ms])
@@ -68,10 +68,13 @@ module Samagotchi
68
68
  # server's KV cache for it, stay stable.
69
69
  # @param chat [Boolean] for the chat loop
70
70
  # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
71
- def build(chat: false, thinking: nil)
71
+ # @param layers [Array<Symbol>] the turn's LLM context layers: the
72
+ # native prompt declares the tools they add (Tools::Registry#entries)
73
+ def build(chat: false, thinking: nil, layers: [])
72
74
  @built ||= {}
73
- @built[[chat, thinking]] ||= system_prompt_with_index(assist_system_prompt(chat: chat, thinking: thinking),
74
- chat: chat, thinking: thinking)
75
+ @built[[chat, thinking, layers]] ||= system_prompt_with_index(
76
+ assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
77
+ )
75
78
  end
76
79
 
77
80
  # Drops the built prompts: the next #build reads the profile, tools,
@@ -90,8 +93,9 @@ module Samagotchi
90
93
 
91
94
  # The base prompt (specs, plugins' declarations), with Gemma's tool
92
95
  # declarations at its end as #build has them.
93
- def base(chat: false, thinking: nil)
94
- with_tail_declarations(assist_system_prompt(chat: chat, thinking: thinking), chat: chat)
96
+ def base(chat: false, thinking: nil, layers: [])
97
+ with_tail_declarations(assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat,
98
+ layers: layers)
95
99
  end
96
100
 
97
101
  # Names activated via preloaded --memory entries during prompt
@@ -113,13 +117,14 @@ module Samagotchi
113
117
 
114
118
  # ── Tool declarations ──────────────────────────────────────────────────────
115
119
 
116
- def tool_declarations
120
+ def tool_declarations(layers)
121
+ schemas = ToolDeclarations.native_schemas(@tools_lookup.call, layers: layers)
117
122
  case profile.name
118
123
  when "qwen36"
119
- ToolDeclarations.qwen_declarations(ToolDeclarations.native_schemas(@tools_lookup.call))
124
+ ToolDeclarations.qwen_declarations(schemas)
120
125
  else
121
126
  # Gemma 4 format
122
- ToolDeclarations.gemma_declarations(ToolDeclarations.native_schemas(@tools_lookup.call))
127
+ ToolDeclarations.gemma_declarations(schemas)
123
128
  end
124
129
  end
125
130
 
@@ -148,7 +153,7 @@ module Samagotchi
148
153
  # @param chat [Boolean] for the chat loop: no tool declarations, call
149
154
  # syntax or turn preamble (its tools go as schemas with each request)
150
155
  # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
151
- def assist_system_prompt(chat: false, thinking: nil)
156
+ def assist_system_prompt(chat: false, thinking: nil, layers: [])
152
157
  return chat_system_prompt if chat
153
158
 
154
159
  hint = tool_call_hint
@@ -158,7 +163,7 @@ module Samagotchi
158
163
  tools = if gemma_tail_declarations?
159
164
  "Your tools are declared at the end of this system prompt."
160
165
  else
161
- "You have access to the following tools:\n\n#{tool_declarations}\n"
166
+ "You have access to the following tools:\n\n#{tool_declarations(layers)}\n"
162
167
  end
163
168
 
164
169
  <<~SYS
@@ -247,7 +252,7 @@ module Samagotchi
247
252
  # indexes (a memory write changes them) after both; the model, the
248
253
  # location and the session close the prompt (Gemma's tool declarations
249
254
  # still follow them).
250
- def system_prompt_with_index(base, chat: false, thinking: nil)
255
+ def system_prompt_with_index(base, chat: false, thinking: nil, layers: [])
251
256
  thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
252
257
  memory_sections = [
253
258
  "Project memories:\n#{read_memory_index("project")}",
@@ -257,15 +262,15 @@ module Samagotchi
257
262
  project_specific_description, memory_sections]
258
263
  stable_text = stable.compact.join("\n")
259
264
  volatile = [current_model, project_location, current_session].compact
260
- return with_tail_declarations(stable_text, chat: chat) if volatile.empty?
265
+ return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
261
266
 
262
267
  prompt = "#{stable_text}\n#{volatile.join("\n")}"
263
268
  (@stable_lengths ||= {})[prompt] = stable_text.length + 1 if chat
264
- with_tail_declarations(prompt, chat: chat)
269
+ with_tail_declarations(prompt, chat: chat, layers: layers)
265
270
  end
266
271
 
267
- def with_tail_declarations(prompt, chat:)
268
- chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations
272
+ def with_tail_declarations(prompt, chat:, layers:)
273
+ chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
269
274
  end
270
275
 
271
276
  def gemma_tail_declarations? = profile.name == "gemma4"
@@ -471,8 +471,9 @@ module Samagotchi
471
471
  lines.join("\n")
472
472
  end
473
473
 
474
- # ", cached 4864 (93%), cache writes 312, reasoning 212" for the tokens
475
- # line; the counts a server didn't report are left out.
474
+ # ", cached 4864 (93%), cache writes 312, re-prefilled 2100, reasoning
475
+ # 212" for the tokens line; the counts a server didn't report are left
476
+ # out (re-prefilled: SessionMetrics' reprefill_sum).
476
477
  def token_breakdown_text(tokens)
477
478
  cached = tokens[:cached_sum].to_i
478
479
  reasoning = tokens[:reasoning_sum].to_i
@@ -480,6 +481,7 @@ module Samagotchi
480
481
  text = +""
481
482
  text << ", cached #{cached} (#{(cached * 100.0 / prompt).round}%)" if cached.positive? && prompt.positive?
482
483
  text << ", cache writes #{tokens[:cache_write_sum].to_i}" if tokens[:cache_write_sum].to_i.positive?
484
+ text << ", re-prefilled #{tokens[:reprefill_sum].to_i}" if tokens[:reprefill_sum].to_i.positive?
483
485
  text << ", reasoning #{reasoning}" if reasoning.positive?
484
486
  text
485
487
  end
@@ -23,6 +23,9 @@ module Samagotchi
23
23
  class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :cache_write_tokens,
24
24
  :reasoning_tokens, :cost, :predicted_per_second, :prompt_per_second, :predicted_ms, :source)
25
25
  CHARS_PER_TOKEN = 4.0
26
+ # Re-prefilled tokens under this are none (KernelLoop#reprefilled_tokens,
27
+ # SessionMetrics): servers count the cache in blocks (OpenAI's 128).
28
+ REPREFILL_MIN_TOKENS = 128
26
29
 
27
30
  class << self
28
31
  # @param payload [Hash, nil] the raw streamed chunk payload