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
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "client_id"
4
4
  require_relative "delivery"
5
+ require_relative "terminal_ui/formatting"
5
6
  require_relative "session"
6
7
  require_relative "session_commands"
7
8
  require_relative "session_inbox"
@@ -33,10 +34,12 @@ module Samagotchi
33
34
  CLIENT_ID = ClientId::CLI_SEND
34
35
  # The cap on one turn's images the Bridge checks.
35
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
36
39
 
37
40
  USAGE = <<~TEXT
38
41
  Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
39
- chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
42
+ chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [-m TEXT] [--image PATH]...
40
43
  chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
41
44
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
42
45
  chi send --wait [--timeout S] [--format json] ID
@@ -59,16 +62,20 @@ module Samagotchi
59
62
  the current one
60
63
  --continues ID
61
64
  (--new) start the next link of that session's chain:
62
- in its folder, on its model and LLM context, with a
63
- note carrying its recap; it is archived. last:ID
64
- names the chain's latest link. Without -m the new
65
- session waits idle. Refused for a session continued
66
- already (naming the next link) or with delegates
67
- 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
68
71
  --model M (--new) its model; default the configured one
69
72
  --llm-context LAYERS, --llm-context-apply RULE, --llm-context-budget N
70
73
  (--new) its own LLM context strategy (none, stale,forget),
71
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
72
79
  --wait wait for the answer and print it (one session); the
73
80
  other lines go to stderr. Exit 3: it waits for an
74
81
  answer from you: the question, its options and the
@@ -110,6 +117,7 @@ module Samagotchi
110
117
  f.value "--llm-context", key: :llm_strategy
111
118
  f.value "--llm-context-apply", key: :llm_apply
112
119
  f.value "--llm-context-budget", key: :llm_budget
120
+ f.value "--thinking"
113
121
  f.value "--image", key: :images, repeat: true
114
122
  f.switch "--wait"
115
123
  f.switch "--cut"
@@ -242,7 +250,7 @@ module Samagotchi
242
250
  return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
243
251
  return new_options(options) if options[:new]
244
252
 
245
- %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] }
246
254
  LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
247
255
  return usage_error("give session ids") if options[:ids].empty?
248
256
 
@@ -259,9 +267,9 @@ module Samagotchi
259
267
 
260
268
  if options[:continues]
261
269
  taken = [("--dir" if options[:dir]), ("--model" if options[:model])] +
262
- 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])]
263
271
  unless taken.compact.empty?
264
- 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; " \
265
273
  "leave out #{taken.compact.join(", ")}")
266
274
  end
267
275
  end
@@ -273,6 +281,15 @@ module Samagotchi
273
281
  return usage_error(e.message)
274
282
  end
275
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
+
276
293
  if options[:dir]
277
294
  options[:dir] = File.expand_path(options[:dir])
278
295
  return usage_error("no folder #{options[:dir]}") unless File.directory?(options[:dir])
@@ -317,7 +334,9 @@ module Samagotchi
317
334
  SessionManager.continue_session(options[:continues], **start, state_dir: @state_dir)
318
335
  else
319
336
  SessionManager.spawn_session(**start, working_directory: options[:dir],
320
- 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]),
321
340
  state_dir: @state_dir)
322
341
  end
323
342
  rescue SessionManager::ContinueRefused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
@@ -462,26 +481,8 @@ module Samagotchi
462
481
  # (a bundle's too).
463
482
  def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
464
483
 
465
- # The UI-only built-in command +text+ (stripped) is a line of (/stats,
466
- # /exit, …), or nil: the worker never runs it, so chi send must not pass
467
- # it on. What the UIs run: the line as the entry's match takes it — bare
468
- # "exit" too (/exit's match), but not with arguments ("/exit --delete" is
469
- # a UI line; "/stats please" is a prompt), as in a terminal.
470
- # @return [Commands::Registry::Entry, nil]
471
- def local_command(text)
472
- line = text.to_s.strip
473
- return nil unless command_like?(line) || line.match?(/\Aexit\z/i)
474
-
475
- entry = SessionCommands.builtin_registry.lookup_local(line)
476
- # Only the exact line the UI runs: "/stats please" is a prompt, and
477
- # "/exit --delete" is left to the worker (the TUI runs it as /exit, the
478
- # worker answers its unknown-command way). Bare "exit" is /exit's line
479
- # as its match takes it; the reply names the command, /exit.
480
- return nil unless entry
481
- return entry if line == entry.name
482
-
483
- line.match?(/\Aexit\z/i) ? entry : nil
484
- end
484
+ # @return [Commands::Registry::Entry, nil] SessionCommands.ui_only_entry
485
+ def local_command(text) = SessionCommands.ui_only_entry(text)
485
486
 
486
487
  # A UI-only command: nothing is sent (with --new, no session is created
487
488
  # either). /stats points at its model-free reader and names the session;
@@ -506,13 +507,7 @@ module Samagotchi
506
507
  CLI::Exit::FAILED
507
508
  end
508
509
 
509
- # Why a UI-only command isn't sent: /stats points at its model-free
510
- # reader (naming the session when there is one).
511
- def local_refusal(name, id)
512
- return "#{name} is a terminal and web command; chi send doesn't run it" unless name == "/stats"
513
-
514
- "#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
515
- end
510
+ def local_refusal(name, id) = SessionCommands.ui_only_refusal(name, id, runner: "chi send")
516
511
 
517
512
  # --wait after a message that ran as a session command: no reply comes
518
513
  # for it. Its output shows where the session is open.
@@ -585,14 +580,13 @@ module Samagotchi
585
580
  # rather than merging it into the running one.
586
581
  return " (runs after the current turn)" unless @images.empty?
587
582
 
588
- case (Delivery.cut?(delivery) ? ack["cut"].to_s : "")
589
- when "now" then " (cut in now)"
590
- when "waits" then " (cuts in once the thinking passes #{Config.get("steer.cut_after").to_i} s)"
591
- when "off" then " (cutting is off; goes in at the next step)"
592
- # No outcome: a plain message, or the input file (the Bridge went
593
- # away), which the next step's drain takes.
594
- else " (goes in at the running turn's next step)"
595
- end
583
+ # The TUIs' words for a cut's outcome. No outcome: a plain message,
584
+ # or the input file (the Bridge went away), which the next step's
585
+ # drain takes.
586
+ outcome = Delivery.cut?(delivery) ? ack["cut"].to_s : ""
587
+ return " (goes in at the running turn's next step)" unless CUT_OUTCOMES.include?(outcome)
588
+
589
+ " #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym)}"
596
590
  end
597
591
 
598
592
  # More keys for --format json, with --cut only (the line is unchanged
@@ -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)
@@ -11,6 +11,7 @@ require_relative "tools/context_read"
11
11
  require_relative "tools/peers"
12
12
  require_relative "commands/registry"
13
13
  require_relative "llm_context_command"
14
+ require_relative "thinking_command"
14
15
 
15
16
  module Samagotchi
16
17
  # The session commands a REPL and a session worker both run: /model,
@@ -29,6 +30,7 @@ module Samagotchi
29
30
  CONTEXT_COMMAND = "/context"
30
31
  HELP_COMMAND = "/help"
31
32
  LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
33
+ THINKING_COMMAND = ThinkingCommand::NAME
32
34
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
33
35
  # shows this many per host; /models <text> lists every match.
34
36
  MODELS_PER_HOST = 20
@@ -88,6 +90,8 @@ module Samagotchi
88
90
  # Setting it saves the session; it takes effect at the next turn's start.
89
91
  registry.register(LLM_CONTEXT_COMMAND, "show or set this session's LLM context strategy, apply rule and budget",
90
92
  id: :llm_context, mid_turn: show_mid_turn(LLM_CONTEXT_COMMAND), match: ->(text) { text.match?(%r{\A/llm-context(?:\s+.*)?\z}) }) { |text| llm_context(text) }
93
+ registry.register(THINKING_COMMAND, "show or set this session's thinking level (off|low|medium|high|default)",
94
+ id: :thinking, mid_turn: show_mid_turn(THINKING_COMMAND), match: ->(text) { text.match?(%r{\A/thinking(?:\s+.*)?\z}) }) { |text| thinking(text) }
91
95
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
92
96
  registry.register("/stats", "show the session's stats", local: true)
93
97
  registry.register("/recap", "show the session's recap", local: true)
@@ -102,11 +106,12 @@ module Samagotchi
102
106
  match: ->(text) { text.casecmp?("/detach") })
103
107
  # /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
104
108
  # themselves (Formatting#steer_choice) before any command check; the
105
- # entries exist for Tab completion and /help. /queue is a real
106
- # after-the-turn list only in the local REPL (the attached TUI's
107
- # worker doesn't skip queued files yet), so it is offered there.
109
+ # entries exist for Tab completion and /help. The web composer sends
110
+ # /cut too. /queue is a real after-the-turn list only in the local
111
+ # REPL (the attached TUI's worker doesn't skip queued files yet), so
112
+ # it is offered there.
108
113
  registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
109
- local: true, match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
114
+ local: true, uis: %i[repl attached web], match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
110
115
  registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
111
116
  local: true, uis: [:repl], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
112
117
  registry
@@ -136,6 +141,34 @@ module Samagotchi
136
141
  @builtin_registry ||= register_builtins(Commands::Registry.new).freeze
137
142
  end
138
143
 
144
+ # The UI-only built-in command +text+ is a line of (/stats, /exit, …), or
145
+ # nil: neither a worker nor a one-shot run (chi send, chi -p
146
+ # --non-interactive) runs it, so they refuse it rather than send it to
147
+ # the model as a prompt. What the UIs run: the line as the entry's match
148
+ # takes it — bare "exit" too (/exit's match), but not with arguments
149
+ # ("/exit --delete" is a UI line; "/stats please" is a prompt), as in a
150
+ # terminal.
151
+ # @return [Commands::Registry::Entry, nil]
152
+ def self.ui_only_entry(text)
153
+ line = text.to_s.strip
154
+ return nil unless line.start_with?("/", "!") || line.match?(/\Aexit\z/i)
155
+
156
+ entry = builtin_registry.lookup_local(line)
157
+ return nil unless entry
158
+ return entry if line == entry.name
159
+
160
+ line.match?(/\Aexit\z/i) ? entry : nil
161
+ end
162
+
163
+ # Why a UI-only command isn't run by +runner+ ("chi send"): /stats points
164
+ # at its model-free reader (naming the session +id+ when there is one).
165
+ # @return [String]
166
+ def self.ui_only_refusal(name, id, runner:)
167
+ return "#{name} is a terminal and web command; #{runner} doesn't run it" unless name == "/stats"
168
+
169
+ "#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
170
+ end
171
+
139
172
  # The name of this chi's own session command +line+ is (one a worker
140
173
  # runs), or nil. A worker that answers such a line unknown_command runs
141
174
  # an older chi that doesn't have it yet (/llm-context against 0.39.0):
@@ -175,13 +208,26 @@ module Samagotchi
175
208
  notes = []
176
209
  notes << entry.source unless entry.source == "core"
177
210
  notes << MID_TURN_NOTES[entry.mid_turn_label] if MID_TURN_NOTES[entry.mid_turn_label]
178
- notes << (entry.uis ? "#{entry.uis.join(" and ")} only" : "terminal only") if entry.local
211
+ notes << "#{ui_words(entry.uis)} only" if entry.local
179
212
  line = " #{self.class.display_name(entry).ljust(width)} #{entry.description}"
180
213
  notes.empty? ? line : "#{line} (#{notes.join("; ")})"
181
214
  end
182
215
  "commands:\n#{lines.join("\n")}"
183
216
  end
184
217
 
218
+ # Where a UI's own entry runs, for /help: both terminals are
219
+ # "terminal" (also for no uis: every terminal), then "web".
220
+ # @param uis [Array<Symbol>, nil]
221
+ # @return [String] "terminal", "repl", "terminal and web" …
222
+ def ui_words(uis)
223
+ return "terminal" if uis.nil?
224
+
225
+ terminals = uis & %i[repl attached]
226
+ words = terminals.size == 2 ? ["terminal"] : terminals.map(&:to_s)
227
+ words << "web" if uis.include?(:web)
228
+ words.join(" and ")
229
+ end
230
+
185
231
  # How help and errors name an entry (the shell's "!" is "!<cmd>").
186
232
  def self.display_name(entry) = entry.name == SHELL_BANG_PREFIX ? "#{SHELL_BANG_PREFIX}<cmd>" : entry.name
187
233
 
@@ -372,14 +418,25 @@ module Samagotchi
372
418
  # /llm-context (LLMContextCommand); :llm_context in changed when it set
373
419
  # the session's values.
374
420
  def llm_context(text)
375
- output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_llm_context(session) })
421
+ output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
376
422
  .run(text.delete_prefix(LLM_CONTEXT_COMMAND))
377
423
  reply(output, changed: changed ? [:llm_context] : [])
378
424
  rescue ArgumentError => e
379
425
  reply(e.message, status: :error)
380
426
  end
381
427
 
382
- def save_llm_context(session)
428
+ # /thinking (ThinkingCommand); :thinking in changed when it set the
429
+ # session's level.
430
+ def thinking(text)
431
+ output, changed = ThinkingCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
432
+ .run(text.delete_prefix(THINKING_COMMAND))
433
+ reply(output, changed: changed ? [:thinking] : [])
434
+ rescue ArgumentError => e
435
+ reply(e.message, status: :error)
436
+ end
437
+
438
+ # A session setting changed (/llm-context, /thinking): saved with its model.
439
+ def save_setup(session)
383
440
  @engine.store_model!(session)
384
441
  @save.call(session)
385
442
  end
@@ -459,7 +516,7 @@ module Samagotchi
459
516
 
460
517
  key = alias_name.strip.downcase
461
518
  warn_prefix = previous ? "warning: overwriting alias '#{key}' (#{previous} -> #{model_name}); " : ""
462
- base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated" : "runtime model set to #{model_name}#{model_note}"
519
+ base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}" : "runtime model set to #{model_name}#{model_note}#{thinking_note}"
463
520
  ["#{warn_prefix}#{base}; alias '#{key}' -> '#{model_name}' persisted", true]
464
521
  else
465
522
  return ["--default requires a model name: usage /model --default <name> or /model <name> [--default]", false] if arg.empty?
@@ -468,14 +525,14 @@ module Samagotchi
468
525
  return ["--default cannot be combined with clear/default/none/off", false] if persist_default
469
526
 
470
527
  switch_model(@default_model)
471
- return ["runtime model reset to #{model_name}#{model_note}#{notes_note}", true]
528
+ return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{notes_note}", true]
472
529
  end
473
530
 
474
531
  switch_model(arg, persist_default: persist_default)
475
532
  if persist_default
476
- ["runtime model set to #{model_name}#{model_note} and default updated#{notes_note}", true]
533
+ ["runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}#{notes_note}", true]
477
534
  else
478
- ["runtime model set to #{model_name}#{model_note}#{notes_note}", true]
535
+ ["runtime model set to #{model_name}#{model_note}#{thinking_note}#{notes_note}", true]
479
536
  end
480
537
  end
481
538
  end
@@ -183,20 +183,29 @@ module Samagotchi
183
183
  Dir.glob(File.join(input_dir, "*.json"))
184
184
  end
185
185
 
186
- # @return [Array(String, Hash|nil, Boolean, Array<Hash>, String|nil)] a
187
- # claimed input file's prompt, origin ({client_id:, enqueued_id:},
188
- # nil when it names no sender), whether its turn runs with the raised
189
- # iteration limit (--no-interrupt), its image refs ({file:, name:}),
190
- # and its delivery ("queue" or nil; a file without the key is one an
191
- # older chi wrote, so nil)
186
+ # A claimed input file as read: its +prompt+, +origin+ ({client_id:,
187
+ # enqueued_id:}, nil when it names no sender), whether its turn runs
188
+ # with the raised iteration limit (+no_interrupt+, --no-interrupt), its
189
+ # +images+ ({file:, name:} refs), and its +delivery+ ("queue" or nil; a
190
+ # file without the key is one an older chi wrote, so nil).
191
+ Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery) do
192
+ def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil) = super
193
+ end
194
+ # A file that doesn't parse, or isn't a JSON object: no prompt.
195
+ UNREADABLE_INPUT = Input.new(prompt: nil)
196
+
197
+ # @return [Input] a claimed input file's contents; UNREADABLE_INPUT for
198
+ # one that isn't a JSON object
192
199
  def self.read_input(claimed_file)
193
200
  data = JSON.parse(File.read(claimed_file).to_s)
201
+ return UNREADABLE_INPUT unless data.is_a?(Hash)
202
+
194
203
  origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
195
204
  images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
196
- [data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images,
197
- Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil]
205
+ Input.new(prompt: data["prompt"].to_s, origin: origin.empty? ? nil : origin, no_interrupt: data["no_interrupt"] == true,
206
+ images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil)
198
207
  rescue JSON::ParserError
199
- [nil, nil, false, [], nil]
208
+ UNREADABLE_INPUT
200
209
  end
201
210
 
202
211
  # Whether an unclaimed input file carries images (a mid-turn drain