samagotchi 0.50.0 → 0.52.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -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 +40 -11
  7. data/docs/configuration.md +63 -21
  8. data/docs/hooks.md +3 -2
  9. data/docs/internals/engine.md +4 -2
  10. data/docs/memory.md +11 -0
  11. data/docs/plugins.md +2 -1
  12. data/docs/sessions.md +10 -5
  13. data/docs/testing.md +6 -1
  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/turn_accumulator.rb +3 -1
  17. data/lib/samagotchi/bridge.rb +29 -17
  18. data/lib/samagotchi/bridge_client.rb +3 -1
  19. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/children_status.rb +1 -1
  23. data/lib/samagotchi/commands/registry.rb +2 -1
  24. data/lib/samagotchi/config.rb +50 -182
  25. data/lib/samagotchi/config_text_edit.rb +8 -32
  26. data/lib/samagotchi/context_sources.rb +0 -7
  27. data/lib/samagotchi/context_window.rb +2 -2
  28. data/lib/samagotchi/continue_offer.rb +5 -4
  29. data/lib/samagotchi/cut_policy.rb +0 -1
  30. data/lib/samagotchi/delivery.rb +56 -0
  31. data/lib/samagotchi/engine.rb +48 -17
  32. data/lib/samagotchi/engine_peers.rb +1 -0
  33. data/lib/samagotchi/host_config.rb +183 -0
  34. data/lib/samagotchi/host_registry.rb +21 -13
  35. data/lib/samagotchi/kernel_loop.rb +28 -2
  36. data/lib/samagotchi/last_turn.rb +48 -0
  37. data/lib/samagotchi/live_versions.rb +0 -6
  38. data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -7
  39. data/lib/samagotchi/llm_context_override.rb +3 -22
  40. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  41. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  42. data/lib/samagotchi/model_profile.rb +4 -4
  43. data/lib/samagotchi/model_settings.rb +46 -0
  44. data/lib/samagotchi/plugin/context.rb +3 -3
  45. data/lib/samagotchi/plugin/sessions.rb +2 -2
  46. data/lib/samagotchi/queued_command.rb +35 -0
  47. data/lib/samagotchi/reminder_store.rb +0 -27
  48. data/lib/samagotchi/reply_wait.rb +11 -13
  49. data/lib/samagotchi/sampling_settings.rb +4 -4
  50. data/lib/samagotchi/self_report.rb +8 -5
  51. data/lib/samagotchi/send_command.rb +92 -21
  52. data/lib/samagotchi/session.rb +45 -24
  53. data/lib/samagotchi/session_commands.rb +41 -3
  54. data/lib/samagotchi/session_inbox.rb +36 -7
  55. data/lib/samagotchi/session_manager.rb +21 -10
  56. data/lib/samagotchi/session_metrics.rb +25 -6
  57. data/lib/samagotchi/session_setup.rb +30 -0
  58. data/lib/samagotchi/sessions_command.rb +1 -1
  59. data/lib/samagotchi/steer_cut.rb +21 -10
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  61. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +69 -4
  63. data/lib/samagotchi/terminal_ui.rb +56 -8
  64. data/lib/samagotchi/thinking.rb +67 -7
  65. data/lib/samagotchi/thinking_command.rb +72 -0
  66. data/lib/samagotchi/tool_activity.rb +3 -0
  67. data/lib/samagotchi/tool_runner.rb +2 -0
  68. data/lib/samagotchi/tools/builtin_calls.rb +33 -0
  69. data/lib/samagotchi/tools/builtins.rb +7 -2
  70. data/lib/samagotchi/tools/peers.rb +3 -2
  71. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +4 -3
  74. data/lib/samagotchi/web/app.rb +59 -7
  75. data/lib/samagotchi/web/message_parts.rb +3 -0
  76. data/lib/samagotchi/web/public/activity.js +6 -0
  77. data/lib/samagotchi/web/public/app.js +199 -48
  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 +25 -20
  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/session_title.js +26 -0
  84. data/lib/samagotchi/web/public/strip.js +4 -6
  85. data/lib/samagotchi/web/public/thinking_chip.js +169 -0
  86. data/lib/samagotchi/web/public/timing.js +15 -0
  87. data/lib/samagotchi/web/public/turn_events.js +26 -2
  88. data/lib/samagotchi/web/public/turn_html.js +3 -1
  89. data/lib/samagotchi/web/public/turn_view.js +328 -310
  90. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  91. data/lib/samagotchi/web/session_summary.rb +1 -1
  92. data/lib/samagotchi/worker.rb +44 -43
  93. data/lib/samagotchi/worker_inbound.rb +10 -7
  94. data/lib/samagotchi/worker_wakes.rb +0 -2
  95. data/lib/samagotchi/yaml_lines.rb +72 -0
  96. data/lib/samagotchi.rb +1 -0
  97. metadata +13 -1
@@ -32,7 +32,7 @@ module Samagotchi
32
32
  # Start a child session from +messages+ (usually ctx.messages plus
33
33
  # more). With no prompt the child waits idle for the user; with one it
34
34
  # runs it as its first turn, and counts against session.max_children.
35
- # It keeps this session's own llm_context values (Session#llm_context).
35
+ # It keeps this session's own setup (SessionSetup: its llm_context values).
36
36
  # Each image a message names is copied into the child, or dropped with
37
37
  # a note in its message when its file is gone.
38
38
  # @param messages [Array<Hash>] the child's conversation to start with
@@ -54,7 +54,7 @@ module Samagotchi
54
54
  prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
55
55
  parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, title: title,
56
56
  images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir,
57
- llm_context: @host.llm_context&.call
57
+ setup: @host.setup&.call || SessionSetup.new
58
58
  )
59
59
  child.id
60
60
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Samagotchi
6
+ # A session command on its way from the Bridge (POST /command, a command
7
+ # sent as a message, the worker's own Bridge#queue_command) to the
8
+ # worker's command queue. +card+: a card's action (the step-limit
9
+ # question's answer), whose line the UIs leave out. The worker adds
10
+ # where it waits (Worker#on_command): +mid_turn+ (the policy it got:
11
+ # :queue, :refuse, :loop), +after_seq+ (the event count then, so a
12
+ # turn's end knows it came before) and +after_file+ (the input file it
13
+ # runs after, a basename).
14
+ QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file) do
15
+ def initialize(command_id:, line:, client_id: nil, card: false, mid_turn: nil, after_seq: nil, after_file: nil) = super
16
+
17
+ # A new command with a fresh id.
18
+ def self.mint(line, client_id: nil, card: false)
19
+ new(command_id: SecureRandom.uuid, client_id: client_id, line: line, card: card ? true : false)
20
+ end
21
+
22
+ # The fields its command_queued and command_ran name: card only when
23
+ # it is a card's.
24
+ # @return [Hash]
25
+ def event_fields
26
+ fields = { command_id: command_id, client_id: client_id, line: line }
27
+ fields[:card] = true if card
28
+ fields
29
+ end
30
+
31
+ # Whether it waits for the running turn's end (its line was shown at
32
+ # its command_queued).
33
+ def waits_for_turn_end? = mid_turn == :queue
34
+ end
35
+ end
@@ -104,21 +104,6 @@ module Samagotchi
104
104
  end
105
105
  end
106
106
 
107
- # Mark a reminder as fired — resets its next_fire_at to now + interval.
108
- # Called by Engine after a due reminder has been delivered.
109
- # @param name [String] the reminder name
110
- def mark_fired(name)
111
- name = name.to_s.strip
112
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
- @mutex.synchronize do
114
- r = @reminders[name]
115
- if r
116
- r[:last_fire_at] = now
117
- r[:next_fire_at] = now + (r[:interval_minutes] * 60)
118
- end
119
- end
120
- end
121
-
122
107
  # Mark multiple reminders as fired — resets each next_fire_at to now + interval.
123
108
  # Called by Engine after injecting multiple due reminders (batch operation).
124
109
  # Thread-safe, single lock acquisition.
@@ -137,18 +122,6 @@ module Samagotchi
137
122
  end
138
123
  end
139
124
 
140
- # Get the description of a due reminder (for injection into the system prompt).
141
- # Called by Engine during run_turn. Thread-safe.
142
- # @param name [String] the reminder name
143
- # @return [String, nil] the description, or nil if not found
144
- def get_description(name)
145
- name = name.to_s.strip
146
- @mutex.synchronize do
147
- r = @reminders[name]
148
- r[:description] if r
149
- end
150
- end
151
-
152
125
  # Clear all reminders.
153
126
  # Called when the session ends or the engine is reset.
154
127
  def clear_all
@@ -61,7 +61,7 @@ module Samagotchi
61
61
  # idle again ends the wait even if it was never seen running, and a
62
62
  # question already pending then is not the answer's. A nil
63
63
  # messages: skips the count. last_turn: (the session's
64
- # last_turn["ended_at"] then, maybe nil) ends the wait on any turn
64
+ # last_turn.ended_at then, maybe nil) ends the wait on any turn
65
65
  # that ended since, idle again, however fast it ran (a failure after
66
66
  # a failure replaces the note and leaves the count as it was), and
67
67
  # with it the count isn't used: a note between turns grows it too.
@@ -141,8 +141,7 @@ module Samagotchi
141
141
  # ended, so a turn that ends before the first look still ends the wait.
142
142
  # @return [Hash]
143
143
  def baseline_of(session, question_id: session.pending_question&.dig(:id))
144
- last = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
145
- { messages: session.messages.size, question_id: question_id, last_turn: last }
144
+ { messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at }
146
145
  end
147
146
 
148
147
  # An idle session whose turn ended after the baseline was taken: its
@@ -155,7 +154,7 @@ module Samagotchi
155
154
  return false unless baseline
156
155
 
157
156
  if baseline.key?(:last_turn)
158
- ended = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
157
+ ended = session.last_turn&.ended_at
159
158
  return !ended.nil? && ended != baseline[:last_turn]
160
159
  end
161
160
  !!(baseline[:messages] && session.messages.size > baseline[:messages])
@@ -164,15 +163,14 @@ module Samagotchi
164
163
  # @return [Result] :no_reply with the outcome the session's last_turn
165
164
  # records, when it is the turn waited for
166
165
  def no_reply(session, baseline)
167
- last = session.last_turn.is_a?(Hash) ? session.last_turn : {}
168
- fresh = baseline.nil? || !baseline.key?(:last_turn) || (last["ended_at"] && last["ended_at"] != baseline[:last_turn])
169
- outcome = fresh ? last["outcome"] : nil
170
- if fresh && last["exhausted"]
171
- return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"], session: session)
172
- end
166
+ last = session.last_turn || LastTurn.new
167
+ fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn])
168
+ outcome = fresh ? last.outcome : nil
169
+ return Result.new(status: :no_reply, outcome: "exhausted", limit: last.limit, session: session) if fresh && last.exhausted
173
170
 
174
171
  text = outcome == "failed" ? TurnNote.failure_summary(session.messages) : nil
175
- why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by", "kept_steps").transform_keys(&:to_sym) : {}
172
+ # Every stop fact the turn has: Result takes them by name.
173
+ why = fresh ? last.stop_facts : {}
176
174
  Result.new(status: :no_reply, outcome: outcome, text: text, session: session, **why)
177
175
  end
178
176
 
@@ -188,12 +186,12 @@ module Samagotchi
188
186
  def settled(session, id, state_dir:)
189
187
  return session unless session.status == Session::STATUS_RUNNING
190
188
 
191
- before = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
189
+ before = session.last_turn&.ended_at
192
190
  deadline = monotonic + SETTLE_SECONDS
193
191
  loop do
194
192
  sleep(0.02)
195
193
  fresh = Session.load(id, state_dir: state_dir)
196
- ended = fresh.last_turn.is_a?(Hash) ? fresh.last_turn["ended_at"] : nil
194
+ ended = fresh.last_turn&.ended_at
197
195
  return fresh if fresh.status != Session::STATUS_RUNNING || ended != before || monotonic > deadline
198
196
  end
199
197
  rescue ArgumentError
@@ -13,7 +13,7 @@ module Samagotchi
13
13
 
14
14
  # @param target [HostRegistry::ModelTarget]
15
15
  # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
16
- # @param models [Hash, nil] ConfigFile.model_settings (specs)
16
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
17
17
  # @return [Hash] frozen, symbol keys; may be empty
18
18
  def self.for(target, names:, models: nil)
19
19
  resolve(target, names, models).first
@@ -44,11 +44,11 @@ module Samagotchi
44
44
  {}
45
45
  end
46
46
  host = target.entry.sampling || EMPTY
47
- key, model = ConfigFile.model_setting(names, :sampling, models: models)
47
+ model = ConfigFile.model_setting(names, :sampling, models: models)
48
48
  sources = []
49
49
  sources << "hosts.#{target.entry.name}" unless host.empty?
50
- sources << "models: #{key}" if key
51
- [host.merge(model || EMPTY).freeze, sources]
50
+ sources << "models: #{model.key}" if model
51
+ [host.merge(model&.value || EMPTY).freeze, sources]
52
52
  end
53
53
 
54
54
  private_class_method :resolve
@@ -145,13 +145,15 @@ module Samagotchi
145
145
  return nil unless window
146
146
  return [window.tokens, "hosts.#{target.entry.name}"] if window.source == :host_setting
147
147
 
148
- [window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).first}"]
148
+ [window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).key}"]
149
149
  rescue StandardError
150
150
  nil
151
151
  end
152
152
 
153
153
  SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
154
154
  PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
155
+ # The session's own thinking level, beside its model (Builtins.parent_env).
156
+ SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
155
157
 
156
158
  # The model of the session this chi self runs in (its execute exports
157
159
  # SAMAGOTCHI_SESSION_MODEL, the resolved ref, live after /model); nil
@@ -355,13 +357,14 @@ module Samagotchi
355
357
  end
356
358
  end
357
359
 
358
- # The model's thinking level and where it came from (Thinking.resolve).
360
+ # The model's thinking level and where it came from (Thinking.resolve);
361
+ # in a session's commands its own level first (SAMAGOTCHI_SESSION_THINKING).
359
362
  def thinking_for(model, env)
360
363
  registry = HostRegistry.new(env: env)
361
364
  target = registry.resolve(model)
362
- level, source = Thinking.resolve(target, names: registry.lookup_names(model, target: target),
363
- models: ConfigFile.model_settings(env: env))
364
- source ? "#{level} (#{source})" : level.to_s
365
+ session = session_model(env) ? Thinking.session_level(env[SESSION_THINKING_ENV]) : nil
366
+ Thinking.explain(target, names: registry.lookup_names(model, target: target),
367
+ models: ConfigFile.model_settings(env: env), session: session).label
365
368
  rescue StandardError => e
366
369
  "(unknown: #{e.message})"
367
370
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "client_id"
4
+ require_relative "delivery"
5
+ require_relative "terminal_ui/formatting"
4
6
  require_relative "session"
5
7
  require_relative "session_commands"
6
8
  require_relative "session_inbox"
@@ -32,15 +34,18 @@ module Samagotchi
32
34
  CLIENT_ID = ClientId::CLI_SEND
33
35
  # The cap on one turn's images the Bridge checks.
34
36
  MAX_IMAGES = ImageStore::MAX_TURN_REFS
37
+ # A cut's outcomes in the Bridge's ack (SteerCut#cut_for_steer).
38
+ CUT_OUTCOMES = %w[now waits off].freeze
35
39
 
36
40
  USAGE = <<~TEXT
37
- Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
38
- chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
41
+ Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
42
+ chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [-m TEXT] [--image PATH]...
39
43
  chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
40
44
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
41
45
  chi send --wait [--timeout S] [--format json] ID
42
46
  Sends a message to each session, as if typed in it: a turn starts,
43
- or a running one picks it up. A stopped session's worker starts.
47
+ or a running one takes it at its next step. A stopped session's
48
+ worker starts.
44
49
  A session command (/model x, !cmd) runs as the command; --wait
45
50
  then has no reply to wait for (exit 0, status command).
46
51
  -m TEXT the message; stdin, when piped too, goes above it as a
@@ -57,16 +62,20 @@ module Samagotchi
57
62
  the current one
58
63
  --continues ID
59
64
  (--new) start the next link of that session's chain:
60
- in its folder, on its model and LLM context, with a
61
- note carrying its recap; it is archived. last:ID
62
- names the chain's latest link. Without -m the new
63
- session waits idle. Refused for a session continued
64
- already (naming the next link) or with delegates
65
- still open
65
+ in its folder, on its model, LLM context and
66
+ thinking level, with a note carrying its recap; it
67
+ is archived. last:ID names the chain's latest link.
68
+ Without -m the new session waits idle. Refused for a
69
+ session continued already (naming the next link) or
70
+ with delegates still open
66
71
  --model M (--new) its model; default the configured one
67
72
  --llm-context LAYERS, --llm-context-apply RULE, --llm-context-budget N
68
73
  (--new) its own LLM context strategy (none, stale,forget),
69
74
  apply rule and budget (64k, off), as chi takes them
75
+ --thinking LEVEL
76
+ (--new) its own thinking level (off, low, medium,
77
+ high; default: none of its own), above a process
78
+ default such as SAMAGOTCHI_THINKING_LEVEL
70
79
  --wait wait for the answer and print it (one session); the
71
80
  other lines go to stderr. Exit 3: it waits for an
72
81
  answer from you: the question, its options and the
@@ -75,6 +84,14 @@ module Samagotchi
75
84
  nothing is sent: it waits for the session's next
76
85
  reply, a running turn's too (after exit 3, 4 or 130,
77
86
  wait again this way)
87
+ --cut ask the running turn to cut the generation it is
88
+ streaming and take the message now (a generation that
89
+ has streamed only thinking for steer.cut_after; else
90
+ the message waits for the thinking to pass it). A
91
+ message without --cut goes in at the running turn's
92
+ next step and cuts nothing. To an idle session it
93
+ sends normally. Refused with --wait into a running
94
+ turn (the steer's turn is the running one)
78
95
  --timeout S (--wait) give up after S seconds, exit 4 (the turn
79
96
  goes on); default no limit
80
97
  --format json
@@ -82,7 +99,8 @@ module Samagotchi
82
99
  status answered (text), question (question,
83
100
  answer_with), running, or failed, canceled,
84
101
  limit, no_answer, error, worker_gone, stopped,
85
- command (detail)
102
+ command (detail); with --cut also delivery (what
103
+ the worker applied) and cut (now, waits, off)
86
104
  Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
87
105
  question waits, 4 still running (--timeout), 130 Ctrl-C.
88
106
  Only sessions on this machine. Answers show in the attached TUI
@@ -99,8 +117,10 @@ module Samagotchi
99
117
  f.value "--llm-context", key: :llm_strategy
100
118
  f.value "--llm-context-apply", key: :llm_apply
101
119
  f.value "--llm-context-budget", key: :llm_budget
120
+ f.value "--thinking"
102
121
  f.value "--image", key: :images, repeat: true
103
122
  f.switch "--wait"
123
+ f.switch "--cut"
104
124
  f.value "--timeout"
105
125
  f.value "--format"
106
126
  # Starting a turn in every live session at once is too easy to do
@@ -160,6 +180,7 @@ module Samagotchi
160
180
  def run_parsed(options)
161
181
  # With --wait stdout is the answer alone.
162
182
  @info = options[:wait] ? @stderr : @stdout
183
+ @cut = options[:cut] == true
163
184
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
164
185
  # Before the wait-only case, which would drop the images.
165
186
  return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
@@ -229,7 +250,7 @@ module Samagotchi
229
250
  return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
230
251
  return new_options(options) if options[:new]
231
252
 
232
- %i[dir model continues].each { |key| return usage_error("--#{key} needs --new") if options[key] }
253
+ %i[dir model continues thinking].each { |key| return usage_error("--#{key} needs --new") if options[key] }
233
254
  LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
234
255
  return usage_error("give session ids") if options[:ids].empty?
235
256
 
@@ -246,9 +267,9 @@ module Samagotchi
246
267
 
247
268
  if options[:continues]
248
269
  taken = [("--dir" if options[:dir]), ("--model" if options[:model])] +
249
- LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] }
270
+ LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])]
250
271
  unless taken.compact.empty?
251
- return usage_error("--continues takes the previous session's folder, model and LLM context; " \
272
+ return usage_error("--continues takes the previous session's folder, model, LLM context and thinking level; " \
252
273
  "leave out #{taken.compact.join(", ")}")
253
274
  end
254
275
  end
@@ -260,6 +281,15 @@ module Samagotchi
260
281
  return usage_error(e.message)
261
282
  end
262
283
 
284
+ if options[:thinking]
285
+ word = options[:thinking].strip.downcase
286
+ unless Thinking::LEVELS.map(&:to_s).include?(word)
287
+ return usage_error("--thinking takes #{Thinking::LEVELS.join(", ")}, not #{options[:thinking]}")
288
+ end
289
+
290
+ options[:thinking] = Thinking.session_level(word)
291
+ end
292
+
263
293
  if options[:dir]
264
294
  options[:dir] = File.expand_path(options[:dir])
265
295
  return usage_error("no folder #{options[:dir]}") unless File.directory?(options[:dir])
@@ -304,7 +334,9 @@ module Samagotchi
304
334
  SessionManager.continue_session(options[:continues], **start, state_dir: @state_dir)
305
335
  else
306
336
  SessionManager.spawn_session(**start, working_directory: options[:dir],
307
- model_name: options[:model], llm_context: options[:llm_context],
337
+ model_name: options[:model],
338
+ setup: SessionSetup.new(llm_context: options[:llm_context],
339
+ thinking: options[:thinking]),
308
340
  state_dir: @state_dir)
309
341
  end
310
342
  rescue SessionManager::ContinueRefused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
@@ -527,7 +559,11 @@ module Samagotchi
527
559
  owner = SessionManager.session_owner(id, state_dir: @state_dir)
528
560
  running = owner && session.status == Session::STATUS_RUNNING
529
561
  refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
530
- result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir)
562
+ # --cut asks the running turn for a cut; to an idle session it is an
563
+ # ordinary message (there is nothing to cut).
564
+ delivery = @cut && running ? Delivery::CUT : nil
565
+ result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
566
+ delivery: delivery)
531
567
  unless result[:status] == :accepted
532
568
  @info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
533
569
  return false
@@ -539,12 +575,12 @@ module Samagotchi
539
575
  return true
540
576
  end
541
577
 
542
- # A busy worker runs a message with images as its own next turn
543
- # rather than merging it into the running one.
544
- note = if owner.nil? then " (started its worker)"
545
- elsif running then refs.empty? ? " (the running turn picks it up)" : " (runs after the current turn)"
546
- end
547
- @info.puts("#{short} sent#{with_images}#{note}")
578
+ ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
579
+ # What the worker applied, for --format json: its ack's delivery
580
+ # (next_step for an idle session) and a cut's outcome.
581
+ @sent_delivery = ack["delivery"] || delivery
582
+ @sent_cut = ack["cut"]
583
+ @info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
548
584
  true
549
585
  rescue SessionManager::OwnedByTUI
550
586
  @info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
@@ -554,6 +590,41 @@ module Samagotchi
554
590
  false
555
591
  end
556
592
 
593
+ # What the line says became of the message: a plain one goes in at the
594
+ # running turn's next step (nothing cuts by default); a --cut one names
595
+ # the outcome from the ack (now, waits, off); images keep their own
596
+ # wording; an idle session's turn simply runs.
597
+ # @param ack [Hash] the Bridge's 202 body ({} for the input file)
598
+ # @return [String] "" or " (...)"
599
+ def delivery_note(ack, delivery:, running:, owner:)
600
+ return " (started its worker)" if owner.nil?
601
+ return "" unless running
602
+
603
+ # A busy worker runs a message with images as its own next turn
604
+ # rather than merging it into the running one.
605
+ return " (runs after the current turn)" unless @images.empty?
606
+
607
+ # The TUIs' words for a cut's outcome. No outcome: a plain message,
608
+ # or the input file (the Bridge went away), which the next step's
609
+ # drain takes.
610
+ outcome = Delivery.cut?(delivery) ? ack["cut"].to_s : ""
611
+ return " (goes in at the running turn's next step)" unless CUT_OUTCOMES.include?(outcome)
612
+
613
+ " #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym)}"
614
+ end
615
+
616
+ # More keys for --format json, with --cut only (the line is unchanged
617
+ # otherwise): the delivery the worker applied (next_step to an idle
618
+ # session) and a cut's outcome (now, waits, off). In P1 --cut --wait is
619
+ # refused for a running turn, so the json line says next_step.
620
+ def report_extra
621
+ return {} unless @cut
622
+
623
+ extra = { delivery: @sent_delivery || Delivery::NEXT_STEP }
624
+ extra[:cut] = @sent_cut if @sent_cut
625
+ extra
626
+ end
627
+
557
628
  # What to say when +model_name+ is known not to take images, or nil
558
629
  # (it does, or it is unknown: sent as before). Asked once per model.
559
630
  def vision_refusal(model_name, typed: nil)
@@ -6,7 +6,9 @@ require "securerandom"
6
6
  require "time"
7
7
  require_relative "atomic_file"
8
8
  require_relative "context_note"
9
+ require_relative "last_turn"
9
10
  require_relative "llm_context_override"
11
+ require_relative "session_setup"
10
12
  require_relative "prompt_note"
11
13
 
12
14
  require_relative "paths"
@@ -86,14 +88,16 @@ module Samagotchi
86
88
  # A `chi scratch` session: deleted when its REPL ends, and by the next
87
89
  # sweep (or `chi sessions clean`) when the process died first.
88
90
  attr_accessor :scratch
89
- # How the last turn ended, for the web's notifications (the hub sends
90
- # it in the summary): {"outcome" => "completed"|"failed"|"canceled",
91
- # "ended_at" => iso8601, "seconds" => Float, "origin" =>
92
- # "client"|"reminder"|"delegate"|"delegate_report"|"context"}; nil
93
- # before the first turn. delegate_report: a turn a parent ran for its
94
- # delegate children's reports (ChildReports); context: one an attached
95
- # context source's change started (Worker).
96
- attr_accessor :last_turn
91
+ # How the last turn ended (LastTurn), for a parent's wait (ReplyWait)
92
+ # and the web's notifications (the hub sends it in the summary); nil
93
+ # before the first turn.
94
+ attr_reader :last_turn
95
+
96
+ # @param value [LastTurn, Hash, nil] a file's Hash is read
97
+ # (LastTurn.from_file)
98
+ def last_turn=(value)
99
+ @last_turn = LastTurn.from_file(value)
100
+ end
97
101
  # The session's own llm_context values (LLMContextOverride: strategy,
98
102
  # apply rule, budget), before the model's; nil when it has none.
99
103
  # Saved, so a --resume and a respawned worker keep them; a plugin's
@@ -115,13 +119,28 @@ module Samagotchi
115
119
  saved.empty? ? nil : saved
116
120
  end
117
121
 
122
+ # The session's own thinking level (:off, :low, :medium, :high), before
123
+ # the process default and the model's (Thinking.resolve); nil when it
124
+ # has none. Saved, so a --resume, a respawned worker and a continue keep
125
+ # it; a plugin's fork copies it, a delegate child starts without.
126
+ attr_reader :thinking
127
+
128
+ # @param value [Symbol, String, nil] a value that isn't a level reads
129
+ # as nil and is saved back as written, until the session sets its own
130
+ def thinking=(value)
131
+ @thinking = Thinking.session_level(value)
132
+ unread = @thinking.nil? && value.is_a?(String) && !value.strip.casecmp?(Thinking::DEFAULT.to_s)
133
+ @thinking_unread = unread ? value : nil
134
+ end
135
+
136
+ # The session file's "thinking": the level, else a value it couldn't
137
+ # read; nil when there is neither.
138
+ def thinking_file = @thinking&.to_s || @thinking_unread
139
+
118
140
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
119
- # left), e.g. "loop-guard"; nil when it ended otherwise. Both strings
120
- # and symbols are read (the file's keys are strings).
141
+ # left), e.g. "loop-guard"; nil when it ended otherwise.
121
142
  def stopped_by
122
- return nil unless @last_turn.is_a?(Hash)
123
-
124
- (@last_turn["stopped_by"] || @last_turn[:stopped_by])&.to_s
143
+ @last_turn&.stopped_by&.to_s
125
144
  end
126
145
  # Archived (ArchiveStore): hidden from the lists. Set by .list (with
127
146
  # include_archived); not saved in session.json.
@@ -146,7 +165,7 @@ module Samagotchi
146
165
  used_memory_names: [], project_root: nil,
147
166
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
148
167
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
149
- continues: nil)
168
+ continues: nil, thinking: nil)
150
169
  @id = id
151
170
  @metadata_version = metadata_version
152
171
  @mode = mode
@@ -168,10 +187,11 @@ module Samagotchi
168
187
  @project_root = project_root
169
188
  @parent_id = parent_id&.to_s
170
189
  @scratch = !!scratch
171
- @last_turn = last_turn
190
+ self.last_turn = last_turn
172
191
  @delegate = !!delegate
173
192
  @continues = continues&.to_s
174
193
  self.llm_context = llm_context
194
+ self.thinking = thinking
175
195
  @archived = false
176
196
  end
177
197
 
@@ -220,9 +240,10 @@ module Samagotchi
220
240
  # Build a new, unsaved session.
221
241
  # @param messages [Array<Hash>] a conversation to start from (a fork's
222
242
  # seed); [] by default
243
+ # @param setup [SessionSetup] the session's own settings (none by default)
223
244
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
224
245
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
225
- scratch: false, model_typed: nil, delegate: false, llm_context: nil, continues: nil)
246
+ scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil)
226
247
  now = Time.now.iso8601(3)
227
248
  resolved_test = if test_run.nil?
228
249
  test_session_env?
@@ -247,7 +268,8 @@ module Samagotchi
247
268
  parent_id: parent_id,
248
269
  scratch: scratch,
249
270
  delegate: delegate,
250
- llm_context: llm_context,
271
+ llm_context: setup.llm_context,
272
+ thinking: setup.thinking,
251
273
  continues: continues
252
274
  )
253
275
  end
@@ -281,7 +303,8 @@ module Samagotchi
281
303
  # value for a file without it (REQUIRED for the ones it must have).
282
304
  # .from_h and #to_h both go by this table; messages and
283
305
  # pending_question change key type on the way (symbols in memory,
284
- # strings on disk).
306
+ # strings on disk); last_turn and prompt_notes are value objects in
307
+ # memory (LastTurn, PromptNote).
285
308
  FIELDS = {
286
309
  "metadata_version" => 1,
287
310
  "id" => REQUIRED,
@@ -307,7 +330,8 @@ module Samagotchi
307
330
  "last_turn" => nil,
308
331
  "delegate" => false,
309
332
  "llm_context" => nil,
310
- "continues" => nil
333
+ "continues" => nil,
334
+ "thinking" => nil
311
335
  }.freeze
312
336
 
313
337
  # A session from a parsed session file. +messages+ false leaves the
@@ -428,7 +452,9 @@ module Samagotchi
428
452
  when "test_run" then !!@test_run
429
453
  when "delegate" then @delegate
430
454
  when "llm_context" then llm_context_file
455
+ when "thinking" then thinking_file
431
456
  when "prompt_notes" then @prompt_notes.map(&:to_file)
457
+ when "last_turn" then @last_turn&.to_file
432
458
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
433
459
  Array(instance_variable_get(:"@#{key}"))
434
460
  else instance_variable_get(:"@#{key}")
@@ -522,11 +548,6 @@ module Samagotchi
522
548
  File.join(state_dir, "#{check_id!(session_id)}#{FILE_EXT}")
523
549
  end
524
550
 
525
- # Default sessions directory path.
526
- def self.default_sessions_dir
527
- default_state_dir
528
- end
529
-
530
551
  # XDG-aware sessions directory.
531
552
  def self.default_state_dir(env: ENV)
532
553
  File.join(Paths.state_home(env: env), STATE_SUBDIR)