samagotchi 0.51.0 → 0.53.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/README.md +1 -1
  4. data/bin/chi +11 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/cli.md +23 -8
  7. data/docs/configuration.md +59 -19
  8. data/docs/memory.md +11 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/releasing.md +6 -3
  11. data/docs/sessions.md +5 -1
  12. data/docs/testing.md +8 -1
  13. data/lib/samagotchi/analytics_file.rb +35 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  15. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  16. data/lib/samagotchi/bridge.rb +10 -13
  17. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  18. data/lib/samagotchi/bundle_command.rb +9 -13
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/children_status.rb +1 -1
  22. data/lib/samagotchi/commands/registry.rb +2 -1
  23. data/lib/samagotchi/config.rb +50 -182
  24. data/lib/samagotchi/config_text_edit.rb +8 -32
  25. data/lib/samagotchi/context_sources.rb +0 -7
  26. data/lib/samagotchi/context_window.rb +2 -2
  27. data/lib/samagotchi/continue_offer.rb +5 -4
  28. data/lib/samagotchi/cut_policy.rb +0 -1
  29. data/lib/samagotchi/delivery.rb +23 -0
  30. data/lib/samagotchi/engine.rb +63 -18
  31. data/lib/samagotchi/engine_peers.rb +1 -0
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_config.rb +183 -0
  34. data/lib/samagotchi/host_registry.rb +18 -10
  35. data/lib/samagotchi/last_turn.rb +48 -0
  36. data/lib/samagotchi/live_versions.rb +0 -6
  37. data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
  38. data/lib/samagotchi/llm_context_override.rb +3 -22
  39. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  40. data/lib/samagotchi/memory_bundle/builder.rb +23 -21
  41. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  42. data/lib/samagotchi/memory_bundle/installer.rb +4 -4
  43. data/lib/samagotchi/memory_bundle/manifest.rb +6 -28
  44. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  45. data/lib/samagotchi/memory_bundle/provenance.rb +8 -17
  46. data/lib/samagotchi/model_profile.rb +4 -4
  47. data/lib/samagotchi/model_settings.rb +46 -0
  48. data/lib/samagotchi/plugin/context.rb +3 -3
  49. data/lib/samagotchi/plugin/sessions.rb +2 -2
  50. data/lib/samagotchi/queued_command.rb +35 -0
  51. data/lib/samagotchi/reminder_store.rb +0 -27
  52. data/lib/samagotchi/reply_wait.rb +11 -13
  53. data/lib/samagotchi/sampling_settings.rb +4 -4
  54. data/lib/samagotchi/self_report.rb +8 -5
  55. data/lib/samagotchi/send_command.rb +40 -46
  56. data/lib/samagotchi/session.rb +45 -24
  57. data/lib/samagotchi/session_commands.rb +68 -11
  58. data/lib/samagotchi/session_inbox.rb +18 -9
  59. data/lib/samagotchi/session_manager.rb +44 -11
  60. data/lib/samagotchi/session_metrics.rb +15 -20
  61. data/lib/samagotchi/session_setup.rb +30 -0
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/steer_cut.rb +9 -2
  64. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  66. data/lib/samagotchi/terminal_ui/formatting.rb +26 -4
  67. data/lib/samagotchi/terminal_ui.rb +21 -4
  68. data/lib/samagotchi/thinking.rb +67 -7
  69. data/lib/samagotchi/thinking_command.rb +72 -0
  70. data/lib/samagotchi/tools/builtins.rb +7 -2
  71. data/lib/samagotchi/tools/peers.rb +3 -2
  72. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +4 -3
  75. data/lib/samagotchi/web/app.rb +56 -13
  76. data/lib/samagotchi/web/public/app.js +131 -20
  77. data/lib/samagotchi/web/public/command_complete.js +7 -2
  78. data/lib/samagotchi/web/public/data.js +8 -4
  79. data/lib/samagotchi/web/public/format.js +8 -4
  80. data/lib/samagotchi/web/public/index.html +8 -5
  81. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  82. data/lib/samagotchi/web/public/model_pick.js +3 -1
  83. data/lib/samagotchi/web/public/thinking_chip.js +179 -0
  84. data/lib/samagotchi/web/public/timing.js +15 -0
  85. data/lib/samagotchi/web/public/turn_events.js +23 -0
  86. data/lib/samagotchi/web/public/turn_view.js +328 -310
  87. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  88. data/lib/samagotchi/web/session_summary.rb +1 -1
  89. data/lib/samagotchi/worker.rb +43 -42
  90. data/lib/samagotchi/worker_inbound.rb +5 -5
  91. data/lib/samagotchi/worker_wakes.rb +0 -2
  92. data/lib/samagotchi/yaml_lines.rb +72 -0
  93. metadata +13 -1
@@ -6,6 +6,7 @@ require "time"
6
6
  require "securerandom"
7
7
  require "rbconfig"
8
8
  require_relative "client_id"
9
+ require_relative "analytics_file"
9
10
  require_relative "atomic_file"
10
11
  require_relative "config"
11
12
  require_relative "delivery"
@@ -14,6 +15,7 @@ require_relative "session"
14
15
  require_relative "session_inbox"
15
16
  require_relative "session_metrics"
16
17
  require_relative "llm_context_strategy"
18
+ require_relative "session_setup"
17
19
  require_relative "host_registry"
18
20
  require_relative "utf8_default"
19
21
  require_relative "turn_note"
@@ -163,8 +165,8 @@ module Samagotchi
163
165
  # @param images_from [String, nil] the session dir the seed's images are in
164
166
  # @param title [String, nil] what the lists show before the first turn
165
167
  # (the prompt's preview by default, else the seed's first user message)
166
- # @param llm_context [LLMContextOverride, nil] the session's own
167
- # llm_context values (chi --llm-context; a fork copies its parent's)
168
+ # @param setup [SessionSetup] the session's own settings (chi
169
+ # --llm-context; a fork and a continue copy their session's)
168
170
  # @param continues [String, nil] the session this one continues (a
169
171
  # session field; #continue_session)
170
172
  # @param note [String, nil] a context note the session starts with,
@@ -176,7 +178,7 @@ module Samagotchi
176
178
  # and #model_warning: an id its host's saved list doesn't have
177
179
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
178
180
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
179
- title: nil, delegate: false, llm_context: nil, continues: nil, note: nil,
181
+ title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
180
182
  note_source: "chi", before_spawn: nil)
181
183
  sd = state_dir || Session.default_state_dir
182
184
  # The resolved ref is stored (a resumed session keeps its model when an
@@ -198,7 +200,7 @@ module Samagotchi
198
200
  parent_id: parent_id,
199
201
  messages: messages,
200
202
  delegate: delegate,
201
- llm_context: llm_context,
203
+ setup: setup,
202
204
  continues: continues
203
205
  )
204
206
  session.model_warning = model_warning
@@ -232,7 +234,7 @@ module Samagotchi
232
234
 
233
235
  # Start the next link of a chain: a new session that continues
234
236
  # +id_or_ref+ (Session#continues) in its folder, on its model (the name
235
- # it was typed as, so an alias holds) and with its own llm_context,
237
+ # it was typed as, so an alias holds) and with its own setup (SessionSetup),
236
238
  # starting with a context note from chi: the link and the previous
237
239
  # link's recap (SessionChain.note_text). The previous link is archived
238
240
  # first, with its finished delegates (archive_session's rules: a turn
@@ -363,7 +365,7 @@ module Samagotchi
363
365
  ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
364
366
  end
365
367
  link = spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
366
- model_name: previous.model_typed || previous.model_name, llm_context: previous.llm_context,
368
+ model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
367
369
  continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
368
370
  note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn)
369
371
  link.moved_children = move
@@ -515,7 +517,7 @@ module Samagotchi
515
517
 
516
518
  GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
517
519
  # Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
518
- SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
520
+ SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING].freeze
519
521
 
520
522
  # List all sessions, reading status from persisted session.json files.
521
523
  # +project_root+: only that project's sessions (nil: every session).
@@ -973,7 +975,7 @@ module Samagotchi
973
975
  # session keeps. "pid" is the pid file workers wrote until 2026-10-01:
974
976
  # nothing writes or reads it now, but a folder from before still has one
975
977
  # and is still empty.
976
- EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerSidecar::FILE, "analytics.json"].freeze
978
+ EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerSidecar::FILE, AnalyticsFile::NAME].freeze
977
979
  EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
978
980
  EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
979
981
 
@@ -1057,17 +1059,48 @@ module Samagotchi
1057
1059
  path = File.join(dir, name)
1058
1060
  if EMPTY_SKELETON_DIRS.include?(name)
1059
1061
  File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.empty?(path))
1060
- elsif name == "analytics.json"
1061
- Array(JSON.parse(File.read(path))["turn_records"]).empty?
1062
+ elsif name == AnalyticsFile::NAME
1063
+ analytics_empty?(path)
1062
1064
  # Cards and notices from before any turn (Bridge::CardStore).
1063
1065
  elsif name == "cards.json"
1064
- JSON.parse(File.read(path))["turns"].to_i.zero?
1066
+ cards_empty?(path)
1065
1067
  else
1066
1068
  EMPTY_SKELETON_FILES.include?(name)
1067
1069
  end
1068
1070
  end
1069
1071
  end
1070
1072
 
1073
+ # A folder's analytics.json is empty only when it is a JSON object with
1074
+ # an empty turn_records array. Anything unreadable, not JSON, not an
1075
+ # object, the wrong type, or turn_records the wrong shape reads as
1076
+ # "not empty": a broken analytics keeps the folder instead of crashing
1077
+ # the retention sweep (or of a wrong-shape one reading as empty).
1078
+ def self.analytics_empty?(path)
1079
+ data = read_json(path)
1080
+ turns = data.is_a?(Hash) ? data["turn_records"] : nil
1081
+ turns.is_a?(Array) && turns.empty?
1082
+ end
1083
+
1084
+ # A folder's cards.json is empty only when it is a JSON object whose
1085
+ # turns is an integer zero. Anything unreadable, not JSON, not an
1086
+ # object, the wrong type, or turns not an integer reads as "not empty":
1087
+ # a broken or wrong-shape cards file keeps the folder instead of
1088
+ # raising (Array#/Hash#to_i) or reading as empty.
1089
+ def self.cards_empty?(path)
1090
+ data = read_json(path)
1091
+ turns = data.is_a?(Hash) ? data["turns"] : nil
1092
+ turns.is_a?(Integer) && turns.zero?
1093
+ end
1094
+
1095
+ # A session-dir JSON file's contents, or nil when it is missing,
1096
+ # unreadable, not JSON or not an object (mirrors AnalyticsFile#read): a
1097
+ # broken file reads as none, so its folder is kept, not deleted.
1098
+ def self.read_json(path)
1099
+ JSON.parse(File.read(path))
1100
+ rescue JSON::ParserError, SystemCallError
1101
+ nil
1102
+ end
1103
+
1071
1104
  # Delete one session: its <id>.json, the whole <id>/ directory
1072
1105
  # (history sidecars, input, output, notes, images) and its attached
1073
1106
  # context (ContextSources: its sources, subscriptions, mute markers). The CLI, the TUI's
@@ -5,6 +5,7 @@ require "monitor"
5
5
  require "time"
6
6
  require "fileutils"
7
7
  require "securerandom"
8
+ require_relative "analytics_file"
8
9
  require_relative "atomic_file"
9
10
  require_relative "token_usage"
10
11
  require_relative "model_price"
@@ -172,8 +173,8 @@ module Samagotchi
172
173
  # @param budget_tokens [Integer, Proc, nil] see .saved_context_pct
173
174
  # @return [SavedSummary, nil]
174
175
  def self.saved_summary(session_dir, budget_tokens: nil)
175
- data = JSON.parse(File.read(File.join(session_dir, "analytics.json")))
176
- return nil unless data.is_a?(Hash)
176
+ data = AnalyticsFile.read(session_dir)
177
+ return nil unless data
177
178
 
178
179
  tokens = data["tokens"].is_a?(Hash) ? data["tokens"] : {}
179
180
  count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
@@ -182,7 +183,7 @@ module Samagotchi
182
183
  reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
183
184
  cost_estimate_sum: count.call("cost_estimate_sum"),
184
185
  memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
185
- rescue JSON::ParserError, SystemCallError, TypeError
186
+ rescue TypeError
186
187
  nil
187
188
  end
188
189
 
@@ -217,10 +218,10 @@ module Samagotchi
217
218
  def self.saved_tool_records(session_dir, turn_id)
218
219
  return [] if turn_id.to_s.empty?
219
220
 
220
- records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["tool_records"]
221
+ records = AnalyticsFile.read(session_dir)&.dig("tool_records")
221
222
  Array(records).select { |r| r.is_a?(Hash) && r["turn_id"] == turn_id }
222
223
  .sort_by { |r| [r["iteration"].to_i, r["call_index"].to_i] }
223
- rescue JSON::ParserError, SystemCallError, TypeError
224
+ rescue TypeError
224
225
  []
225
226
  end
226
227
 
@@ -234,9 +235,9 @@ module Samagotchi
234
235
  def self.saved_turn_record(session_dir, turn_id)
235
236
  return nil if turn_id.to_s.empty?
236
237
 
237
- records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["turn_records"]
238
+ records = AnalyticsFile.read(session_dir)&.dig("turn_records")
238
239
  Array(records).find { |r| r.is_a?(Hash) && r["id"] == turn_id }
239
- rescue JSON::ParserError, SystemCallError, TypeError
240
+ rescue TypeError
240
241
  nil
241
242
  end
242
243
 
@@ -422,8 +423,7 @@ module Samagotchi
422
423
  # (XDG) location; passing an explicit nil would override it and break
423
424
  # File.join.
424
425
  FileUtils.mkdir_p(dir = session_dir(sid, state_dir || @state_dir))
425
- path = File.join(dir, "analytics.json")
426
- AtomicFile.write(path, JSON.pretty_generate(data) + "\n")
426
+ AtomicFile.write(AnalyticsFile.path(dir), JSON.pretty_generate(data) + "\n")
427
427
  @mutex.synchronize { @persisted_turns = [@persisted_turns, turn_count].max }
428
428
  true
429
429
  rescue StandardError
@@ -977,11 +977,8 @@ module Samagotchi
977
977
  # broken file, or an older shape: nothing, or zeros where keys are
978
978
  # missing). Caller holds the mutex.
979
979
  def load_persisted
980
- path = File.join(session_dir(@session_id, @state_dir), "analytics.json")
981
- return unless File.file?(path)
982
-
983
- prior = JSON.parse(File.read(path))
984
- return unless prior.is_a?(Hash)
980
+ prior = AnalyticsFile.read(session_dir(@session_id, @state_dir))
981
+ return unless prior
985
982
 
986
983
  loaded_turns = loaded_records(prior["turn_records"])
987
984
  @turn_records = loaded_turns + @turn_records
@@ -1004,12 +1001,10 @@ module Samagotchi
1004
1001
  @memory_index ||= symbolized(prior["memory_index"]) if prior["memory_index"].is_a?(Hash)
1005
1002
  # The window last seen, until this process's first generation reports.
1006
1003
  window = prior["context"].is_a?(Hash) ? prior["context"] : {}
1007
- if window["window_tokens"] && @context_window_tokens.nil?
1008
- @context_window_tokens = window["window_tokens"]
1009
- @context_window_source = window["window_source"]
1010
- end
1011
- rescue JSON::ParserError, SystemCallError
1012
- nil
1004
+ return unless window["window_tokens"] && @context_window_tokens.nil?
1005
+
1006
+ @context_window_tokens = window["window_tokens"]
1007
+ @context_window_source = window["window_source"]
1013
1008
  end
1014
1009
 
1015
1010
  def symbolized(hash)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llm_context_override"
4
+ require_relative "thinking"
5
+
6
+ module Samagotchi
7
+ # A session's own settings that come before its model's, carried as one
8
+ # value on the paths that spawn a session from a whole setup
9
+ # (SessionManager.spawn_session, a continue, a plugin's fork, the web's
10
+ # create, chi send --new): +llm_context+ is an LLMContextOverride or nil,
11
+ # +thinking+ a level Symbol (Thinking.session_level) or nil.
12
+ #
13
+ # It doesn't save itself: the session keeps each setting in its own field
14
+ # and file key, and resolves it on its own. A delegate child starts
15
+ # without one; a continue and a fork copy their session's (.of).
16
+ SessionSetup = Data.define(:llm_context, :thinking) do
17
+ def initialize(llm_context: nil, thinking: nil) = super
18
+
19
+ def empty? = llm_context.nil? && thinking.nil?
20
+
21
+ # A session's own setup, for a session that starts from it.
22
+ # @param session [Session, nil]
23
+ # @return [SessionSetup]
24
+ def self.of(session)
25
+ return new if session.nil?
26
+
27
+ new(llm_context: session.llm_context, thinking: session.thinking)
28
+ end
29
+ end
30
+ end
@@ -5,6 +5,7 @@ require_relative "session"
5
5
  require_relative "session_manager"
6
6
  require_relative "session_retention"
7
7
  require_relative "project_scope"
8
+ require_relative "analytics_file"
8
9
  require_relative "session_metrics"
9
10
  require_relative "recap_store"
10
11
  require_relative "session_delete_command"
@@ -337,10 +338,10 @@ module Samagotchi
337
338
  return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
338
339
  end
339
340
  end
340
- return [nil, false, nil] unless File.file?(File.join(dir, "analytics.json"))
341
+ return [nil, false, nil] unless File.file?(Samagotchi::AnalyticsFile.path(dir))
341
342
 
342
343
  snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.saved_snapshot
343
- note = live_worker?(session.id) ? "(no live worker answered; from the saved analytics.json)" : nil
344
+ note = live_worker?(session.id) ? "(no live worker answered; from the saved #{Samagotchi::AnalyticsFile::NAME})" : nil
344
345
  [snapshot, false, note]
345
346
  end
346
347
 
@@ -22,6 +22,13 @@ module Samagotchi
22
22
  { thinking: event[:thinking].to_s, text: text.to_s }
23
23
  end
24
24
 
25
+ # steer.cut_after: how long a generation streams only thinking before a
26
+ # message may cut it, in whole seconds (0 or less: cutting is off).
27
+ # @return [Integer]
28
+ def self.cut_after
29
+ Config.get("steer.cut_after").to_i
30
+ end
31
+
25
32
  # +clock+: monotonic seconds; +controller+: the running turn's cancel
26
33
  # controller (nil between turns), read only when a cut is due.
27
34
  def initialize(clock:, controller:)
@@ -51,7 +58,7 @@ module Samagotchi
51
58
  def cut_for_steer(source, epoch: nil)
52
59
  return :off unless Steer.cuts?(source)
53
60
 
54
- after = Config.get("steer.cut_after").to_i
61
+ after = self.class.cut_after
55
62
  return :off unless after.positive?
56
63
 
57
64
  ctrl = @controller.call
@@ -104,7 +111,7 @@ module Samagotchi
104
111
  def recheck
105
112
  return unless @waiting.waiting?
106
113
 
107
- after = Config.get("steer.cut_after").to_i
114
+ after = self.class.cut_after
108
115
  return unless after.positive? && @phase.cuttable?(after)
109
116
 
110
117
  source = @waiting.take
@@ -4,6 +4,7 @@ require_relative "../session"
4
4
  require_relative "../config"
5
5
  require_relative "../model_profile"
6
6
  require_relative "../session_manager"
7
+ require_relative "../thinking_command"
7
8
  require_relative "../bridge_client"
8
9
  require_relative "../parent_report"
9
10
  require_relative "../cli/exit"
@@ -40,16 +41,20 @@ module Samagotchi
40
41
  # (LLMContextOverride.update): a new session starts with them; a
41
42
  # resumed or attached one's worker gets a /llm-context, after the
42
43
  # /model
44
+ # @param thinking [String, nil] --thinking as typed: a new session
45
+ # starts with it as its own level; a resumed or attached one's
46
+ # worker gets a /thinking, after those
43
47
  # @return [Symbol] :detached, :closed when the worker went away,
44
48
  # :failed when the --model switch didn't go through, or (input from a
45
49
  # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
46
50
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
47
- memories: [], muted_memories: [], llm_context: {})
51
+ memories: [], muted_memories: [], llm_context: {}, thinking: nil)
48
52
  client = connect(attach: attach, shared: shared, resume: resume, model: model,
49
- memories: memories, muted_memories: muted_memories, llm_context: llm_context)
53
+ memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking)
50
54
  commands = []
51
55
  commands << "/model #{model}" if model && (attach || resume)
52
56
  commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
57
+ commands << "#{ThinkingCommand::NAME} #{thinking}" if (attach || resume) && thinking
53
58
  first_command = commands.size > 1 ? commands : commands.first
54
59
  surface = open_surface
55
60
  attached = AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
@@ -111,7 +116,7 @@ module Samagotchi
111
116
  # @return [BridgeClient] a client for the live Bridge of the session
112
117
  # @raise [Error]
113
118
  def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
114
- memories: [], muted_memories: [], llm_context: {}, err: $stderr)
119
+ memories: [], muted_memories: [], llm_context: {}, thinking: nil, err: $stderr)
115
120
  sd = state_dir || Session.default_state_dir
116
121
  warn_memory_flags_ignored(attach || resume, memories, muted_memories, err) if attach || resume
117
122
  if attach
@@ -127,7 +132,8 @@ module Samagotchi
127
132
  else
128
133
  SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
129
134
  memories: memories, muted_memories: muted_memories,
130
- llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context))
135
+ setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
136
+ thinking: Thinking.session_level(thinking)))
131
137
  end
132
138
  client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
133
139
  client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
@@ -430,7 +430,7 @@ module Samagotchi
430
430
  # @return [nil]
431
431
  def submit_delivery(choice, text)
432
432
  return @screen.commit(STEER_CHOICE_USAGE) if text.empty?
433
- return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue
433
+ return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue && @running
434
434
 
435
435
  # To an idle session it is an ordinary prompt: no delivery to send.
436
436
  send_prompt(text, delivery: @running ? Delivery::CUT : nil)
@@ -5,6 +5,7 @@ require_relative "../served_model"
5
5
  require_relative "../image_store"
6
6
  require_relative "../turn_note"
7
7
  require_relative "../steer"
8
+ require_relative "../steer_cut"
8
9
  require_relative "../memory_bundle/index_size"
9
10
  require_relative "../prompt_note"
10
11
 
@@ -19,7 +20,7 @@ module Samagotchi
19
20
  # (no UI: the same text from the CLI).
20
21
  # @return [String] "(no metrics yet)" without a snapshot
21
22
  def self.format_session_metrics(snapshot)
22
- (@formatter ||= Object.new.extend(Formatting)).send(:format_session_metrics, snapshot)
23
+ (@formatter ||= Object.new.extend(Formatting)).format_session_metrics(snapshot)
23
24
  end
24
25
 
25
26
  def format_tool_activity_line(activity, duration_ms: nil)
@@ -99,7 +100,8 @@ module Samagotchi
99
100
  # line goes in at the running turn's next step boundary (nothing cuts
100
101
  # unless the message asked for it), /cut's cutting now or once the
101
102
  # thinking passes steer.cut_after, and /queue's waiting for the end.
102
- # Both TUIs print the same words (SteerCut#cut_for_steer's answer).
103
+ # Both TUIs and chi send --cut print the same words
104
+ # (SteerCut#cut_for_steer's answer).
103
105
  NEXT_STEP_NOTE = "(goes in at the next step)"
104
106
  CUT_NOW_NOTE = "(cut in now)"
105
107
  CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
@@ -115,14 +117,17 @@ module Samagotchi
115
117
 
116
118
  # @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
117
119
  # @return [String] the dim line to show for a /cut
118
- def steer_cut_note(outcome)
120
+ def self.steer_cut_note(outcome)
119
121
  case outcome
120
122
  when :now then CUT_NOW_NOTE
121
- when :waits then format(CUT_WAITS_NOTE, Config.get("steer.cut_after").to_i)
123
+ when :waits then format(CUT_WAITS_NOTE, SteerCut.cut_after)
122
124
  else CUT_OFF_NOTE
123
125
  end
124
126
  end
125
127
 
128
+ # The same words, for a UI that includes the module.
129
+ def steer_cut_note(outcome) = Formatting.steer_cut_note(outcome)
130
+
126
131
  # The choice a mid-turn line carries, as [:cut, "TEXT"] or
127
132
  # [:queue, "TEXT"]; nil for any other line. The text is "" when the
128
133
  # line named the choice without one.
@@ -538,6 +543,8 @@ module Samagotchi
538
543
  value.length > width ? value[0, width] : value
539
544
  end
540
545
 
546
+ public
547
+
541
548
  # Render the analytics snapshot as a compact, user-facing report. Raw event
542
549
  # logs (debug-only) are intentionally excluded; this surface is for the REPL
543
550
  # and `chi sessions stats` (the module function above).
@@ -591,6 +598,8 @@ module Samagotchi
591
598
  lines << "model notes: #{notes}" unless notes.empty?
592
599
  llm_context = llm_context_stats_text(snapshot[:llm_context])
593
600
  lines << "llm context: #{llm_context}" if llm_context
601
+ thinking = thinking_stats_text(snapshot[:thinking])
602
+ lines << "thinking: #{thinking}" if thinking
594
603
  if snapshot[:served_model]
595
604
  asked = snapshot[:served_model_for]
596
605
  differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
@@ -600,6 +609,8 @@ module Samagotchi
600
609
  lines.join("\n")
601
610
  end
602
611
 
612
+ private
613
+
603
614
  # " (25.0% of the 64000 budget)" when the session's llm_context budget
604
615
  # is set and smaller than the window, else " (12.5%)": the live meter
605
616
  # counts against the smaller of the two (ContextStatus#counted_against),
@@ -630,6 +641,17 @@ module Samagotchi
630
641
  "budget #{budget} (#{get.call(:budget_where)})"
631
642
  end
632
643
 
644
+ # "low (session)", "default" for /stats (Thinking::Explained#summary;
645
+ # symbol or string keys, the attached TUI's come as JSON); nil
646
+ # without one.
647
+ def thinking_stats_text(summary)
648
+ return nil unless summary.is_a?(Hash)
649
+
650
+ level = summary[:level] || summary["level"]
651
+ source = summary[:source] || summary["source"]
652
+ source ? "#{level} (#{source})" : level
653
+ end
654
+
633
655
  # ", cached 4864 (93%), cache writes 312, re-prefilled 2100, reasoning
634
656
  # 212" for the tokens line; the counts a server didn't report are left
635
657
  # out (re-prefilled: SessionMetrics' reprefill_sum).
@@ -90,9 +90,12 @@ module Samagotchi
90
90
  # however the REPL ends, saves no memories and writes no recap
91
91
  # @param llm_context [Hash{Symbol => String}] --llm-context* as typed
92
92
  # (LLMContextOverride.update): set on the new or resumed session
93
+ # @param thinking [String, nil] --thinking as typed: the new or resumed
94
+ # session's own level (default unsets it)
93
95
  def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
94
- spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {})
96
+ spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil)
95
97
  @scratch = scratch
98
+ @thinking_word = thinking
96
99
  @prompt = prompt
97
100
  @llm_context_words = llm_context
98
101
  @default_model_name = ModelProfile.required_model_name(nil)
@@ -235,6 +238,10 @@ module Samagotchi
235
238
  # --non-interactive with no --prompt is a harmless no-op exit: build
236
239
  # nothing and return (no transient session, no banner).
237
240
  return if @non_interactive && @prompt.nil?
241
+ # A UI-only command (/stats, /exit …) isn't run without the REPL, and
242
+ # isn't a prompt either: refused as chi send refuses it, before any
243
+ # session is made (bin/chi exits 1).
244
+ return refuse_one_shot_ui_command if @non_interactive && SessionCommands.ui_only_entry(@prompt)
238
245
 
239
246
  session = @resume_session || @engine.store_model!(Session.new_session(
240
247
  mode: "assist",
@@ -246,8 +253,10 @@ module Samagotchi
246
253
  ))
247
254
  # --llm-context*: the session's own values, over a resumed one's.
248
255
  # Saved at once on a resumed one: leaving before a turn keeps them.
249
- unless @llm_context_words.empty?
250
- session.llm_context = LLMContextOverride.update(session.llm_context, @llm_context_words)
256
+ # --thinking: the session's own level, saved at once on a resumed one.
257
+ unless @llm_context_words.empty? && @thinking_word.nil?
258
+ session.llm_context = LLMContextOverride.update(session.llm_context, @llm_context_words) unless @llm_context_words.empty?
259
+ session.thinking = Thinking.session_level(@thinking_word) if @thinking_word
251
260
  session.save if @resume_session
252
261
  end
253
262
  claim_session!(session.id) unless @owner_lock
@@ -343,7 +352,7 @@ module Samagotchi
343
352
  # so the session is kept and `chi --resume` takes it up again.
344
353
  # @return [Symbol] :turn_failed
345
354
  def one_shot_failed(session, error)
346
- warn "Error: #{error.respond_to?(:summary) ? error.summary : error.message}"
355
+ warn "Error: #{@engine.failure_summary(error)}"
347
356
  if !@scratch && Session.exist?(session.id)
348
357
  warn "chi: the session is kept with your prompt; continue it with: chi --resume #{session.id}"
349
358
  end
@@ -366,6 +375,14 @@ module Samagotchi
366
375
  :turn_failed if result.status == :error
367
376
  end
368
377
 
378
+ # `chi -p /stats --non-interactive`: why it doesn't run, on stderr.
379
+ # @return [Symbol] :turn_failed
380
+ def refuse_one_shot_ui_command
381
+ name = SessionCommands.ui_only_entry(@prompt).name
382
+ warn "chi: refused: #{SessionCommands.ui_only_refusal(name, @resume_session&.id, runner: "--non-interactive")}"
383
+ :turn_failed
384
+ end
385
+
369
386
  # Ctrl-C in a -p --non-interactive turn: the Engine kept the prompt and
370
387
  # a cancel note in the session (it saves only a failed turn), so save
371
388
  # it here: `chi --resume` takes it up again. A scratch session goes in
@@ -10,7 +10,9 @@ module Samagotchi
10
10
  # low, medium, high the effort a chat host is asked for
11
11
  # default chi sends nothing: the provider's or template's own default
12
12
  #
13
- # Order (#resolve): --thinking / SAMAGOTCHI_THINKING_LEVEL, then
13
+ # Order (#resolve): the session's own level (/thinking, chi --thinking at
14
+ # start, chi send --new --thinking, the web), then the process default
15
+ # (chi web --thinking / SAMAGOTCHI_THINKING_LEVEL), then
14
16
  # models.<key>.thinking, hosts.<name>.thinking, thinking.level in
15
17
  # config.yml, then default.
16
18
  module Thinking
@@ -33,8 +35,39 @@ module Samagotchi
33
35
  # anything there (low/medium/high have no native knob).
34
36
  Native = Data.define(:system_token, :prefill, :honoured)
35
37
 
38
+ # The source a session's own level reads as.
39
+ SESSION_SOURCE = "session"
40
+
41
+ # A level and where it came from (#explain): +source+ is "session",
42
+ # "--thinking", "SAMAGOTCHI_THINKING_LEVEL", "models: qwen",
43
+ # "hosts.work" or "thinking.level", nil for the default; +own+ is the
44
+ # session's own level, nil without one. What /thinking, /model,
45
+ # /stats, the snapshots and the web read.
46
+ Explained = Data.define(:level, :source, :own) do
47
+ # "low (session)", "default" with no source.
48
+ def label = source ? "#{level} (#{source})" : level.to_s
49
+
50
+ # The plain form the snapshots, the worker's command_ran event and
51
+ # the web read.
52
+ # @return [Hash] symbol keys
53
+ def summary = { level: level.to_s, source: source, own: own&.to_s }
54
+ end
55
+
36
56
  module_function
37
57
 
58
+ # A session's own level from its file or a command word: :off, :low,
59
+ # :medium or :high; nil for anything else, default included (unset:
60
+ # the session follows the process default and the model). Never warns:
61
+ # a session file keeps a value it can't read as written.
62
+ # @return [Symbol, nil]
63
+ def session_level(value)
64
+ return value if LEVELS.include?(value) && value != DEFAULT
65
+ return nil unless value.is_a?(String)
66
+
67
+ level = value.strip.downcase.to_sym
68
+ LEVELS.include?(level) && level != DEFAULT ? level : nil
69
+ end
70
+
38
71
  # A configured value as a level; nil when unset or not a level (that
39
72
  # warns once, naming +where+). YAML reads an unquoted `off` as false,
40
73
  # which is :off; true (`on`) isn't a level.
@@ -51,13 +84,17 @@ module Samagotchi
51
84
  nil
52
85
  end
53
86
 
54
- # The level for +target+ and where it came from ("--thinking",
55
- # "models: qwen", "hosts.work", "thinking.level"; nil for the default).
87
+ # The level for +target+ and where it came from ("session",
88
+ # "--thinking", "models: qwen", "hosts.work", "thinking.level"; nil for
89
+ # the default).
56
90
  # @param target [HostRegistry::ModelTarget]
57
91
  # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
58
- # @param models [Hash, nil] ConfigFile.model_settings (specs)
92
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
93
+ # @param session [Symbol, nil] the session's own level (Session#thinking)
59
94
  # @return [Array(Symbol, String|nil)]
60
- def resolve(target, names:, models: nil)
95
+ def resolve(target, names:, models: nil, session: nil)
96
+ return [session, SESSION_SOURCE] if session
97
+
61
98
  global, origin = global_level
62
99
  return [global, origin == :cli ? "--thinking" : "SAMAGOTCHI_THINKING_LEVEL"] if global && %i[cli env].include?(origin)
63
100
 
@@ -66,8 +103,8 @@ module Samagotchi
66
103
  rescue StandardError
67
104
  {}
68
105
  end
69
- key, value = ConfigFile.model_setting(names, :thinking, models: models)
70
- return [value, "models: #{key}"] if key
106
+ setting = ConfigFile.model_setting(names, :thinking, models: models)
107
+ return [setting.value, "models: #{setting.key}"] if setting
71
108
 
72
109
  host = target.entry.thinking
73
110
  return [host, "hosts.#{target.entry.name}"] if host
@@ -76,6 +113,13 @@ module Samagotchi
76
113
  [DEFAULT, nil]
77
114
  end
78
115
 
116
+ # #resolve, as an Explained with the session's own level beside it.
117
+ # @return [Explained]
118
+ def explain(target, names:, models: nil, session: nil)
119
+ level, source = resolve(target, names: names, models: models, session: session)
120
+ Explained.new(level: level, source: source, own: session)
121
+ end
122
+
79
123
  # thinking.level as a level and its origin (:cli, :env, :file), or nil.
80
124
  def global_level
81
125
  raw, origin = Config.get_with_origin("thinking.level")
@@ -88,6 +132,22 @@ module Samagotchi
88
132
  nil
89
133
  end
90
134
 
135
+ # What a level change costs the prompt cache on +target+ (/thinking's
136
+ # note; a confirm step may ask before the costly ones):
137
+ # :full the native Gemma 4 prompt starts with <|think|>, so the
138
+ # whole prompt is read again;
139
+ # :provider a chat host gets other request fields: a local llama.cpp
140
+ # or Splash server keeps its cache, a hosted API may not;
141
+ # :tail native Qwen and the rest: only the tail changes.
142
+ # @param target [HostRegistry::ModelTarget]
143
+ # @param profile [String, nil] the native prompt profile's name
144
+ # @return [Symbol]
145
+ def cache_cost(target, profile: nil)
146
+ return :provider if target.entry&.chat?
147
+
148
+ profile == "gemma4" ? :full : :tail
149
+ end
150
+
91
151
  # Request fields for an OpenAI-style chat host. off sends both switches
92
152
  # (the template's enable_thinking and the OpenAI-style reasoning_effort
93
153
  # "none"): llama.cpp, Splash and OpenRouter each honour one of them.