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
@@ -6,6 +6,7 @@ require "fileutils"
6
6
  require "ipaddr"
7
7
  require_relative "../atomic_file"
8
8
  require_relative "../config"
9
+ require_relative "../yaml_lines"
9
10
 
10
11
  module Samagotchi
11
12
  module Bootstrap
@@ -15,6 +16,8 @@ module Samagotchi
15
16
  # comments), after a backup, and is checked by parsing it again: every
16
17
  # other key must be unchanged, or the backup goes back.
17
18
  class ConfigWriter
19
+ include YAMLLines
20
+
18
21
  class Error < StandardError
19
22
  end
20
23
 
@@ -33,7 +36,6 @@ module Samagotchi
33
36
  end
34
37
 
35
38
  HOSTS_LINE_RE = /\Ahosts:[ \t]*(#.*)?\z/
36
- DEFAULT_INDENT = 2
37
39
  SERVER_TRANSPORTS = ConfigFile::VALID_TRANSPORTS_FOR_CONFIG
38
40
 
39
41
  # The host part of a name: localhost/127.x → local, another IP → lan,
@@ -157,9 +159,7 @@ module Samagotchi
157
159
  original = File.binread(@path).force_encoding(Encoding::UTF_8)
158
160
  return snippet(name, fields) unless data.is_a?(Hash) && !anchors?(original)
159
161
 
160
- eol = original.include?("\r\n") ? "\r\n" : "\n"
161
- lines = original.split(/(?<=\n)/)
162
- lines[-1] = "#{lines[-1]}#{eol}" if lines.any? && !lines[-1].end_with?("\n")
162
+ lines, eol = lines_of(original)
163
163
  hosts_at = lines.index { |line| line.chomp.match?(HOSTS_LINE_RE) }
164
164
  return snippet(name, fields) if hosts_at.nil? && data.key?("hosts")
165
165
 
@@ -207,28 +207,14 @@ module Samagotchi
207
207
  # before trailing blank lines and comments shallower than an entry.
208
208
  # @return [index, entry indent, field indent step]
209
209
  def block_end(lines, hosts_at)
210
- stop = hosts_at + 1
211
- stop += 1 while stop < lines.length && inside_block?(lines[stop])
210
+ stop = block_stop(lines, hosts_at)
212
211
  children = lines[(hosts_at + 1)...stop].reject { |line| blank_or_comment?(line) }
213
212
  child = children.first ? indent(children.first) : DEFAULT_INDENT
214
213
  field = children.find { |line| indent(line) > child }
215
214
  step = field ? indent(field) - child : child
216
- at = stop
217
- at -= 1 while at > hosts_at + 1 && (lines[at - 1].strip.empty? || (comment?(lines[at - 1]) && indent(lines[at - 1]) < child))
218
- [at, child, step]
219
- end
220
-
221
- def inside_block?(line)
222
- return true if line.strip.empty?
223
- return false if line.start_with?("---", "...")
224
-
225
- line.start_with?(" ", "\t") || comment?(line)
215
+ [insert_at(lines, hosts_at, stop, child), child, step]
226
216
  end
227
217
 
228
- def blank_or_comment?(line) = line.strip.empty? || comment?(line)
229
- def comment?(line) = line.lstrip.start_with?("#")
230
- def indent(line) = line[/\A */].length
231
-
232
218
  def entry_lines(name, fields, child, step)
233
219
  ["#{" " * child}#{name}:\n", *fields.map { |key, value| "#{" " * (child + step)}#{key}: #{scalar(value)}\n" }]
234
220
  end
@@ -316,23 +302,6 @@ module Samagotchi
316
302
  false
317
303
  end
318
304
 
319
- def parse(text) = YAML.safe_load(text, permitted_classes: [], aliases: false)
320
-
321
- # Anchors and aliases: chi's reader refuses aliases, and an insert
322
- # can't know what an anchor shares.
323
- def anchors?(text)
324
- stack = [Psych.parse_stream(text)]
325
- until stack.empty?
326
- node = stack.pop
327
- return true if node.is_a?(Psych::Nodes::Alias) || (node.respond_to?(:anchor) && node.anchor)
328
-
329
- stack.concat(Array(node.children)) if node.respond_to?(:children)
330
- end
331
- false
332
- rescue Psych::Exception
333
- true
334
- end
335
-
336
305
  def backup_path(real)
337
306
  stamp = @now.call.utc.strftime("%Y%m%dT%H%M%SZ")
338
307
  candidate = "#{real}.bak-#{stamp}"
@@ -40,11 +40,6 @@ module Samagotchi
40
40
  end
41
41
  end
42
42
 
43
- # Whether any buffered event could satisfy a reconnect from +from_seq+.
44
- def has_any_after?(from_seq:)
45
- @mutex.synchronize { @events.any? { |e| e[:seq] > from_seq } }
46
- end
47
-
48
43
  # @return [Integer, nil] the highest seq buffered so far.
49
44
  def last_seq
50
45
  @mutex.synchronize { @events.last&.[](:seq) }
@@ -158,7 +158,8 @@ module Samagotchi
158
158
  def self.tool_replay_events(part)
159
159
  call = { iteration: part[:iteration], call_index: part[:call_index], tool: part[:tool] }
160
160
  call[:label] = part[:label] if part[:label]
161
- title = part[:title] ? { title: part[:title] } : {}
161
+ # Its title and, for an alias's call, the name the model used.
162
+ title = part.slice(:title, :called_as).compact
162
163
  view = part[:view] ? { view: part[:view] } : {}
163
164
  events = [{ type: :tool_call_started, **call, params: part[:params], **title, **view }]
164
165
  if part[:status] == "running"
@@ -274,6 +275,7 @@ module Samagotchi
274
275
  tool: event[:tool], params: event[:params], status: "running" }
275
276
  part[:label] = event[:label] if event[:label]
276
277
  part[:title] = event[:title] if event[:title]
278
+ part[:called_as] = event[:called_as] if event[:called_as]
277
279
  part[:view] = event[:view] if event[:view]
278
280
  parts << part
279
281
  @tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
@@ -7,6 +7,7 @@ require "fileutils"
7
7
  require "securerandom"
8
8
  require "time"
9
9
  require_relative "atomic_file"
10
+ require_relative "delivery"
10
11
  require_relative "session_inbox"
11
12
 
12
13
  require_relative "bridge/bounded_queue"
@@ -20,6 +21,7 @@ require_relative "session"
20
21
  require_relative "engine"
21
22
  require_relative "guardrails/parent_approvals"
22
23
  require_relative "session_commands"
24
+ require_relative "queued_command"
23
25
  require_relative "steer"
24
26
  require_relative "image_store"
25
27
  require_relative "log"
@@ -285,12 +287,11 @@ module Samagotchi
285
287
  # basename): it runs once that file is taken (Worker#on_command)
286
288
  # @return [String] its command_id
287
289
  def queue_command(line, client_id: nil, card: false, after_file: nil)
288
- command = { command_id: SecureRandom.uuid, client_id: client_id, line: line.to_s.strip }
289
290
  # A card's action (the step-limit question's answer): the UIs leave
290
291
  # its line out.
291
- command[:card] = true if card
292
+ command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card)
292
293
  @engine.synchronize_events { queue_command_locked(command, after_file: after_file) }
293
- command[:command_id]
294
+ command.command_id
294
295
  end
295
296
 
296
297
  private
@@ -721,18 +722,17 @@ module Samagotchi
721
722
  # :command_queued, unless +deadline+ has passed. Returns [headers,
722
723
  # status, body]: 202 with its command_id, or 408.
723
724
  def queue_command_request(session_id, line, client_id:, deadline: nil, card: false)
724
- command = { command_id: SecureRandom.uuid, client_id: client_id, line: line }
725
725
  # A card's action: the UIs leave its line out (the card is the echo).
726
- command[:card] = true if card
726
+ command = QueuedCommand.mint(line, client_id: client_id, card: card)
727
727
  queued = @engine.synchronize_events do
728
- next false if expired?("command_expired", deadline, sid: session_id, client_id: command[:client_id])
728
+ next false if expired?("command_expired", deadline, sid: session_id, client_id: command.client_id)
729
729
 
730
730
  queue_command_locked(command)
731
731
  true
732
732
  end
733
733
  return deadline_passed("command") unless queued
734
734
 
735
- [{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
735
+ [{}, 202, { status: "accepted", command_id: command.command_id, session_id: @session_id }]
736
736
  end
737
737
 
738
738
  # With the event log held. +on_command+ answers what it did with the
@@ -747,7 +747,7 @@ module Samagotchi
747
747
  when :queue then { waits: "turn_end" }
748
748
  else {}
749
749
  end
750
- @engine.announce(type: :command_queued, **command, **marks)
750
+ @engine.announce(type: :command_queued, **command.event_fields, **marks)
751
751
  end
752
752
 
753
753
  # A client asks the worker to exit now (`/exit` in the attached TUI), or
@@ -826,6 +826,13 @@ module Samagotchi
826
826
  deadline = fetched(parsed, "deadline")
827
827
  return BAD_DEADLINE unless deadline_valid?(deadline)
828
828
 
829
+ raw_delivery = fetched(parsed, "delivery")
830
+ if (refused = Delivery.refusal(raw_delivery))
831
+ return [{ "Allow" => "POST" }, 400, refused]
832
+ end
833
+
834
+ delivery = Delivery.parse(raw_delivery)
835
+
829
836
  # A session command sent as a message (chi send -m "/model x", chi -p,
830
837
  # a UI whose command list lags) runs as the command, as typed in a
831
838
  # TUI; an unknown /word stays a prompt. Not with images: those are
@@ -844,7 +851,7 @@ module Samagotchi
844
851
  next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
845
852
 
846
853
  enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
847
- no_interrupt: no_interrupt, images: images).tap do |ok|
854
+ no_interrupt: no_interrupt, images: images, delivery: delivery).tap do |ok|
848
855
  next unless ok
849
856
 
850
857
  enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
@@ -856,12 +863,17 @@ module Samagotchi
856
863
  return deadline_passed("turn") if enqueued == :expired
857
864
  return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
858
865
 
859
- # Queued (outside the event lock): a running generation that has only
860
- # been thinking for long is cut for it, unless a plugin or a client
861
- # chi doesn't know sent it. An image input never merges mid-turn, so
862
- # it never cuts.
863
- @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty?
864
- [{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id }]
866
+ # Queued (outside the event lock): a message sent as +cut+ cuts a
867
+ # running generation that has only been thinking for long, unless a
868
+ # plugin or a client chi doesn't know sent it. An image input never
869
+ # merges mid-turn, so it never cuts. Nothing else cuts: a message
870
+ # that says nothing (an older client, the web composer) goes in at
871
+ # the running turn's next step.
872
+ cut = nil
873
+ cut = @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty? && Delivery.cut?(delivery)
874
+ ack = { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id, delivery: delivery }
875
+ ack[:cut] = cut.to_s if cut
876
+ [{}, 202, ack]
865
877
  end
866
878
 
867
879
  # Read-only snapshot surface. AC #4: too-old reconnects re-derive state
@@ -933,9 +945,9 @@ module Samagotchi
933
945
 
934
946
  # Write a turn into this session's input dir, reusing the file IPC the
935
947
  # worker polls. Never calls run_turn across the boundary.
936
- def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [])
948
+ def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil)
937
949
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
938
- no_interrupt: no_interrupt, images: images)
950
+ no_interrupt: no_interrupt, images: images, delivery: delivery)
939
951
  end
940
952
 
941
953
  # ── HTTP plumbing ────────────────────────────────────────────────────────
@@ -4,6 +4,7 @@ require "json"
4
4
  require "socket"
5
5
 
6
6
  require_relative "worker_sidecar"
7
+ require_relative "delivery"
7
8
  require_relative "bridge_client/sse_parser"
8
9
  require_relative "bridge_client/event_stream"
9
10
 
@@ -139,10 +140,11 @@ module Samagotchi
139
140
  # @param no_interrupt [Boolean] run the turn with the raised iteration limit
140
141
  # @param images [Array<Hash>] refs ({file:, name:}) to images already in
141
142
  # the session's images/
142
- def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil)
143
+ def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil)
143
144
  body = { session_id: @session_id, prompt: prompt, client_id: client_id }
144
145
  body[:no_interrupt] = true if no_interrupt
145
146
  body[:images] = images if images && !images.empty?
147
+ body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
146
148
  body[:deadline] = deadline
147
149
  post("turn", body, read_body: true)
148
150
  end
@@ -46,8 +46,7 @@ module Samagotchi
46
46
  return false unless (time(row[:updated_at]) || Time.at(0)) >= cutoff
47
47
 
48
48
  session = Session.summary_from_file(Session.session_file(row[:id], state_dir: state_dir))
49
- last_turn = session&.last_turn
50
- ended = last_turn.is_a?(Hash) ? time(last_turn["ended_at"] || last_turn[:ended_at]) : nil
49
+ ended = time(session&.last_turn&.ended_at)
51
50
  (ended || time(row[:updated_at])) >= cutoff
52
51
  end
53
52
  private_class_method :active_since?
@@ -58,7 +58,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
58
58
 
59
59
  - `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
60
60
  - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
61
- - `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
61
+ - `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
63
63
  - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
64
64
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.50.0
3
+ version: 0.52.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
- config_modification_protocol.md: sha256:337ad1b4cc9308dc5abb47b13f51a76df4691ba80fe41648f391687b5b929b65
9
+ config_modification_protocol.md: sha256:a7a6352c03979db69d98955bee8f36eb468a6cf68904d418b7f3ddb788c83e7b
10
10
  memory_guide.md: sha256:15cd3b24eda853cc458371c6c75809a2d4b5352311d075aaa4384926b5221c27
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -95,7 +95,7 @@ module Samagotchi
95
95
  return "running" if Tools::Delegate.running?(row)
96
96
  return "stopped" if session.status == Session::STATUS_STOPPED
97
97
 
98
- outcome = session.last_turn.is_a?(Hash) ? session.last_turn["outcome"] : nil
98
+ outcome = session.last_turn&.outcome
99
99
  return "failed" if session.status == Session::STATUS_ERROR || outcome == "failed"
100
100
  return "done" if outcome == "completed" && reply
101
101
 
@@ -21,7 +21,8 @@ module Samagotchi
21
21
  # turn runs (MID_TURN): a symbol, or a lambda line (stripped) → one
22
22
  # @!attribute local [Boolean] the UI runs it; completion and help only
23
23
  # @!attribute uis [Array<Symbol>, nil] a local entry's UIs (:repl,
24
- # :attached) for completion; nil means every UI
24
+ # :attached, :web) for completion and /help; nil means every UI
25
+ # (for a local entry, every terminal)
25
26
  # @!attribute match [#call] line (stripped) → whether it is this command
26
27
  # @!attribute handler [Proc, nil] runs it; SessionCommands instance_execs
27
28
  # a built-in's with the line, and calls a bundle's with the text