samagotchi 0.24.0 → 0.25.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/docs/architecture.md +16 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/configuration.md +21 -13
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/prompt-caching.md +94 -0
  9. data/docs/plugins.md +16 -2
  10. data/docs/testing.md +12 -0
  11. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/command_steps.rb +233 -0
  15. data/lib/samagotchi/config.rb +5 -2
  16. data/lib/samagotchi/empty_answer_retry.rb +2 -2
  17. data/lib/samagotchi/engine.rb +23 -4
  18. data/lib/samagotchi/guardrails/shell_lex.rb +49 -5
  19. data/lib/samagotchi/idle_client.rb +14 -2
  20. data/lib/samagotchi/kernel_loop.rb +21 -3
  21. data/lib/samagotchi/llm/chat_loop.rb +17 -3
  22. data/lib/samagotchi/llm/errors.rb +26 -1
  23. data/lib/samagotchi/llm/openai_chat.rb +5 -4
  24. data/lib/samagotchi/llm/prompt_cache.rb +34 -9
  25. data/lib/samagotchi/llm/usage.rb +18 -3
  26. data/lib/samagotchi/log_subscriber.rb +2 -1
  27. data/lib/samagotchi/plugin/api.rb +3 -2
  28. data/lib/samagotchi/session_metrics.rb +38 -4
  29. data/lib/samagotchi/system_prompt.rb +30 -9
  30. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -2
  31. data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
  32. data/lib/samagotchi/terminal_ui/event_renderer.rb +14 -2
  33. data/lib/samagotchi/terminal_ui/formatting.rb +31 -9
  34. data/lib/samagotchi/thinking_tails.rb +103 -0
  35. data/lib/samagotchi/token_usage.rb +18 -6
  36. data/lib/samagotchi/tool_activity.rb +37 -3
  37. data/lib/samagotchi/tool_declarations.rb +14 -0
  38. data/lib/samagotchi/tool_view.rb +40 -4
  39. data/lib/samagotchi/tools/builtins.rb +4 -1
  40. data/lib/samagotchi/tools/execute.rb +2 -1
  41. data/lib/samagotchi/tools/registry.rb +11 -4
  42. data/lib/samagotchi/tools/shell.rb +32 -0
  43. data/lib/samagotchi/tools/task_runtime.rb +3 -3
  44. data/lib/samagotchi/turn_tally.rb +17 -6
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/public/command_view.js +71 -3
  47. data/lib/samagotchi/web/public/format.js +11 -1
  48. data/lib/samagotchi/web/public/index.html +25 -0
  49. data/lib/samagotchi/web/public/stage_model.js +5 -1
  50. data/lib/samagotchi/web/public/stage_view.js +1 -1
  51. data/lib/samagotchi/web/public/turn_view.js +3 -3
  52. metadata +5 -1
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "guardrails/shell_lex"
4
+
5
+ module Samagotchi
6
+ # An execute command as the steps a person reads, for the web's tool row
7
+ # (display only: the guardrails read the command themselves). Built from
8
+ # ShellLex's tokens and their spans, so each step's text is a slice of the
9
+ # command as the model wrote it. Rules, in order:
10
+ #
11
+ # - a heredoc's body leaves its step's text and becomes its +heredoc+
12
+ # ({tag:, lines:}), also one inside a "$(cat <<'EOF' … EOF)" argument;
13
+ # - steps are split at && || ; | |& & and newlines; a ( … ) subshell stays
14
+ # one step; +op+ is the operator before a step (nil for the first);
15
+ # - a leading "cd X &&" / "cd X;" becomes +cd+ (only the first);
16
+ # - a pipeline's last "| head …" / "| tail …" becomes the +limit+ of the
17
+ # step before it ("head 20", "tail", "head -c 60");
18
+ # - a lone echo of a marker-wrapped string ("=== x ===", "--- x ---",
19
+ # "## x") becomes the +label+ of the next step; a bare marker echo
20
+ # ("---", "====", "") only spaces the output and goes;
21
+ # - anything the parser isn't sure of (a quote or heredoc left open,
22
+ # for/while/until/if/case/function bodies, { … } groups, unbalanced
23
+ # parentheses) gives nil: the UI then shows the title and the raw
24
+ # command.
25
+ module CommandSteps
26
+ Step = Data.define(:text, :op, :label, :limit, :heredoc) do
27
+ def initialize(text:, op: nil, label: nil, limit: nil, heredoc: nil) = super
28
+
29
+ def to_h = super.compact
30
+ end
31
+
32
+ Parsed = Data.define(:cd, :steps) do
33
+ def to_h = { cd: cd, steps: steps.map(&:to_h) }.compact
34
+ end
35
+
36
+ Lex = Guardrails::ShellLex
37
+
38
+ SPLIT = ["&&", "||", ";", "|", "|&", "&", "\n"].freeze
39
+ KEYWORDS = %w[for while until if then elif else fi case esac select do done function { }].freeze
40
+ LIMITERS = %w[head tail].freeze
41
+ MARKER = /\A\s*(?:={2,}|-{2,}|#+)\s*(.*?)\s*(?:={2,}|-{2,})?\s*\z/m
42
+ ECHO_FLAGS = %w[-e -n -en -ne].freeze
43
+
44
+ module_function
45
+
46
+ # @return [Parsed, nil] nil when the command isn't plain enough to show
47
+ # as steps
48
+ def parse(command)
49
+ text = command.to_s
50
+ lexer = Lex::Lexer.new(text)
51
+ tokens = lexer.tokens.zip(lexer.spans).map { |token, span| [*token, span] }
52
+ return nil if lexer.unterminated? || tokens.empty?
53
+
54
+ bodies = lexer.heredoc_spans.to_h { |doc| [doc[:span], doc[:tag]] }
55
+ raw = split(tokens)
56
+ return nil if raw.nil? || raw.empty?
57
+
58
+ steps = raw.map { |op, words| step(text, op, words, bodies) }
59
+ return nil if steps.any?(&:nil?)
60
+
61
+ cd, steps = leading_cd(steps, raw)
62
+ steps = labels(limits(steps))
63
+ steps.empty? ? nil : Parsed.new(cd: cd, steps: steps)
64
+ end
65
+
66
+ # [[op, word tokens]] split at SPLIT outside parentheses; nil for a
67
+ # control keyword at a command's start, ;; or unbalanced parentheses.
68
+ def split(tokens)
69
+ steps = []
70
+ op = nil
71
+ words = []
72
+ depth = 0
73
+ tokens.each do |token|
74
+ kind, value = token
75
+ if kind == :op && value == "("
76
+ depth += 1
77
+ words << token
78
+ elsif kind == :op && value == ")"
79
+ depth -= 1
80
+ return nil if depth.negative?
81
+
82
+ words << token
83
+ elsif kind == :op && depth.zero?
84
+ return nil unless SPLIT.include?(value)
85
+
86
+ if words.empty?
87
+ op = value unless value == "\n" # "a;\nb" keeps the ;
88
+ else
89
+ steps << [steps.empty? ? nil : op, words]
90
+ op = value
91
+ end
92
+ words = []
93
+ else
94
+ return nil if keyword?(words, token)
95
+
96
+ words << token
97
+ end
98
+ end
99
+ return nil unless depth.zero?
100
+
101
+ steps << [steps.empty? ? nil : op, words] unless words.empty?
102
+ steps
103
+ end
104
+
105
+ # Whether +token+ is a control keyword where a command starts (the
106
+ # step's first word, or the first after a "(" inside it).
107
+ def keyword?(words, token)
108
+ return false unless token[0] == :word
109
+
110
+ at_start = words.empty? || words.last[0] == :op
111
+ at_start && KEYWORDS.include?(token[1])
112
+ end
113
+
114
+ # A Step from its tokens: the text from the first to the last token,
115
+ # heredoc bodies cut out.
116
+ def step(text, op, tokens, bodies)
117
+ body = tokens.find { |t| bodies.key?(t.last) }
118
+ shown = tokens.reject { |t| bodies.key?(t.last) }
119
+ from = shown.map { |t| t.last.begin }.min
120
+ to = shown.map { |t| t.last.end }.max
121
+ return nil unless from
122
+
123
+ slice = text[from...to]
124
+ heredoc = body && { tag: bodies[body.last], lines: body_lines(text[body.last]) }
125
+ slice, heredoc = inner_heredoc(slice) unless heredoc
126
+ Step.new(text: slice.strip, op: op, heredoc: heredoc)
127
+ end
128
+
129
+ # A heredoc inside a $(…) in +slice+ (git commit -m "$(cat <<'EOF' …)"):
130
+ # the slice with the body cut out, and the heredoc; or the slice as is.
131
+ def inner_heredoc(slice)
132
+ return [slice, nil] unless slice.include?("<<")
133
+
134
+ at = 0
135
+ while (open = slice.index("$(", at))
136
+ lexer = Lex::Lexer.new(slice[(open + 2)..])
137
+ lexer.tokens
138
+ doc = lexer.heredoc_spans.first
139
+ if doc
140
+ from = open + 2 + doc[:span].begin - 1 # the newline before the body
141
+ to = open + 2 + doc[:span].end
142
+ to += 1 if slice[to] == "\n"
143
+ return [slice[0...from] + slice[to..], { tag: doc[:tag], lines: body_lines(slice[(from + 1)...to]) }]
144
+ end
145
+ at = open + 2
146
+ end
147
+ [slice, nil]
148
+ end
149
+
150
+ # The body's line count, its terminator line not counted.
151
+ def body_lines(body_and_tag) = [body_and_tag.to_s.chomp.lines.size - 1, 0].max
152
+
153
+ # A first "cd X" step followed by && or ; becomes the cd.
154
+ def leading_cd(steps, raw)
155
+ words = raw.first[1]
156
+ next_op = steps[1]&.op
157
+ plain = words.size == 2 && words.all? { |t| t[0] == :word } && !words[1][1].include?(Lex::SUBST)
158
+ return [nil, steps] unless plain && words[0][1] == "cd" && ["&&", ";"].include?(next_op)
159
+
160
+ [words[1][1], [steps[1].with(op: nil), *steps.drop(2)]]
161
+ end
162
+
163
+ # A pipeline's last head/tail becomes the limit of the step before it.
164
+ def limits(steps)
165
+ steps.each_with_index.with_object([]) do |(step, i), out|
166
+ limit = step.op == "|" && !out.empty? && out.last.limit.nil? && limit_of(step.text)
167
+ if limit && !["|", "|&"].include?(steps[i + 1]&.op)
168
+ out[-1] = out.last.with(limit: limit)
169
+ else
170
+ out << step
171
+ end
172
+ end
173
+ end
174
+
175
+ # "head 20" for "head -n 20" / "head -20" / "head -n20"; "head -c 60";
176
+ # "tail +5"; "head" alone. nil for anything else (tail -f, a file).
177
+ def limit_of(text)
178
+ words = Lex.lex(text)
179
+ return nil unless words.all? { |t| t[0] == :word }
180
+
181
+ name, *args = words.map { |t| t[1] }
182
+ return nil unless LIMITERS.include?(name)
183
+
184
+ count = case args
185
+ in [] then nil
186
+ in [/\A-\d+\z/ => arg] then arg[1..]
187
+ in [/\A-n\+?\d+\z/ => arg] then arg[2..]
188
+ in ["-n", /\A\+?\d+\z/ => arg] then arg
189
+ in ["-c", /\A\d+\z/ => arg] then "-c #{arg}"
190
+ else return nil
191
+ end
192
+ [name, count].compact.join(" ")
193
+ end
194
+
195
+ # A marker echo labels the next step; a bare one goes.
196
+ # A dropped echo's op goes to the step after it (the link from the
197
+ # step before).
198
+ def labels(steps)
199
+ out = []
200
+ label = nil
201
+ op = nil
202
+ steps.each_with_index do |step, i|
203
+ marker = marker_label(step.text)
204
+ if marker.nil? || (!marker.empty? && i == steps.size - 1)
205
+ step = step.with(label: label, op: op || step.op) if op
206
+ out << (out.empty? ? step.with(op: nil) : step)
207
+ label = op = nil
208
+ else
209
+ label = marker unless marker.empty?
210
+ op ||= step.op || "\n"
211
+ end
212
+ end
213
+ out
214
+ end
215
+
216
+ # The label of a lone marker echo ("" for a bare marker or an empty
217
+ # echo), nil for any other step.
218
+ def marker_label(text)
219
+ words = Lex.lex(text)
220
+ return nil unless words.all? { |t| t[0] == :word && !t[1].include?(Lex::SUBST) }
221
+
222
+ name, *args = words.map { |t| t[1] }
223
+ args.shift while ECHO_FLAGS.include?(args.first)
224
+ return nil unless name == "echo" && args.size <= 1
225
+ return "" if args.empty? || args[0].strip.empty?
226
+
227
+ match = MARKER.match(args[0])
228
+ return nil unless match && args[0].strip.match?(/\A(?:={2,}|-{2,}|#)/)
229
+
230
+ match[1].strip
231
+ end
232
+ end
233
+ end
@@ -174,6 +174,8 @@ module Samagotchi
174
174
  Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
175
175
  # Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
176
176
  Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
177
+ # Whether execute declares its optional description parameter (a few model-written words, a tool row's title).
178
+ Entry.new(key: "execute.description", yaml_path: %w[execute description], type: :bool, default: true, expose: %i[env config]),
177
179
 
178
180
  Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
179
181
  Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
@@ -666,8 +668,9 @@ module Samagotchi
666
668
  # A `sampling:` setting (hosts entry or models: entry): request parameters
667
669
  # passed through to the provider as written (keys symbolized, nested maps
668
670
  # too, so e.g. chat_template_kwargs works). A null value means "don't
669
- # send it" (drops chi's own temperature default). Not a map: warns once,
670
- # nil. Reserved keys warn once and are dropped. nil when nothing is left.
671
+ # send it" (a null temperature also keeps the empty-answer retry from
672
+ # setting one). Not a map: warns once, nil. Reserved keys warn once and
673
+ # are dropped. nil when nothing is left.
671
674
  def sampling_map(value, where)
672
675
  return nil if value.nil?
673
676
 
@@ -13,8 +13,8 @@ module Samagotchi
13
13
  # when the retries run out.
14
14
  class EmptyAnswerRetry
15
15
  MAX = 3
16
- # Greedy decoding on a near-identical prompt would likely loop again:
17
- # the retry runs at this temperature unless one is configured.
16
+ # The same (or greedy) decoding on a near-identical prompt would likely
17
+ # loop again: the retry runs at this temperature unless one is configured.
18
18
  TEMPERATURE = 0.6
19
19
  # A generation cut at the provider's output cap (finish_reason length)
20
20
  # is retried unless the context is this full.
@@ -22,6 +22,7 @@ require_relative "session_commands"
22
22
  require_relative "plugin/loader"
23
23
  require_relative "log"
24
24
  require_relative "log_subscriber"
25
+ require_relative "thinking_tails"
25
26
  require_relative "client"
26
27
  require_relative "host_registry"
27
28
  require_relative "llm/backend"
@@ -172,8 +173,9 @@ module Samagotchi
172
173
  @hooks = load_hooks_from_config
173
174
  load_hooks_from_bundles
174
175
  # The tools this session offers (the prompts' declarations and the
175
- # kernel's dispatch): the built-ins, per Engine.
176
- @tools = Tools::Builtins.registry
176
+ # kernel's dispatch): the built-ins, per Engine. execute.description
177
+ # off: execute offers no description parameter.
178
+ @tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false)
177
179
  if @scratch
178
180
  # A scratch session's children would outlive it, and so would a
179
181
  # note it left in a peer; its memories would too (write and edit
@@ -282,6 +284,10 @@ module Samagotchi
282
284
  @session_observer.subscribe(observer: @metrics)
283
285
  # And the event trail in the debug log (`turn` records).
284
286
  @session_observer.subscribe(observer: LogSubscriber.new(session_id: -> { @session&.id }))
287
+ # The thinking of cut and capped generations, kept in the session dir.
288
+ @session_observer.subscribe(observer: ThinkingTails.new(session_dir: lambda {
289
+ @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir)
290
+ }))
285
291
  end
286
292
 
287
293
  # A monotonically-increasing clock (wall clock can jump backwards; the idle
@@ -2039,12 +2045,23 @@ module Samagotchi
2039
2045
  nil
2040
2046
  end
2041
2047
 
2048
+ # Whether this generation's thinking lane said anything: nil while it
2049
+ # streamed none, false while only whitespace (Gemma 4's empty thought
2050
+ # with thinking off), true once a real thought came.
2051
+ def note_generation_thought(thinking)
2052
+ return if thinking.empty?
2053
+
2054
+ @generation_thought ||= thinking.match?(/\S/)
2055
+ end
2056
+
2042
2057
  # Thinking off, and the model thought anyway: logged each time, said
2043
- # once per session and host.
2058
+ # once per session and host. A thinking lane of whitespace alone is no
2059
+ # thought.
2044
2060
  def check_thinking_honoured(event)
2045
2061
  level, target = @turn_thinking
2046
2062
  chars = event[:thinking_chars].to_i
2047
2063
  return unless level == :off && chars.positive? && target
2064
+ return if @generation_thought == false
2048
2065
 
2049
2066
  Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
2050
2067
  thinking_notice_once(:not_honoured, target, :warn,
@@ -2184,7 +2201,7 @@ module Samagotchi
2184
2201
  return @native_backend unless target.entry.chat?
2185
2202
 
2186
2203
  @chat_backend_mutex.synchronize do
2187
- @chat_backend ||= LLM::ChatLoop.new(kernel: @kernel)
2204
+ @chat_backend ||= LLM::ChatLoop.new(kernel: @kernel, stable_length: ->(text) { @prompt_builder.stable_length(text) })
2188
2205
  @chat_backend.adapter = @host_registry.adapter_for(target.entry)
2189
2206
  @chat_backend.session_id = session&.id
2190
2207
  @chat_backend
@@ -2406,6 +2423,7 @@ module Samagotchi
2406
2423
  case event[:type]
2407
2424
  when :generation_started
2408
2425
  @generation_phase.started!
2426
+ @generation_thought = nil
2409
2427
  watch&.started(event[:iteration])
2410
2428
  when :generation_chunk
2411
2429
  # A chunk without the lanes (no loop of ours sends one) counts as text.
@@ -2413,6 +2431,7 @@ module Samagotchi
2413
2431
  progress = { thinking: event[:thinking].to_s, text: text.to_s }
2414
2432
  @generation_phase.chunk!(**progress, tool_call: event[:tool_call])
2415
2433
  recheck_waiting_steer unless progress[:thinking].empty?
2434
+ note_generation_thought(progress[:thinking])
2416
2435
  when :generation_retrying then @generation_phase.retrying!
2417
2436
  when :generation_completed, :generation_cancelled then @generation_phase.finished!
2418
2437
  end
@@ -33,6 +33,15 @@ module Samagotchi
33
33
  Lexer.new(text).tokens
34
34
  end
35
35
 
36
+ # lex's tokens, each with the range of +text+ it was read from
37
+ # appended ([:word, "x", specials, 0...1] / [:op, "&&", 2...4]), for a
38
+ # display that slices the original text. A heredoc body's range is
39
+ # the body and its terminator line. lex itself is unchanged by this.
40
+ def self.lex_with_spans(text)
41
+ lexer = Lexer.new(text)
42
+ lexer.tokens.zip(lexer.spans).map { |token, span| [*token, span] }
43
+ end
44
+
36
45
  # The token stream split at operators into word lists; ( and ) become
37
46
  # :open / :close markers.
38
47
  def self.simple_commands(tokens)
@@ -57,18 +66,34 @@ module Samagotchi
57
66
  @s = text
58
67
  @i = 0
59
68
  @tokens = []
69
+ @spans = []
70
+ @start = 0
60
71
  @word = +""
61
72
  @specials = +""
62
73
  @in_word = false
63
74
  @heredocs = []
75
+ @heredoc_spans = []
76
+ @unterminated = false
64
77
  end
65
78
 
66
79
  def tokens
67
80
  step while @i < @s.size
68
81
  flush
82
+ @unterminated = true unless @heredocs.empty?
69
83
  @tokens
70
84
  end
71
85
 
86
+ # Each token's range in the text, in step with #tokens (after it).
87
+ attr_reader :spans
88
+
89
+ # The heredocs read ({tag:, span:}, the body and its terminator
90
+ # line), after #tokens.
91
+ attr_reader :heredoc_spans
92
+
93
+ # Whether a quote, $(…), backtick or heredoc was left open (after
94
+ # #tokens): the lexer's best effort, not the shell's reading.
95
+ def unterminated? = @unterminated
96
+
72
97
  private
73
98
 
74
99
  def step
@@ -87,6 +112,7 @@ module Samagotchi
87
112
  end
88
113
 
89
114
  def add(text, advance)
115
+ @start = @i unless @in_word
90
116
  @word << text
91
117
  @in_word = true
92
118
  @i += advance
@@ -99,7 +125,10 @@ module Samagotchi
99
125
  end
100
126
 
101
127
  def flush
102
- @tokens << [:word, @word.dup, @specials.dup] if @in_word
128
+ if @in_word
129
+ @tokens << [:word, @word.dup, @specials.dup]
130
+ @spans << (@start...[@i, @s.size].min)
131
+ end
103
132
  @word.clear
104
133
  @specials.clear
105
134
  @in_word = false
@@ -107,11 +136,14 @@ module Samagotchi
107
136
  end
108
137
 
109
138
  def single_quote
110
- close = @s.index("'", @i + 1) || @s.size
139
+ close = @s.index("'", @i + 1)
140
+ @unterminated ||= close.nil?
141
+ close ||= @s.size
111
142
  add(@s[(@i + 1)...close].to_s, close + 1 - @i)
112
143
  end
113
144
 
114
145
  def double_quote
146
+ @start = @i unless @in_word
115
147
  @i += 1
116
148
  @in_word = true
117
149
  while @i < @s.size && @s[@i] != '"'
@@ -126,11 +158,14 @@ module Samagotchi
126
158
  @i += 1
127
159
  end
128
160
  end
161
+ @unterminated = true if @i >= @s.size
129
162
  @i += 1
130
163
  end
131
164
 
132
165
  def backtick
133
- close = @s.index("`", @i + 1) || @s.size
166
+ close = @s.index("`", @i + 1)
167
+ @unterminated ||= close.nil?
168
+ close ||= @s.size
134
169
  add(SUBST, close + 1 - @i)
135
170
  end
136
171
 
@@ -143,6 +178,7 @@ module Samagotchi
143
178
  j += 1
144
179
  break if depth.zero? || j >= @s.size
145
180
  end
181
+ @unterminated = true unless depth.zero?
146
182
  add(SUBST, j - @i)
147
183
  end
148
184
 
@@ -170,6 +206,7 @@ module Samagotchi
170
206
  else
171
207
  flush
172
208
  @tokens << [:op, op]
209
+ @spans << (@i...(@i + op.size))
173
210
  @i += op.size
174
211
  bodies if op == "\n" && !@heredocs.empty?
175
212
  end
@@ -223,16 +260,23 @@ module Samagotchi
223
260
  heredocs = @heredocs
224
261
  @heredocs = []
225
262
  pos = @i
263
+ start = pos
226
264
  read = []
227
265
  heredocs.each do |doc|
228
266
  body, pos = body(doc, pos)
229
267
  break unless body
230
268
 
231
- read << [doc, body]
269
+ read << [doc, body, start...(@s[pos - 1] == "\n" ? pos - 1 : pos)]
270
+ start = pos
232
271
  end
272
+ @unterminated ||= read.size < heredocs.size
233
273
  return if read.size < heredocs.size
234
274
 
235
- read.reverse_each { |doc, body| @tokens.insert(doc[:at], [:word, body_word(doc, body), ""]) }
275
+ read.reverse_each do |doc, body, span|
276
+ @tokens.insert(doc[:at], [:word, body_word(doc, body), ""])
277
+ @spans.insert(doc[:at], span)
278
+ end
279
+ read.each { |doc, _body, span| @heredoc_spans << { tag: doc[:tag], span: span } }
236
280
  @i = pos
237
281
  end
238
282
 
@@ -93,7 +93,8 @@ module Samagotchi
93
93
  # @raise [SummarizeError] any failure but a cancel
94
94
  # @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
95
95
  def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil)
96
- content, served = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false)
96
+ content, served = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
97
+ kind: "ask")
97
98
  Summary.new(text: content.to_s.strip, model: served)
98
99
  rescue SummarizeError, LLM::RequestCancelled
99
100
  raise
@@ -109,7 +110,7 @@ module Samagotchi
109
110
  # the reply has neither content nor reasoning_content. Cut off by
110
111
  # +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
111
112
  # or all of it, with "…".
112
- def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true)
113
+ def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap")
113
114
  response = begin
114
115
  request(messages, max_tokens, cancel_controller)
115
116
  rescue LLM::BadRequest => e
@@ -121,6 +122,7 @@ module Samagotchi
121
122
  Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
122
123
  request(messages, max_tokens, cancel_controller)
123
124
  end
125
+ log_usage(response, kind)
124
126
  content = response.text
125
127
  reasoning = response.reasoning
126
128
  raise SummarizeError, "server returned no parseable assistant content" if content.empty? && reasoning.empty?
@@ -141,6 +143,16 @@ module Samagotchi
141
143
  raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
142
144
  end
143
145
 
146
+ # The request's prompt-cache counts (a side request on the session's
147
+ # host can evict the session's cached prompt there).
148
+ def log_usage(response, kind)
149
+ fields = response.usage&.cache_fields || {}
150
+ return if fields.empty?
151
+
152
+ Log.info(:recap, "request_usage", kind: kind, model: @model, prompt: fields[:prompt_tokens],
153
+ cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens])
154
+ end
155
+
144
156
  def request(messages, max_tokens, cancel_controller)
145
157
  @chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
146
158
  options: { max_tokens: max_tokens, **thinking_fields })
@@ -8,6 +8,7 @@ require_relative "context_status"
8
8
  require_relative "context_window"
9
9
  require_relative "prompt"
10
10
  require_relative "prompt_literal_guard"
11
+ require_relative "token_usage"
11
12
  require_relative "client"
12
13
  require_relative "llm/errors"
13
14
  require_relative "log"
@@ -238,8 +239,10 @@ module Samagotchi
238
239
  # One generation: the response (nil when cut), the cut's detail, this
239
240
  # generation's own server counts, the model the server named, the
240
241
  # thinking it streamed, and why it stopped (the transport's finish
241
- # reason, Client::Transport#finish_reason_from; nil when not named).
242
- Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, keyword_init: true)
242
+ # reason, Client::Transport#finish_reason_from; nil when not named),
243
+ # and its prompt-cache counts (the largest seen: they come cumulative).
244
+ Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, :cache,
245
+ keyword_init: true)
243
246
  private_constant :Turn, :Request, :Generation
244
247
 
245
248
  def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
@@ -328,6 +331,7 @@ module Samagotchi
328
331
  on_retry: lambda { |retry_event|
329
332
  # The retry streams from the start: its counts replace these.
330
333
  generation.usage = nil
334
+ generation.cache = nil
331
335
  generation.streamed_thinking = 0
332
336
  generation.finish_reason = nil
333
337
  turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
@@ -346,6 +350,7 @@ module Samagotchi
346
350
 
347
351
  def stream_chunk(turn, generation, stream_splitter, chunk)
348
352
  generation.usage = turn.context.capture(chunk[:payload]) || generation.usage
353
+ note_cache_counts(generation, chunk[:payload])
349
354
  # llama.cpp names the loaded model in the stream's last payload.
350
355
  named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
351
356
  generation.served_model = named if named.is_a?(String) && !named.strip.empty?
@@ -363,6 +368,19 @@ module Samagotchi
363
368
  emit(turn, **event)
364
369
  end
365
370
 
371
+ # The payload's prompt-cache counts, kept as the largest seen.
372
+ def note_cache_counts(generation, payload)
373
+ counts = TokenUsage.from_payload(payload)
374
+ return unless counts
375
+
376
+ cache = generation.cache ||= { prompt: 0, cached: 0, cache_write: 0 }
377
+ cache[:prompt] = [cache[:prompt], counts.prompt_tokens.to_i].max
378
+ cache[:cached] = [cache[:cached], counts.cached_tokens.to_i].max
379
+ cache[:cache_write] = [cache[:cache_write], counts.cache_write_tokens.to_i].max
380
+ end
381
+
382
+ def cache_fields(cache) = cache ? TokenUsage.cache_fields(**cache) : {}
383
+
366
384
  # The cut stream's visible text goes with it: the buffer as it was
367
385
  # before (the next generation gets a fresh splitter).
368
386
  def cut_generation(turn, generation, buffer_mark)
@@ -380,7 +398,7 @@ module Samagotchi
380
398
  emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
381
399
  thinking_chars: thinking_chars(response, generation.streamed_thinking),
382
400
  served_model: generation.served_model, requested_model: turn.model_name,
383
- finish_reason: generation.finish_reason)
401
+ finish_reason: generation.finish_reason, **cache_fields(generation.cache))
384
402
  dump_log("response", generation.response, iteration: turn.iteration)
385
403
  # The after_generation hook (after the model returns, before the tool
386
404
  # parse), with a read-only copy of the conversation as sent.
@@ -50,9 +50,12 @@ module Samagotchi
50
50
  # @param kernel [KernelLoop] tool dispatch, thought stripping, hooks
51
51
  # @param adapter [OpenAIChat, nil] the host to talk to; Engine sets it
52
52
  # per turn for the effective model's host
53
- def initialize(kernel:, adapter: nil)
53
+ # @param stable_length [#call, nil] text → the length of the system
54
+ # prompt's part every session shares (SystemPrompt#stable_length), or nil
55
+ def initialize(kernel:, adapter: nil, stable_length: nil)
54
56
  @kernel = kernel
55
57
  @adapter = adapter
58
+ @stable_length = stable_length
56
59
  end
57
60
 
58
61
  def provider = :chat
@@ -105,7 +108,7 @@ module Samagotchi
105
108
  items = plan.items(entry, index)
106
109
  case entry[:role].to_s
107
110
  when "system"
108
- wire << { role: "system", content: entry[:content].to_s }
111
+ wire << system_message(entry, index)
109
112
  when "user"
110
113
  # A steer or merged input (always a String) goes with its header.
111
114
  content = Steer.header(entry) ? Steer.wire_text(entry, entry[:content].to_s) : entry[:content]
@@ -132,6 +135,16 @@ module Samagotchi
132
135
  wire
133
136
  end
134
137
 
138
+ # The head system prompt says where its stable part ends
139
+ # (`cache_split:`, a character offset): the adapter puts a remote
140
+ # Claude model's cache breakpoint there (PromptCache) and drops it
141
+ # for every other request.
142
+ def system_message(entry, index)
143
+ text = entry[:content].to_s
144
+ split = index.zero? && @stable_length&.call(text)
145
+ split ? { role: "system", content: text, cache_split: split } : { role: "system", content: text }
146
+ end
147
+
135
148
  TOOL_IMAGES_TEXT = "[images from tool results]"
136
149
 
137
150
  # The turn's VisionContext (the Engine sets the kernel's
@@ -448,7 +461,8 @@ module Samagotchi
448
461
  record_context_status(response.usage, window)
449
462
  emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
450
463
  thinking_chars: response.reasoning.to_s.length, served_model: response.model,
451
- served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason)
464
+ served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason,
465
+ **(response.usage&.cache_fields || {}))
452
466
  dump_response(response, iteration)
453
467
  @loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
454
468
  messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })