samagotchi 0.23.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/docs/architecture.md +16 -9
  4. data/docs/cli.md +10 -4
  5. data/docs/configuration.md +30 -13
  6. data/docs/hooks.md +21 -7
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/prompt-caching.md +94 -0
  9. data/docs/plugins.md +17 -3
  10. data/docs/sub-agent.md +4 -3
  11. data/docs/testing.md +12 -0
  12. data/lib/samagotchi/bridge/card_store.rb +4 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  14. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +7 -0
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
  18. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
  20. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  21. data/lib/samagotchi/command_steps.rb +233 -0
  22. data/lib/samagotchi/config.rb +9 -2
  23. data/lib/samagotchi/empty_answer_retry.rb +2 -2
  24. data/lib/samagotchi/engine.rb +103 -5
  25. data/lib/samagotchi/generation_phase.rb +83 -0
  26. data/lib/samagotchi/guardrails/shell_lex.rb +158 -6
  27. data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
  28. data/lib/samagotchi/idle_client.rb +14 -2
  29. data/lib/samagotchi/idle_recap.rb +4 -2
  30. data/lib/samagotchi/kernel_loop.rb +40 -12
  31. data/lib/samagotchi/llm/chat_loop.rb +37 -13
  32. data/lib/samagotchi/llm/errors.rb +26 -1
  33. data/lib/samagotchi/llm/openai_chat.rb +8 -4
  34. data/lib/samagotchi/llm/prompt_cache.rb +34 -9
  35. data/lib/samagotchi/llm/usage.rb +18 -3
  36. data/lib/samagotchi/log_subscriber.rb +5 -3
  37. data/lib/samagotchi/plugin/api.rb +3 -2
  38. data/lib/samagotchi/prompt.rb +4 -1
  39. data/lib/samagotchi/session_metrics.rb +38 -4
  40. data/lib/samagotchi/steer.rb +103 -8
  41. data/lib/samagotchi/system_prompt.rb +30 -9
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -3
  43. data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
  44. data/lib/samagotchi/terminal_ui/event_renderer.rb +17 -2
  45. data/lib/samagotchi/terminal_ui/formatting.rb +40 -9
  46. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  47. data/lib/samagotchi/terminal_ui.rb +5 -0
  48. data/lib/samagotchi/thinking_tails.rb +103 -0
  49. data/lib/samagotchi/thought_stream_splitter.rb +9 -3
  50. data/lib/samagotchi/token_usage.rb +18 -6
  51. data/lib/samagotchi/tool_activity.rb +37 -3
  52. data/lib/samagotchi/tool_declarations.rb +14 -0
  53. data/lib/samagotchi/tool_runner.rb +7 -0
  54. data/lib/samagotchi/tool_view.rb +76 -0
  55. data/lib/samagotchi/tools/builtins.rb +4 -1
  56. data/lib/samagotchi/tools/execute.rb +2 -1
  57. data/lib/samagotchi/tools/registry.rb +11 -4
  58. data/lib/samagotchi/tools/shell.rb +32 -0
  59. data/lib/samagotchi/tools/task_runtime.rb +3 -3
  60. data/lib/samagotchi/turn_tally.rb +17 -6
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/waiting_steer.rb +64 -0
  63. data/lib/samagotchi/web/message_parts.rb +5 -0
  64. data/lib/samagotchi/web/public/activity.js +5 -0
  65. data/lib/samagotchi/web/public/app.js +2 -1
  66. data/lib/samagotchi/web/public/command_view.js +96 -0
  67. data/lib/samagotchi/web/public/copy.js +7 -0
  68. data/lib/samagotchi/web/public/format.js +11 -1
  69. data/lib/samagotchi/web/public/index.html +33 -0
  70. data/lib/samagotchi/web/public/stage_model.js +16 -5
  71. data/lib/samagotchi/web/public/stage_view.js +5 -2
  72. data/lib/samagotchi/web/public/turn_events.js +15 -1
  73. data/lib/samagotchi/web/public/turn_view.js +15 -6
  74. data/lib/samagotchi/worker.rb +4 -1
  75. metadata +9 -1
@@ -25,6 +25,9 @@ module Samagotchi
25
25
  # CreditsHeld 402 "… in-flight requests": credit reserved by other
26
26
  # requests; a RateLimited, retried after 20 s
27
27
  # OutOfCredits any other 402; never retried
28
+ # OverBudget 402 metadata.reason weight_exceeds_budget: the
29
+ # request alone exceeds the key's budget; an
30
+ # OutOfCredits, never retried
28
31
  # ServerError 5xx or a server's error event mid-stream
29
32
  # AuthError 401/403, or an API key variable that is not set
30
33
  # BadRequest other 4xx (a context overflow is one, whatever status;
@@ -105,6 +108,15 @@ module Samagotchi
105
108
  def summary = "out of credits on host #{host}: #{detail}; add credits, then send again"
106
109
  end
107
110
 
111
+ # A 402 whose error.metadata.reason is weight_exceeds_budget (OpenRouter):
112
+ # this one request's credit reservation is larger than the key's whole
113
+ # budget, so no amount of waiting lets it through. Never retried, even
114
+ # when the message reads like the in-flight one.
115
+ class OverBudget < OutOfCredits
116
+ def summary = "request too large for the credit budget on host #{host}: #{detail}; " \
117
+ "lower max_tokens (default.max_tokens) or raise the key's credit limit"
118
+ end
119
+
108
120
  class ServerError < ProviderError
109
121
  def kind = :server
110
122
 
@@ -261,6 +273,9 @@ module Samagotchi
261
273
  REASONING_REFUSED_RE = /reasoning|thinking/i
262
274
  # A 402 about credit held by in-flight requests (see CreditsHeld).
263
275
  CREDITS_HELD_RE = /in-flight requests/i
276
+ # A 402's error.metadata.reason when the request can never fit the
277
+ # key's budget (see OverBudget).
278
+ OVER_BUDGET_REASON = "weight_exceeds_budget"
264
279
  # Seconds to wait before retrying a CreditsHeld without a Retry-After.
265
280
  CREDITS_SETTLE_DELAY = 20.0
266
281
  RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
@@ -285,7 +300,9 @@ module Samagotchi
285
300
  when 429 then RateLimited.new(text, retry_after: parse_retry_after(retry_after), **options)
286
301
  when 408 then ServerError.new(text, retryable: true, **options)
287
302
  when 402
288
- if CREDITS_HELD_RE.match?(message) || CREDITS_HELD_RE.match?(body.to_s)
303
+ if error_reason(body) == OVER_BUDGET_REASON
304
+ OverBudget.new(text, **options)
305
+ elsif CREDITS_HELD_RE.match?(message) || CREDITS_HELD_RE.match?(body.to_s)
289
306
  CreditsHeld.new(text, retry_after: parse_retry_after(retry_after) || CREDITS_SETTLE_DELAY, **options)
290
307
  else
291
308
  OutOfCredits.new(text, **options)
@@ -356,6 +373,14 @@ module Samagotchi
356
373
  message.empty? ? "(empty body)" : message[0, 500]
357
374
  end
358
375
 
376
+ # error.metadata.reason of a JSON error body (OpenRouter), or nil.
377
+ def error_reason(body)
378
+ parsed = parse_json(body.to_s)
379
+ error = parsed["error"] if parsed.is_a?(Hash)
380
+ metadata = error["metadata"] if error.is_a?(Hash)
381
+ metadata["reason"] if metadata.is_a?(Hash)
382
+ end
383
+
359
384
  # +message+ followed by the upstream provider's name and words from
360
385
  # +metadata+ (raw may be text or a JSON error body of its own).
361
386
  def with_upstream(message, metadata)
@@ -69,6 +69,9 @@ module Samagotchi
69
69
  **)
70
70
  end
71
71
 
72
+ # true when a streamed +payload+ carries a tool-call delta.
73
+ def self.tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
74
+
72
75
  # true when +url+ is OpenRouter's API (openrouter.ai or a subdomain).
73
76
  def self.openrouter?(url)
74
77
  host = URI(url.to_s).host.to_s.downcase
@@ -237,7 +240,6 @@ module Samagotchi
237
240
  body = {
238
241
  model: model,
239
242
  messages: cache_breakpoints(Array(messages).map { |message| wire_message(message) }, model),
240
- temperature: 0.0,
241
243
  stream: @stream
242
244
  }
243
245
  body[:stream_options] = { include_usage: true } if @stream
@@ -246,7 +248,9 @@ module Samagotchi
246
248
  body[:tools] = tools
247
249
  body[:tool_choice] = "auto"
248
250
  end
249
- # A configured value replaces chi's own (temperature, max_tokens); nil drops it.
251
+ # No temperature unless configured: each model runs at its provider's
252
+ # default (greedy decoding made thinking models loop). A configured
253
+ # value replaces chi's own (max_tokens); nil drops it.
250
254
  body.merge(options || {}).compact
251
255
  end
252
256
 
@@ -255,13 +259,13 @@ module Samagotchi
255
259
  # would pay for a cache write nothing reads, and a local model named
256
260
  # after Claude must not get Array content.
257
261
  def cache_breakpoints(messages, model)
258
- return messages unless @remote && @purpose == "chat"
262
+ return PromptCache.without_split(messages) unless @remote && @purpose == "chat"
259
263
 
260
264
  PromptCache.mark(messages, model: model)
261
265
  end
262
266
 
263
267
  # The body's fields beyond the conversation and the stream, for the
264
- # log line: "temperature=0.6 presence_penalty=1.5".
268
+ # log line: "temperature=0.6 presence_penalty=1.5", nil for none.
265
269
  def sampling_summary(body)
266
270
  SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
267
271
  end
@@ -11,6 +11,12 @@ module Samagotchi
11
11
  # same prefix plus new messages) reads everything up to it.
12
12
  # Mid and tail system messages (reminders, turn notes) never take the
13
13
  # second mark. Other models' messages pass through untouched.
14
+ #
15
+ # A system message whose `cache_split:` says where its stable part ends
16
+ # (ChatLoop, from SystemPrompt#stable_length) goes as two text parts:
17
+ # the stable part with the mark, the per-session tail (model, location,
18
+ # session) without, so a new session reads the stable part from the
19
+ # cache. `cache_split:` never goes on the wire (#without_split).
14
20
  module PromptCache
15
21
  CONTROL = { type: "ephemeral" }.freeze
16
22
 
@@ -21,16 +27,27 @@ module Samagotchi
21
27
 
22
28
  # +messages+ (wire messages: symbol keys, content a String or an Array
23
29
  # of parts) with the breakpoints set when +model+ is a Claude model;
24
- # the given messages as they are otherwise. The marked messages and
25
- # parts are copies: the caller's are never changed.
30
+ # the given messages as they are otherwise (#without_split). The marked
31
+ # messages and parts are copies: the caller's are never changed.
26
32
  # @return [Array<Hash>]
27
33
  def mark(messages, model:)
28
- return messages unless claude?(model)
34
+ return without_split(messages) unless claude?(model)
29
35
 
30
36
  indexes = [system_index(messages), last_index(messages)].compact.uniq
31
- return messages if indexes.empty?
37
+ messages.each_with_index.map do |message, index|
38
+ next marked(message.except(:cache_split), split: message[:cache_split]) if indexes.include?(index)
32
39
 
33
- messages.each_with_index.map { |message, index| indexes.include?(index) ? marked(message) : message }
40
+ message.key?(:cache_split) ? message.except(:cache_split) : message
41
+ end
42
+ end
43
+
44
+ # +messages+ without the `cache_split:` hints, for a request that sets
45
+ # no breakpoints.
46
+ # @return [Array<Hash>]
47
+ def without_split(messages)
48
+ return messages unless messages.any? { |message| message.key?(:cache_split) }
49
+
50
+ messages.map { |message| message.except(:cache_split) }
34
51
  end
35
52
 
36
53
  # Whether any message in +messages+ carries a breakpoint.
@@ -60,11 +77,19 @@ module Samagotchi
60
77
  end
61
78
  end
62
79
 
63
- # A String becomes one text part; an Array gets the mark on its last
64
- # text part (the last part when none is text).
65
- def marked(message)
80
+ # A String becomes one text part (two at +split+, the mark on the
81
+ # first); an Array gets the mark on its last text part (the last part
82
+ # when none is text).
83
+ def marked(message, split: nil)
66
84
  content = message[:content]
67
- return message.merge(content: [{ type: "text", text: content, cache_control: CONTROL }]) if content.is_a?(String)
85
+ if content.is_a?(String)
86
+ if split.is_a?(Integer) && split.positive? && split < content.length
87
+ return message.merge(content: [{ type: "text", text: content[0, split], cache_control: CONTROL },
88
+ { type: "text", text: content[split..] }])
89
+ end
90
+
91
+ return message.merge(content: [{ type: "text", text: content, cache_control: CONTROL }])
92
+ end
68
93
 
69
94
  target = content.rindex { |part| part.is_a?(Hash) && (part[:type] || part["type"]).to_s == "text" } ||
70
95
  content.rindex { |part| part.is_a?(Hash) }
@@ -5,20 +5,35 @@ require_relative "../token_usage"
5
5
  module Samagotchi
6
6
  module LLM
7
7
  # A response's token counts, never nil: what the server reported
8
- # (:server), else zeros (:none).
9
- Usage = Data.define(:prompt_tokens, :completion_tokens, :source) do
8
+ # (:server), else zeros (:none). +cached_tokens+ is the prompt the server
9
+ # reused from its cache, +cache_write_tokens+ what it wrote to it
10
+ # (OpenRouter reports those for Anthropic models); 0 when not reported.
11
+ Usage = Data.define(:prompt_tokens, :completion_tokens, :source, :cached_tokens, :cache_write_tokens) do
10
12
  # @return [Usage, nil] nil when the payload carries no counts
11
13
  def self.from_payload(payload)
12
14
  counts = TokenUsage.from_payload(payload)
13
15
  return nil unless counts
14
16
 
15
17
  new(prompt_tokens: counts.prompt_tokens.to_i, completion_tokens: counts.completion_tokens.to_i,
16
- source: :server)
18
+ source: :server, cached_tokens: counts.cached_tokens.to_i,
19
+ cache_write_tokens: counts.cache_write_tokens.to_i)
17
20
  end
18
21
 
19
22
  def self.none = new(prompt_tokens: 0, completion_tokens: 0, source: :none)
20
23
 
24
+ def initialize(prompt_tokens:, completion_tokens:, source:, cached_tokens: 0, cache_write_tokens: 0)
25
+ super
26
+ end
27
+
21
28
  def total_tokens = prompt_tokens + completion_tokens
29
+
30
+ # The request's prompt-cache counts for a :generation_completed event
31
+ # and the log ({} when the server reported none).
32
+ def cache_fields
33
+ return {} unless source == :server
34
+
35
+ TokenUsage.cache_fields(prompt: prompt_tokens, cached: cached_tokens, cache_write: cache_write_tokens)
36
+ end
22
37
  end
23
38
  end
24
39
  end
@@ -106,11 +106,13 @@ module Samagotchi
106
106
  requested_model: event[:requested_model],
107
107
  content_length: event[:content_length],
108
108
  thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason],
109
- stopped_by: event[:stopped_by])
109
+ stopped_by: event[:stopped_by], prompt: event[:prompt_tokens],
110
+ cached: event[:cached_tokens], cache_write: event[:cache_write_tokens])
110
111
  return unless event[:stopped_by]
111
112
 
112
- # A plugin cut this generation (stop_generation); the turn goes on.
113
- log(:info, :generation_stopped, iteration: event[:iteration], bundle: event[:stopped_by],
113
+ # A plugin (stop_generation) or a steer (Engine#cut_for_steer, by
114
+ # "steer") cut this generation; the turn goes on.
115
+ log(:info, :generation_stopped, iteration: event[:iteration], by: event[:stopped_by],
114
116
  reason: event[:stop_reason].to_s[0, 200], thinking_chars: event[:thinking_chars], ms: ms)
115
117
  end
116
118
 
@@ -264,8 +264,9 @@ module Samagotchi
264
264
 
265
265
  # Make +bundle+'s tools in +registry+ the +specs+: its tools not in
266
266
  # them go; a new one, or one whose schema or label changed, is
267
- # registered (a changed one again, at the end); an unchanged one is
268
- # kept as it is. A name another source has is left out. Called on
267
+ # registered (a changed one again, in its place: Tools::Registry
268
+ # orders bundle tools by bundle and name); an unchanged one is kept
269
+ # as it is. A name another source has is left out. Called on
269
270
  # the turn thread (Engine#apply_staged_tools!).
270
271
  # @return [Hash] {changed: Boolean, skipped: [String] (why)}
271
272
  def self.apply_tools(registry, bundle, specs, context)
@@ -4,6 +4,7 @@ require_relative "model_profile"
4
4
  require_relative "prompt_literal_guard"
5
5
  require_relative "vision_context"
6
6
  require_relative "tool_response"
7
+ require_relative "steer"
7
8
 
8
9
  module Samagotchi
9
10
  # Formats a message list into a prompt string ready for the /completion endpoint.
@@ -168,7 +169,9 @@ module Samagotchi
168
169
  # peer session), and a turn note carries an error's text: escaped like
169
170
  # user content so it can't close its turn.
170
171
  role = ESCAPED_KINDS.include?(message[:kind].to_s) ? "user" : message[:role]
171
- PromptLiteralGuard.escape(message[:content], profile: profile, role: role)
172
+ # A steer or merged input is led by a header naming its sender (added
173
+ # after the escape: the header has no literals to guard).
174
+ Steer.wire_text(message, PromptLiteralGuard.escape(message[:content], profile: profile, role: role))
172
175
  end
173
176
 
174
177
  private_class_method :image_lines, :format_with_turn_markers, :ends_in_tool_response?, :tool_response_blocks,
@@ -16,7 +16,7 @@ module Samagotchi
16
16
  # paths and SessionManager background workers.
17
17
  #
18
18
  # Each finished turn leaves a record (status, timings, model, prompt and
19
- # completion tokens, tool calls, iterations, retries); session totals are
19
+ # completion tokens, tool calls, iterations, retries, cuts); session totals are
20
20
  # sums over the records, kept as running counters as records are added, so
21
21
  # a worker that stops and wakes again (a new collector) keeps counting:
22
22
  # #session_id= loads the records already saved.
@@ -78,10 +78,11 @@ module Samagotchi
78
78
  :gen_prompt_max,
79
79
  :gen_open,
80
80
  # The rest of this generation's server report: the largest cached and
81
- # reasoning counts, the last cost, and the server's speeds and decode
81
+ # reasoning counts (and cache writes), the last cost, and the server's speeds and decode
82
82
  # time (llama.cpp's timings); and when its first content, reasoning or
83
83
  # tool call chunk came (an estimated speed runs from there).
84
84
  :gen_cached_max,
85
+ :gen_cache_write_max,
85
86
  :gen_reasoning_max,
86
87
  :gen_cost,
87
88
  :gen_decode_tps,
@@ -102,6 +103,7 @@ module Samagotchi
102
103
  # tokens of the generations with a speed. The last speeds and where the
103
104
  # decode one came from (server / estimate).
104
105
  :cached_sum,
106
+ :cache_write_sum,
105
107
  :reasoning_sum,
106
108
  :cost_sum,
107
109
  :decode_ms_sum,
@@ -110,6 +112,11 @@ module Samagotchi
110
112
  :last_prefill_tps,
111
113
  :tps_source,
112
114
  :retries,
115
+ # Generations a plugin cut (stop_generation: loop-guard's thinking
116
+ # watch), not a steer's cut; and ones that ended at the output cap
117
+ # (finish_reason length). `retries` counts the network's only.
118
+ :cuts,
119
+ :capped,
113
120
  :model,
114
121
  :id,
115
122
  :started_at,
@@ -265,6 +272,7 @@ module Samagotchi
265
272
  end
266
273
  when :generation_completed, :generation_cancelled
267
274
  record_served_model(event)
275
+ count_cut(event)
268
276
  record_generation_completed
269
277
  when :generation_retrying
270
278
  # The retry streams from the start: its counts replace the ones so far.
@@ -364,6 +372,8 @@ module Samagotchi
364
372
  iterations_total: totals[:iterations] + (turn&.iteration_count || 0),
365
373
  gen_latency_ms: (totals[:gen_ms] + (turn&.gen_latency_accum || 0)).round,
366
374
  retries: totals[:retries] + (turn&.retries || 0),
375
+ cuts: totals[:cuts] + (turn&.cuts || 0),
376
+ capped: totals[:capped] + (turn&.capped || 0),
367
377
  started_at: @started_at,
368
378
  last_activity_at: @last_activity_at,
369
379
  # From the session's first start (an earlier process's too), to now.
@@ -386,6 +396,7 @@ module Samagotchi
386
396
  prompt_sum: add.call(:prompt_sum),
387
397
  completion_sum: add.call(:completion_sum),
388
398
  cached_sum: add.call(:cached_sum),
399
+ cache_write_sum: add.call(:cache_write_sum),
389
400
  reasoning_sum: add.call(:reasoning_sum),
390
401
  cost_sum: add.call(:cost_sum),
391
402
  decode_ms_sum: decode_ms.round,
@@ -435,6 +446,7 @@ module Samagotchi
435
446
  gen_prompt_max: 0,
436
447
  gen_open: false,
437
448
  gen_cached_max: 0,
449
+ gen_cache_write_max: 0,
438
450
  gen_reasoning_max: 0,
439
451
  generations: 0,
440
452
  prompt_last: nil,
@@ -443,11 +455,14 @@ module Samagotchi
443
455
  completion_sum: 0,
444
456
  token_sources: [],
445
457
  cached_sum: 0,
458
+ cache_write_sum: 0,
446
459
  reasoning_sum: 0,
447
460
  cost_sum: nil,
448
461
  decode_ms_sum: 0,
449
462
  decode_tokens_sum: 0,
450
463
  retries: 0,
464
+ cuts: 0,
465
+ capped: 0,
451
466
  model: nil,
452
467
  id: event[:turn_id] || SecureRandom.uuid,
453
468
  started_at: now.iso8601(3),
@@ -500,6 +515,15 @@ module Samagotchi
500
515
  end
501
516
  end
502
517
 
518
+ def count_cut(event)
519
+ @mutex.synchronize do
520
+ next unless @turn
521
+
522
+ @turn.cuts += 1 if event[:stopped_by] && event[:stopped_by].to_s != "steer"
523
+ @turn.capped += 1 if event[:finish_reason].to_s == "length"
524
+ end
525
+ end
526
+
503
527
  def record_generation_completed
504
528
  @mutex.synchronize { close_generation if @turn&.gen_open }
505
529
  end
@@ -544,6 +568,7 @@ module Samagotchi
544
568
  # here: it has no speed. Caller holds the mutex.
545
569
  def close_server_report(turn, completion)
546
570
  turn.cached_sum += turn.gen_cached_max
571
+ turn.cache_write_sum += turn.gen_cache_write_max
547
572
  turn.reasoning_sum += turn.gen_reasoning_max
548
573
  turn.cost_sum = turn.cost_sum.to_f + turn.gen_cost if turn.gen_cost
549
574
  turn.last_prefill_tps = turn.gen_prefill_tps.round(1) if turn.gen_prefill_tps
@@ -582,6 +607,7 @@ module Samagotchi
582
607
  @turn.gen_had_server = false
583
608
  @turn.gen_estimate_sum = 0
584
609
  @turn.gen_cached_max = 0
610
+ @turn.gen_cache_write_max = 0
585
611
  @turn.gen_reasoning_max = 0
586
612
  @turn.gen_cost = nil
587
613
  @turn.gen_decode_tps = @turn.gen_prefill_tps = @turn.gen_decode_ms = nil
@@ -605,7 +631,9 @@ module Samagotchi
605
631
  tool_calls: turn.tool_calls,
606
632
  tool_errors: turn.tool_errors,
607
633
  iterations: turn.iteration_count,
608
- retries: turn.retries
634
+ retries: turn.retries,
635
+ cuts: turn.cuts,
636
+ capped: turn.capped
609
637
  }.merge(turn_usage_fields(turn))
610
638
  end
611
639
 
@@ -622,6 +650,7 @@ module Samagotchi
622
650
  prefill_tps: turn.last_prefill_tps,
623
651
  tps_source: turn.tps_source
624
652
  }
653
+ fields[:cache_write_tokens_sum] = turn.cache_write_sum if turn.cache_write_sum.positive?
625
654
  fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
626
655
  fields
627
656
  end
@@ -708,6 +737,7 @@ module Samagotchi
708
737
  turn.gen_prompt_max = [turn.gen_prompt_max, usage.prompt_tokens.to_i].max
709
738
  turn.gen_completion_max = [turn.gen_completion_max, usage.completion_tokens.to_i].max
710
739
  turn.gen_cached_max = [turn.gen_cached_max, usage.cached_tokens.to_i].max
740
+ turn.gen_cache_write_max = [turn.gen_cache_write_max, usage.cache_write_tokens.to_i].max
711
741
  turn.gen_reasoning_max = [turn.gen_reasoning_max, usage.reasoning_tokens.to_i].max
712
742
  turn.gen_cost = usage.cost if usage.cost
713
743
  if usage.predicted_per_second && usage.predicted_ms
@@ -857,7 +887,8 @@ module Samagotchi
857
887
  # (#snapshot adds the running turn). Caller holds the mutex.
858
888
  def reset_totals
859
889
  @totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
860
- gen_ms: 0, retries: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, reasoning_sum: 0,
890
+ gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
891
+ reasoning_sum: 0,
861
892
  cost_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
862
893
  last_prefill_tps: nil, tps_source: nil }
863
894
  end
@@ -870,7 +901,10 @@ module Samagotchi
870
901
  totals[:iterations] += number(record[:iterations])
871
902
  totals[:gen_ms] += number(record[:gen_ms])
872
903
  totals[:retries] += number(record[:retries])
904
+ totals[:cuts] += number(record[:cuts])
905
+ totals[:capped] += number(record[:capped])
873
906
  totals[:cached_sum] += number(record[:cached_tokens_sum])
907
+ totals[:cache_write_sum] += number(record[:cache_write_tokens_sum])
874
908
  totals[:reasoning_sum] += number(record[:reasoning_tokens])
875
909
  totals[:cost_sum] += number(record[:cost])
876
910
  totals[:decode_ms_sum] += number(record[:decode_ms])
@@ -10,20 +10,41 @@ module Samagotchi
10
10
  #
11
11
  # The user's own lines merged into a running turn are marked kind:
12
12
  # "input": they are part of that turn, not a turn of their own, so a
13
- # reloaded history keeps the turn in one piece.
13
+ # reloaded history keeps the turn in one piece. A line can be a Line
14
+ # naming who sent it (a worker's input from chi send, a delegating parent,
15
+ # a plugin); its input message then carries that source:. The user's own
16
+ # lines carry none, so their saved bytes are as they always were.
14
17
  module Steer
15
18
  KIND = "steer"
16
19
  INPUT_KIND = "input"
17
20
 
21
+ # A drain item for an input line with its sender (source nil = the user).
22
+ # A plain String is still a valid item: the user's line.
23
+ Line = Data.define(:text, :source)
24
+
25
+ # The client ids whose input is not the user's own words. Literals, not
26
+ # SendCommand::CLIENT_ID / Tools::Delegate::CLIENT_PREFIX, so this file
27
+ # needs no requires (a spec pins them equal).
28
+ CHI_SEND_CLIENT = "cli:send"
29
+ DELEGATE_CLIENT_PREFIX = "delegate:"
30
+ PLUGIN_CLIENT = "plugin"
31
+
18
32
  # What one drain brought: the merged user text (nil when none) and how
19
- # many lines made it, and the steers in order.
20
- Merge = Struct.new(:content, :count, :steers, keyword_init: true) do
33
+ # many lines made it, the input messages' text grouped by sender
34
+ # (consecutive lines from one sender form one group), and the steers in
35
+ # order.
36
+ Merge = Struct.new(:content, :count, :inputs, :steers, keyword_init: true) do
21
37
  def empty? = content.nil? && steers.empty?
22
38
 
23
- # The messages it appends: the user's first (marked merged input),
24
- # then each steer.
39
+ # The messages it appends: the merged input first (one message per
40
+ # sender run, marked kind input, source: when not the user's), then
41
+ # each steer.
25
42
  def messages
26
- list = content ? [{ role: "user", kind: INPUT_KIND, content: content }] : []
43
+ list = inputs.map do |input|
44
+ message = { role: "user", kind: INPUT_KIND }
45
+ message[:source] = input[:source] if input[:source]
46
+ message.merge(content: input[:content])
47
+ end
27
48
  list + steers.map { |steer| Steer.message(**steer) }
28
49
  end
29
50
 
@@ -36,8 +57,50 @@ module Samagotchi
36
57
  end
37
58
  end
38
59
 
60
+ # The one line the model reads above a steer or merged input (the wire
61
+ # only; the saved content stays raw): who sent it, and that it should
62
+ # follow it or, if it asks for nothing, carry on.
63
+ HEADER_TAIL = "mid-task. Follow it; if it asks for nothing, carry on with the task.]"
64
+ SENDERS = {
65
+ "user" => "from the user",
66
+ "parent_agent" => "from the parent agent that started this session",
67
+ "chi_send" => "sent with chi send",
68
+ "plugin_send" => "sent by a plugin"
69
+ }.freeze
70
+ USER_HEADER = "[Steer #{SENDERS["user"]}, #{HEADER_TAIL}".freeze
71
+
39
72
  module_function
40
73
 
74
+ # The saved source of a worker input line from +client_id+: nil for the
75
+ # user (no client id, the web, an attached TUI, anything unknown).
76
+ def source_for_client(client_id)
77
+ id = client_id.to_s
78
+ if id == CHI_SEND_CLIENT then "chi_send"
79
+ elsif id.start_with?(DELEGATE_CLIENT_PREFIX) then "parent_agent"
80
+ elsif id == PLUGIN_CLIENT then "plugin_send"
81
+ end
82
+ end
83
+
84
+ # The header for a steer or merged input message, else nil. No source
85
+ # (or "") is the user's; an input with an unknown source too; a steer
86
+ # with an unknown source is a plugin's (its bundle or hook label).
87
+ def header(message)
88
+ kind = (message[:kind] || message["kind"]).to_s
89
+ return unless [KIND, INPUT_KIND].include?(kind)
90
+
91
+ source = (message[:source] || message["source"]).to_s
92
+ source = "user" if source.empty?
93
+ sender = SENDERS[source] || (kind == KIND ? "from the #{source} plugin" : SENDERS["user"])
94
+ "[Steer #{sender}, #{HEADER_TAIL}"
95
+ end
96
+
97
+ # The text a steer or merged input goes out as: its header, a newline,
98
+ # then +text+ (the message's content by default). Other messages: +text+.
99
+ def wire_text(message, text = (message[:content] || message["content"]).to_s)
100
+ header = header(message)
101
+ header ? "#{header}\n#{text}" : text
102
+ end
103
+
41
104
  def message(text:, source:)
42
105
  { role: "user", kind: KIND, source: source.to_s, content: text.to_s }
43
106
  end
@@ -46,6 +109,24 @@ module Samagotchi
46
109
  (message[:kind] || message["kind"]).to_s == KIND
47
110
  end
48
111
 
112
+ # The sources of a steer a person sent (a continue card's text, from the
113
+ # user or a parent agent's chi answer), not a plugin's prod.
114
+ PERSON_SOURCES = %w[user parent_agent].freeze
115
+
116
+ # The senders whose message may cut a generation that has only been
117
+ # thinking (Engine#cut_for_steer): the user (no source), chi send, a
118
+ # parent agent. A plugin's never does.
119
+ CUT_SOURCES = ["", "user", "chi_send", "parent_agent"].freeze
120
+
121
+ def cuts?(source)
122
+ CUT_SOURCES.include?(source.to_s)
123
+ end
124
+
125
+ # A steer a person sent: what a recap and a side question keep.
126
+ def person?(message)
127
+ steer?(message) && PERSON_SOURCES.include?((message[:source] || message["source"]).to_s)
128
+ end
129
+
49
130
  # A user message that is a prompt or the user's steering, not a steer:
50
131
  # what "the last prompt" and "user turns" count.
51
132
  def prompt?(message)
@@ -105,8 +186,22 @@ module Samagotchi
105
186
  text = (item[:text] || item["text"]).to_s.strip
106
187
  { source: (item[:source] || item["source"]).to_s, text: text } unless text.empty?
107
188
  end
108
- content = lines.map { |line| line.to_s.strip }.reject(&:empty?).join("\n\n")
109
- Merge.new(content: content.empty? ? nil : content, count: content.empty? ? 0 : lines.length, steers: steers)
189
+ inputs = input_groups(lines)
190
+ content = inputs.map { |input| input[:content] }.join("\n\n")
191
+ Merge.new(content: content.empty? ? nil : content, count: content.empty? ? 0 : lines.length,
192
+ inputs: inputs, steers: steers)
193
+ end
194
+
195
+ # Non-blank input lines as [{content:, source:}], one per run of lines
196
+ # from the same sender.
197
+ def input_groups(lines)
198
+ pairs = lines.filter_map do |line|
199
+ text = (line.is_a?(Line) ? line.text : line).to_s.strip
200
+ [text, line.is_a?(Line) ? line.source : nil] unless text.empty?
201
+ end
202
+ pairs.chunk_while { |a, b| a[1] == b[1] }.map do |run|
203
+ { content: run.map(&:first).join("\n\n"), source: run.first[1] }
204
+ end
110
205
  end
111
206
  end
112
207
  end
@@ -14,8 +14,9 @@ module Samagotchi
14
14
  # The system prompt an Engine gives its model: the base prompt (tool
15
15
  # declarations and call syntax for the raw-prompt loop, none for the chat
16
16
  # loop, the shared guidance) and around it the thinking token, rg
17
- # guidance, AGENT.md, where the session runs, the memory indexes, the
18
- # identity memory and the preloaded memories.
17
+ # guidance, the identity memory, the preloaded memories, AGENT.md, the
18
+ # memory indexes and, last, the model, where the session runs and the
19
+ # session (#system_prompt_with_index has the order and why).
19
20
  #
20
21
  # What changes during a session (the profile after a model switch, the
21
22
  # tools a plugin replaces, the attached session, the thinking level) is
@@ -77,8 +78,16 @@ module Samagotchi
77
78
  # indexes and memories again (a model or profile switch, changed tools).
78
79
  def reset!
79
80
  @built = nil
81
+ @stable_lengths = nil
80
82
  end
81
83
 
84
+ # The length of +prompt+'s stable part when it is a chat prompt #build
85
+ # made: everything before the model, location and session lines, which
86
+ # a remote prompt cache can keep for every session (PromptCache). nil
87
+ # for any other text, or a prompt without those lines.
88
+ # @return [Integer, nil]
89
+ def stable_length(prompt) = @stable_lengths&.[](prompt)
90
+
82
91
  # The base prompt (specs, plugins' declarations), with Gemma's tool
83
92
  # declarations at its end as #build has them.
84
93
  def base(chat: false, thinking: nil)
@@ -186,7 +195,7 @@ module Samagotchi
186
195
  6. Use write for full-file rewrites or creating new files.
187
196
 
188
197
  Memory convention:
189
- Project scope: one folder per git repository, shared by its worktrees and subdirectories (path shown above)
198
+ Project scope: one folder per git repository, shared by its worktrees and subdirectories (its path is on the "Project memories folder:" line below)
190
199
  System scope: ~/.config/samagotchi/memories/ (cross-project)
191
200
  memory_read accepts optional scope (project|system).
192
201
  memory_write requires explicit scope and entry name.
@@ -229,16 +238,28 @@ module Samagotchi
229
238
  # @param chat [Boolean] no Gemma thinking token (the chat API's template
230
239
  # decides about thinking)
231
240
  # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
241
+ #
242
+ # Ordered for the servers' prompt caches (they reuse only an exact token
243
+ # prefix): what every session shares first, what changes least before
244
+ # what changes more, and the per-session lines last. The global parts
245
+ # (identity, preloads) come before the project's AGENT.md, the memory
246
+ # indexes (a memory write changes them) after both; the model, the
247
+ # location and the session close the prompt (Gemma's tool declarations
248
+ # still follow them).
232
249
  def system_prompt_with_index(base, chat: false, thinking: nil)
233
- project_index = read_memory_index("project")
234
- system_index = read_memory_index("system")
235
- project_description = project_specific_description
236
250
  thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
237
251
  memory_sections = [
238
- "Project memories:\n#{project_index}",
239
- "System memories:\n#{system_index}"
252
+ "Project memories:\n#{read_memory_index("project")}",
253
+ "System memories:\n#{read_memory_index("system")}"
240
254
  ].join("\n\n")
241
- prompt = [thinking_token + base, rg_guidance, project_description, project_location, current_model, current_session, memory_sections, system_identity_section, explicit_memory_section].compact.join("\n")
255
+ stable = [thinking_token + base, rg_guidance, system_identity_section, explicit_memory_section,
256
+ project_specific_description, memory_sections]
257
+ stable_text = stable.compact.join("\n")
258
+ volatile = [current_model, project_location, current_session].compact
259
+ return with_tail_declarations(stable_text, chat: chat) if volatile.empty?
260
+
261
+ prompt = "#{stable_text}\n#{volatile.join("\n")}"
262
+ (@stable_lengths ||= {})[prompt] = stable_text.length + 1 if chat
242
263
  with_tail_declarations(prompt, chat: chat)
243
264
  end
244
265