samagotchi 0.58.0 → 0.60.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +6 -4
  6. data/docs/cli.md +14 -7
  7. data/docs/configuration.md +96 -31
  8. data/docs/hooks.md +12 -6
  9. data/docs/internals/background-tasks.md +7 -0
  10. data/docs/internals/engine.md +20 -12
  11. data/docs/internals/gemma4-contract.md +1 -0
  12. data/docs/internals/prompt-caching.md +4 -3
  13. data/docs/internals/tool-guardrails.md +2 -1
  14. data/docs/memory.md +1 -1
  15. data/docs/plugins.md +75 -6
  16. data/docs/sessions.md +2 -2
  17. data/docs/sub-agent.md +2 -2
  18. data/docs/testing.md +4 -2
  19. data/lib/samagotchi/bootstrap_command.rb +2 -1
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  21. data/lib/samagotchi/bridge.rb +6 -2
  22. data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
  23. data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
  24. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  25. data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
  26. data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  28. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  29. data/lib/samagotchi/config.rb +44 -10
  30. data/lib/samagotchi/continue_offer.rb +5 -2
  31. data/lib/samagotchi/engine.rb +89 -15
  32. data/lib/samagotchi/host_config.rb +3 -1
  33. data/lib/samagotchi/host_registry.rb +2 -1
  34. data/lib/samagotchi/iteration_limit.rb +3 -2
  35. data/lib/samagotchi/kernel_loop.rb +35 -9
  36. data/lib/samagotchi/llm/backend.rb +6 -1
  37. data/lib/samagotchi/llm/chat_loop.rb +11 -6
  38. data/lib/samagotchi/llm/model_result.rb +11 -2
  39. data/lib/samagotchi/llm/native_backend.rb +3 -2
  40. data/lib/samagotchi/log_subscriber.rb +2 -1
  41. data/lib/samagotchi/model_settings.rb +4 -2
  42. data/lib/samagotchi/plugin/context.rb +16 -3
  43. data/lib/samagotchi/process_group.rb +76 -3
  44. data/lib/samagotchi/prompt_files.rb +127 -0
  45. data/lib/samagotchi/ref.rb +124 -0
  46. data/lib/samagotchi/send_command.rb +1 -1
  47. data/lib/samagotchi/session.rb +15 -3
  48. data/lib/samagotchi/session_commands.rb +15 -3
  49. data/lib/samagotchi/session_metrics.rb +8 -2
  50. data/lib/samagotchi/session_setup.rb +11 -5
  51. data/lib/samagotchi/steer_cut.rb +12 -8
  52. data/lib/samagotchi/steer_cut_after.rb +69 -0
  53. data/lib/samagotchi/system_prompt.rb +74 -18
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  56. data/lib/samagotchi/terminal_ui/formatting.rb +23 -5
  57. data/lib/samagotchi/terminal_ui.rb +18 -6
  58. data/lib/samagotchi/tool_activity.rb +18 -1
  59. data/lib/samagotchi/tool_declarations.rb +1 -1
  60. data/lib/samagotchi/tools/execute.rb +318 -70
  61. data/lib/samagotchi/tools/task_get.rb +2 -0
  62. data/lib/samagotchi/tools/task_runtime.rb +96 -13
  63. data/lib/samagotchi/tools/task_wait.rb +23 -5
  64. data/lib/samagotchi/turn_flow.rb +22 -3
  65. data/lib/samagotchi/utf8_default.rb +1 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/public/favicon.svg +1 -0
  68. data/lib/samagotchi/web/public/index.html +1 -0
  69. data/lib/samagotchi/web/public/read_view.js +3 -1
  70. data/lib/samagotchi/web/public/refs.js +107 -15
  71. data/lib/samagotchi/web/public/task_stop.js +25 -7
  72. data/lib/samagotchi/worker.rb +1 -0
  73. metadata +7 -1
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: core
3
- version: 0.2.0
3
+ version: 0.3.0
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
@@ -10,3 +10,4 @@ includes:
10
10
  - check-in
11
11
  - guardrails
12
12
  - slow-commands
13
+ - agents-md
@@ -1,11 +1,12 @@
1
1
  ---
2
2
  name: slow-commands
3
- version: 0.1.0
3
+ version: 0.2.0
4
4
  scope: system
5
5
  description: "Remembers commands that ran past the execute limit in a project; the next identical execute is denied once a turn with advice to run it with task_create and task_wait; /slow-commands lists and forgets them"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:00a35b8f930d8f99a908b99c5eb3a5e97b1209c63c616037072580d92d3c8639
10
- # after_tool_call's call:, elapsed_ms: and result: (0.57.0)
11
- requires_chi: ">= 0.57.0"
9
+ sha256: sha256:bf2cfccbcadedcd85ea0b53f2d263c8729e14ed8cf310bca4a56affe50181292
10
+ # Execute::HANDED_OVER, read when the plugin loads (0.59.0); after_tool_call's
11
+ # call:, elapsed_ms: and result: (0.57.0)
12
+ requires_chi: ">= 0.59.0"
@@ -11,13 +11,22 @@
11
11
  # (relative to the project), and a trailing output filter dropped (a final
12
12
  # 2>&1, `| tail …`, `| head …`, `| grep …`: they don't change how long it
13
13
  # runs). Arguments stay: `bundle exec rspec` (the suite) is not
14
- # `bundle exec rspec spec/x_spec.rb`. Input that ends inside a quote, $(…)
14
+ # `bundle exec rspec spec/x_spec.rb`. Wrappers a model adds by habit are
15
+ # dropped at the start of each simple command (after &&, ||, ;, a newline,
16
+ # so after a leading `cd X &&` too): `time [-p]` and `timeout`/`gtimeout`
17
+ # with its options and duration. `X=…` assignments stay, sorted (they can
18
+ # pick the target or the mode: SPEC=…, RAILS_ENV=…, CI=… (jest/vitest
19
+ # watch without it)), except NO_COLOR, FORCE_COLOR and TERM, which never
20
+ # change what runs; a plain
21
+ # `env X=… cmd` is `X=… cmd`, an `env` with -C/-i/-u (or any other option)
22
+ # stays as typed. The entry's `shown` is the last run as typed. Input that ends inside a quote, $(…)
15
23
  # or heredoc is never recorded, nor one with a $(…), backticks or a heredoc
16
24
  # cat/tee read as data: ShellLex makes those one placeholder, so different
17
25
  # commands would share a key.
18
26
  #
19
- # after_tool_call records: timed out, or ran at least warn_ratio of the
20
- # limit -> slow; ran under half of it with exit 0 -> the entry is dropped
27
+ # after_tool_call records: timed out (killed at the limit), handed over as
28
+ # a task at the limit (Execute::HANDED_OVER), or ran at least warn_ratio of
29
+ # the limit -> slow; ran under half of it with exit 0 -> the entry is dropped
21
30
  # (a fast failure says nothing about the suite's length), unless a trailing
22
31
  # filter was stripped: `rspec | tail` exits with tail's 0 when rspec failed,
23
32
  # and a `| head` can end it early. A stopped or
@@ -25,15 +34,23 @@
25
34
  # running it again in the same turn is let through (maybe it got faster),
26
35
  # and that run records the new truth. before_turn resets it.
27
36
  #
28
- # Store: <data_dir>/commands.json, a list of {project, command, slow_at,
29
- # limit_s, elapsed_s, timed_out}, re-read on each lookup (another session's
37
+ # Store: <data_dir>/commands.json, a list of {project, command (the key),
38
+ # shown, slow_at, limit_s, elapsed_s, timed_out}, re-read on each lookup (another session's
30
39
  # record counts at once), written whole (AtomicFile), entries older than
31
40
  # ttl_days dropped on write.
32
41
  #
33
42
  # The advice names how long a finished task of the same command took: the
34
43
  # project's tmp/tasks/*/task.json read as files (never TaskRuntime's
35
44
  # list_records, which mkdirs, runs ps and writes), the exit_code file's
36
- # mtime minus started_at (finished_at is when chi noticed).
45
+ # mtime minus started_at (finished_at is when chi noticed). The exit_code
46
+ # file is the record's exit_code_path (a handed-over execute keeps it in
47
+ # the state dir), else the one next to task.json.
48
+ #
49
+ # A `timeout N` over the execute limit is denied up front (no history
50
+ # needed) when execute.on_timeout is kill: execute stops it first. Under
51
+ # task (the default) it runs on as a task until timeout fires: nothing to
52
+ # say. Exit codes mean what they always do here: 124/137 (timeout's) are
53
+ # no sign of a slow command by themselves.
37
54
  #
38
55
  # Settings (config.yml, bundles: slow-commands:):
39
56
  # mode: deny deny | notify (warn the user, never deny)
@@ -46,6 +63,7 @@ require "json"
46
63
  require "pathname"
47
64
  require "time"
48
65
  require "samagotchi/atomic_file"
66
+ require "samagotchi/config"
49
67
  require "samagotchi/guardrails/shell_lex"
50
68
  require "samagotchi/tools/execute"
51
69
  require "samagotchi/tools/task_runtime"
@@ -55,10 +73,23 @@ class Plugin
55
73
  FILE = "commands.json"
56
74
  FILTERS = %w[tail head grep].freeze
57
75
  TIMED_OUT = /\A#{Regexp.escape(Samagotchi::Tools::Execute::TIMEOUT_PREFIX)} (\d+)s/
76
+ # Still running at the limit, handed over as a task (chi >= 0.59.0).
77
+ HANDED_OVER = /\A#{Regexp.escape(Samagotchi::Tools::Execute::HANDED_OVER)} (\d+)s limit/
58
78
  STOPPED = [Samagotchi::Tools::Execute::STOPPED_BY_USER, Samagotchi::Tools::Execute::NOT_RUN_ON_STOP].freeze
59
79
  LEX = Samagotchi::Guardrails::ShellLex
60
80
  SHORT_CHARS = 60
61
81
  WAIT_DEFAULT = 600
82
+ # Where a simple command starts: a wrapper there is dropped from the key.
83
+ STARTS = ["&&", "||", ";", "\n"].freeze
84
+ ASSIGNMENT = /\A[A-Za-z_][A-Za-z0-9_]*=/
85
+ # Assignments that never change what runs: not part of the key.
86
+ NEUTRAL_VARS = %w[NO_COLOR FORCE_COLOR TERM].freeze
87
+ TIMEOUTS = %w[timeout gtimeout].freeze
88
+ # timeout's options: those that take a value, and the flags.
89
+ TIMEOUT_VALUE_OPTIONS = %w[-s -k --signal --kill-after].freeze
90
+ TIMEOUT_FLAGS = %w[--foreground --preserve-status -v --verbose].freeze
91
+ DURATION = /\A(\d+(?:\.\d+)?|\.\d+)([smhd]?)\z/
92
+ UNIT_SECONDS = { "" => 1, "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
62
93
 
63
94
  def initialize(settings = {})
64
95
  @mode = settings["mode"].to_s == "notify" ? :notify : :deny
@@ -87,6 +118,7 @@ class Plugin
87
118
  tokens = LEX.lex(text)
88
119
  return nil if tokens.any? { |kind, value| kind == :word && value.include?("\0") }
89
120
 
121
+ tokens = unwrap(tokens).first
90
122
  tokens = strip_filters(tokens)
91
123
  return nil if tokens.empty?
92
124
 
@@ -96,6 +128,95 @@ class Plugin
96
128
  words.join(" ")
97
129
  end
98
130
 
131
+ # The tokens without the wrappers at the start of each simple command
132
+ # (time, timeout), assignments sorted and the neutral ones dropped, and
133
+ # the seconds of every timeout dropped. A simple command left with no
134
+ # words (`NO_COLOR=1 && rspec`) goes with the operator after it, and a
135
+ # last one with the operator before it.
136
+ # @return [Array(Array, Array<Float>)]
137
+ def self.unwrap(tokens)
138
+ durations = []
139
+ out = []
140
+ segment = []
141
+ flush = lambda do
142
+ words, seconds = unwrap_command(segment)
143
+ durations.concat(seconds)
144
+ out.concat(words)
145
+ segment = []
146
+ !words.empty?
147
+ end
148
+ tokens.each do |token|
149
+ if token[0] == :op && STARTS.include?(token[1])
150
+ out << token if flush.call
151
+ else
152
+ segment << token
153
+ end
154
+ end
155
+ out.pop if !flush.call && out.last&.first == :op && STARTS.include?(out.last[1])
156
+ [out, durations]
157
+ end
158
+
159
+ # One simple command's tokens (it may hold a | or & further on: only its
160
+ # start is looked at).
161
+ def self.unwrap_command(tokens)
162
+ rest = tokens.dup
163
+ assignments = []
164
+ durations = []
165
+ loop do
166
+ assignments << rest.shift[1] while word?(rest.first) && rest.first[1].match?(ASSIGNMENT)
167
+ head = word?(rest.first) ? rest.first[1] : nil
168
+ # time -p only: another option (GNU time -v) is a different program.
169
+ if head == "time" && !(word?(rest[1]) && rest[1][1].start_with?("-") && rest[1][1] != "-p")
170
+ rest.shift
171
+ rest.shift if word?(rest.first) && rest.first[1] == "-p"
172
+ elsif head == "env" && word?(rest[1]) && !rest[1][1].start_with?("-")
173
+ rest.shift
174
+ elsif TIMEOUTS.include?(head) && (skipped = timeout_length(rest))
175
+ count, seconds = skipped
176
+ rest.shift(count)
177
+ durations << seconds
178
+ else
179
+ break
180
+ end
181
+ end
182
+ kept = assignments.reject { |a| NEUTRAL_VARS.include?(a.split("=", 2).first) }.sort
183
+ [kept.map { |a| [:word, a, ""] } + rest, durations]
184
+ end
185
+
186
+ # [words to drop, seconds] for a `timeout [opts] DUR` at the start of
187
+ # +tokens+, or nil when it can't be read (then it stays in the key).
188
+ def self.timeout_length(tokens)
189
+ i = 1
190
+ while word?(tokens[i]) && tokens[i][1].start_with?("-")
191
+ option = tokens[i][1]
192
+ if TIMEOUT_FLAGS.include?(option) || option.start_with?("--signal=", "--kill-after=")
193
+ i += 1
194
+ elsif TIMEOUT_VALUE_OPTIONS.include?(option)
195
+ i += 2
196
+ else
197
+ return nil
198
+ end
199
+ end
200
+ seconds = word?(tokens[i]) ? duration_seconds(tokens[i][1]) : nil
201
+ seconds && word?(tokens[i + 1]) ? [i + 1, seconds] : nil
202
+ end
203
+
204
+ def self.duration_seconds(text)
205
+ match = DURATION.match(text)
206
+ match ? Float(match[1]) * UNIT_SECONDS.fetch(match[2]) : nil
207
+ end
208
+
209
+ def self.word?(token) = token && token[0] == :word
210
+
211
+ # The longest `timeout N` (seconds) at the start of a simple command in
212
+ # +text+, or nil for none (or text that can't be keyed).
213
+ def self.timeout_seconds(text)
214
+ text = text.to_s.strip
215
+ return nil if text.empty? || LEX.unterminated?(text)
216
+
217
+ unwrap(LEX.lex(text)).last.max
218
+ end
219
+
99
220
  # Whether the key dropped a trailing output filter (2>&1, | tail …):
100
221
  # the exit code is then the filter's, and the run may have been cut short.
101
222
  def self.filtered?(text)
@@ -148,18 +269,44 @@ class Plugin
148
269
  key = key_for(call, ctx)
149
270
  return unless key
150
271
  return if @denied[key]
272
+ return if over_limit_timeout(call, verdict, ctx, key)
151
273
 
152
274
  entry = live_entries(ctx).find { |e| e["project"] == key[0] && e["command"] == key[1] }
153
275
  return unless entry
154
276
 
155
277
  @denied[key] = true
156
278
  if @mode == :notify
157
- ctx.notify("execute #{short(key[1])}: #{what(entry)} before", level: :warn)
279
+ ctx.notify("execute #{short(call[:content])}: #{what(entry)} before", level: :warn)
158
280
  return
159
281
  end
160
282
 
161
283
  verdict.deny!("slow command", source: SOURCE, advice: advice(entry, ctx, key))
162
- ctx.notify("execute #{short(key[1])}: #{what(entry)} before, denied", level: :warn)
284
+ ctx.notify("execute #{short(call[:content])}: #{what(entry)} before, denied", level: :warn)
285
+ end
286
+
287
+ # A `timeout N` over the limit while execute kills at it (on_timeout:
288
+ # kill): N never takes effect. Denied once a turn, like a slow entry.
289
+ # @return [Boolean] whether it was handled
290
+ def over_limit_timeout(call, verdict, ctx, key)
291
+ return false unless Samagotchi::Config.get("execute.on_timeout").to_s == "kill"
292
+
293
+ seconds = self.class.timeout_seconds(call[:content])
294
+ limit = Samagotchi::Tools::Execute.timeout_seconds
295
+ return false unless seconds && seconds > limit
296
+
297
+ @denied[key] = true
298
+ wait = [WAIT_DEFAULT, seconds.ceil].max
299
+ line = "execute #{short(call[:content])}: timeout #{duration(seconds.ceil)} is past the #{limit} s limit"
300
+ if @mode == :notify
301
+ ctx.notify(line, level: :warn)
302
+ else
303
+ verdict.deny!("timeout past the execute limit", source: SOURCE,
304
+ advice: "execute stops a command at #{limit} s, so its timeout " \
305
+ "(#{duration(seconds.ceil)}) never takes effect. Start it " \
306
+ "with task_create, then task_wait (timeout #{wait}).")
307
+ ctx.notify("#{line}, denied", level: :warn)
308
+ end
309
+ true
163
310
  end
164
311
 
165
312
  def after(event, ctx)
@@ -174,10 +321,11 @@ class Plugin
174
321
  return unless key
175
322
 
176
323
  limit = Samagotchi::Tools::Execute.timeout_seconds
177
- if (timed_out = result[TIMED_OUT, 1])
178
- remember(ctx, key, limit_s: timed_out.to_i, elapsed_s: timed_out.to_i, timed_out: true)
324
+ shown = call[:content].to_s.strip
325
+ if (timed_out = result[TIMED_OUT, 1] || result[HANDED_OVER, 1])
326
+ remember(ctx, key, shown, limit_s: timed_out.to_i, elapsed_s: timed_out.to_i, timed_out: true)
179
327
  elsif elapsed_ms >= @warn_ratio * limit * 1000
180
- remember(ctx, key, limit_s: limit, elapsed_s: (elapsed_ms / 1000.0).round, timed_out: false)
328
+ remember(ctx, key, shown, limit_s: limit, elapsed_s: (elapsed_ms / 1000.0).round, timed_out: false)
181
329
  elsif elapsed_ms < 0.5 * limit * 1000 && exit_zero?(result) && !self.class.filtered?(call[:content])
182
330
  forget_key(ctx, key)
183
331
  end
@@ -218,9 +366,10 @@ class Plugin
218
366
  Samagotchi::AtomicFile.write(store_path(ctx), "#{JSON.pretty_generate(list.select { |e| live?(e) })}\n")
219
367
  end
220
368
 
221
- def remember(ctx, key, limit_s:, elapsed_s:, timed_out:)
369
+ # +shown+: the command as this run typed it (the list shows it).
370
+ def remember(ctx, key, shown, limit_s:, elapsed_s:, timed_out:)
222
371
  list = entries(ctx).reject { |e| e["project"] == key[0] && e["command"] == key[1] }
223
- list << { "project" => key[0], "command" => key[1], "slow_at" => Time.now.utc.iso8601,
372
+ list << { "project" => key[0], "command" => key[1], "shown" => shown, "slow_at" => Time.now.utc.iso8601,
224
373
  "limit_s" => limit_s, "elapsed_s" => elapsed_s, "timed_out" => timed_out }
225
374
  save(ctx, list)
226
375
  end
@@ -259,7 +408,8 @@ class Plugin
259
408
  # [started_at, seconds] for a task record of +key+ that ended with exit 0.
260
409
  def finished_task(path, ctx, key)
261
410
  record = JSON.parse(File.read(path))
262
- exit_code = File.join(File.dirname(path), Samagotchi::Tools::TaskRuntime::EXIT_CODE_FILENAME)
411
+ exit_code = record["exit_code_path"].to_s
412
+ exit_code = File.join(File.dirname(path), Samagotchi::Tools::TaskRuntime::EXIT_CODE_FILENAME) unless File.absolute_path?(exit_code)
263
413
  return nil unless File.file?(exit_code) && File.read(exit_code).strip == "0"
264
414
 
265
415
  command = self.class.normalize(record["command"], cwd: record["cwd"], project: key[0], base: ctx.cwd)
@@ -282,7 +432,7 @@ class Plugin
282
432
  return "slow-commands: none recorded for #{project}" if mine.empty?
283
433
 
284
434
  lines = mine.each_with_index.map do |e, i|
285
- "#{i + 1}. #{e["command"]}: #{what(e)} (#{e["slow_at"].to_s[0, 10]})"
435
+ "#{i + 1}. #{shown(e)}: #{what(e)} (#{e["slow_at"].to_s[0, 10]})"
286
436
  end
287
437
  "slow-commands in #{project}:\n#{lines.join("\n")}\n/slow-commands forget <n|all> drops an entry"
288
438
  when /\Aforget\s+(all|\d+)\z/
@@ -291,7 +441,7 @@ class Plugin
291
441
  return "slow-commands: no entry #{which} (/slow-commands lists them)" if drop.empty?
292
442
 
293
443
  save(ctx, entries(ctx).reject { |e| drop.any? { |d| d["project"] == e["project"] && d["command"] == e["command"] } })
294
- "slow-commands: forgot #{drop.size == 1 ? drop.first["command"] : "#{drop.size} commands"}"
444
+ "slow-commands: forgot #{drop.size == 1 ? shown(drop.first) : "#{drop.size} commands"}"
295
445
  else
296
446
  "usage: /slow-commands (list this project's slow commands), /slow-commands forget <n|all>"
297
447
  end
@@ -299,6 +449,9 @@ class Plugin
299
449
 
300
450
  # --- text ---
301
451
 
452
+ # The command as last typed; an entry from 0.1.0 has only its key.
453
+ def shown(entry) = entry["shown"].to_s.empty? ? entry["command"] : entry["shown"]
454
+
302
455
  def what(entry)
303
456
  entry["timed_out"] ? "ran past #{entry["limit_s"]} s" : "took #{duration(entry["elapsed_s"])} of #{entry["limit_s"]} s"
304
457
  end
@@ -121,7 +121,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
121
121
  1. **Read** the current file via `read` tool (or `ConfigFile.global_path`). If `File.file?` false, start from `{}`.
122
122
  2. `YAML.safe_load` (permitted_classes: [], aliases: false). If data nil or not Hash, treat as `{}` or raise with path.
123
123
  3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
124
- 4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agent_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
124
+ 4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agents_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:`/`append_system_prompt:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
125
125
  5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
126
126
  6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, bundle settings) waits for its restart; guardrail rules and `model_aliases:` are read again live. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
127
127
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.58.0
3
+ version: 0.60.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:d908921b5988244fb2a03fe04e693d0e7a1dfadf8cd77704e9232826db7eb2d7
9
+ config_modification_protocol.md: sha256:16403da5dd97a335e19d9d15bb1ba4390237bf9f4869e35d63c172a8828b5ae0
10
10
  memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -20,6 +20,8 @@ module Samagotchi
20
20
  # One models: entry, and one field found in them; they need ConfigFile's readers.
21
21
  autoload :ModelSettings, File.expand_path("model_settings", __dir__)
22
22
  autoload :ModelSetting, File.expand_path("model_settings", __dir__)
23
+ # The per-model/host seconds a cut waits (like Thinking for the level).
24
+ autoload :SteerCutAfter, File.expand_path("steer_cut_after", __dir__)
23
25
 
24
26
  # Unified configuration registry implementing the implicit convention:
25
27
  # ENV SAMAGOTCHI_ATTR (UPPER + prefix + _ = nesting)
@@ -216,11 +218,12 @@ module Samagotchi
216
218
  Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
217
219
  # Seconds a generation must have streamed only thinking before a message
218
220
  # for the running turn (the user's, chi send's, a parent agent's) cuts
219
- # it (Engine#cut_for_steer); 0 = never. No CLI flag: workers get no CLI args.
221
+ # it (Engine#cut_for_steer); 0 = never. hosts.<name>.steer_cut_after and
222
+ # models.<key>.steer_cut_after win. No CLI flag: workers get no CLI args.
220
223
  Entry.new(key: "steer.cut_after", yaml_path: %w[steer cut_after], type: :integer, default: 20, expose: %i[env config]),
221
224
  # A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
222
225
  # --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
223
- Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 100, expose: %i[env config]),
226
+ Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 300, expose: %i[env config]),
224
227
  # Whether a parent agent (chi answer) may answer Continue to a session's step-limit question; false: Stop only.
225
228
  # config.yml only, like guardrails.parent_approvals: a parent must not decide it from its own environment.
226
229
  Entry.new(key: "turn.parent_continue", yaml_path: %w[turn parent_continue], type: :bool, default: true, expose: %i[config]),
@@ -242,8 +245,11 @@ module Samagotchi
242
245
  Entry.new(key: "execute.truncate_at_bytes", yaml_path: %w[execute truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
243
246
  Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
244
247
  Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
245
- # Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
248
+ # Seconds one execute command may run before execute.on_timeout applies (Tools::Execute::TIMEOUT_SEC).
246
249
  Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
250
+ # What happens to a command still running at that limit (Tools::Execute::ON_TIMEOUT): task hands it over as a
251
+ # background task (task_wait waits for it), kill stops it.
252
+ Entry.new(key: "execute.on_timeout", yaml_path: %w[execute on_timeout], type: :enum, default: "task", expose: %i[env config cli], enum_values: %w[task kill]),
247
253
  # Whether execute declares its optional description parameter (a few model-written words, a tool row's title).
248
254
  Entry.new(key: "execute.description", yaml_path: %w[execute description], type: :bool, default: true, expose: %i[env config]),
249
255
 
@@ -263,19 +269,19 @@ module Samagotchi
263
269
  # env+config only
264
270
  Entry.new(key: "default.input", yaml_path: %w[default input], type: :string, default: nil, expose: %i[env config]),
265
271
  Entry.new(key: "history.file", yaml_path: %w[history file], type: :string, default: nil, expose: %i[env config]),
266
- Entry.new(key: "skip_agent_md", yaml_path: %w[skip_agent_md], type: :bool, default: false, expose: %i[env config])
272
+ Entry.new(key: "skip_agents_md", yaml_path: %w[skip_agents_md], type: :bool, default: false, expose: %i[env config])
267
273
  ].freeze
268
274
 
269
- # Top-level maps read by their own code, whose entry names and contents
270
- # are the user's: ConfigFile.model_aliases / preloaded_memories,
271
- # Hooks::Loader, Engine#read_bundle_settings.
272
- FREE_FORM_MAPS = %w[model_aliases hooks bundles memories].freeze
275
+ # Top-level maps and lists read by their own code, whose entry names and
276
+ # contents are the user's: ConfigFile.model_aliases / preloaded_memories /
277
+ # appended_prompt_files, Hooks::Loader, Engine#read_bundle_settings.
278
+ FREE_FORM_MAPS = %w[model_aliases hooks bundles memories append_system_prompt].freeze
273
279
  # Maps of named entries and the keys an entry may hold
274
280
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
275
281
  MAP_ENTRY_KEYS = {
276
282
  "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking
277
- reasoning_history enabled remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
278
- "models" => %w[profile vision sampling thinking reasoning_history window_tokens llm_context_strategy llm_context_apply
283
+ reasoning_history enabled remote window_tokens steer_cut_after llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
284
+ "models" => %w[profile vision sampling thinking reasoning_history window_tokens steer_cut_after llm_context_strategy llm_context_apply
279
285
  llm_context_budget_tokens].freeze
280
286
  }.freeze
281
287
  # The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
@@ -981,6 +987,34 @@ module Samagotchi
981
987
  []
982
988
  end
983
989
 
990
+ # Resolve the `append_system_prompt:` list: files whose text goes into
991
+ # the system prompt after AGENTS.md (SystemPrompt). A YAML list of
992
+ # strings or a single string; ~ is expanded, a relative entry is kept as
993
+ # written (resolved against the cwd the prompt is built in). [] when
994
+ # the key is absent or malformed.
995
+ # @return [Array<String>]
996
+ def appended_prompt_files(env: ENV, path: global_path(env: env))
997
+ data = read_yaml(env: env, path: path)
998
+ raw = data["append_system_prompt"] if data.is_a?(Hash)
999
+ items = case raw
1000
+ when Array then raw
1001
+ when String then [raw]
1002
+ else []
1003
+ end
1004
+ items.select { |v| v.is_a?(String) }.map(&:strip).reject(&:empty?).filter_map { |v| expand_appended_entry(v) }
1005
+ rescue StandardError
1006
+ []
1007
+ end
1008
+
1009
+ # An append_system_prompt: entry with ~ expanded; nil (skipped, logged)
1010
+ # for a ~user that doesn't exist, so the other entries still load.
1011
+ def expand_appended_entry(entry)
1012
+ entry.start_with?("~") ? File.expand_path(entry) : entry
1013
+ rescue ArgumentError => e
1014
+ Samagotchi::Log.warn(:config, "appended_prompt_file_skipped", entry: entry, error: e.message) if defined?(Samagotchi::Log)
1015
+ nil
1016
+ end
1017
+
984
1018
  # Parse a model string that may be qualified as "host_alias:model"
985
1019
  # Returns [host_alias_or_nil, bare_model] (ModelRef.split)
986
1020
  def parse_host_qualified_model(raw, hosts: nil)
@@ -111,13 +111,15 @@ module Samagotchi
111
111
  end
112
112
 
113
113
  # The continue offer was answered yes: resume the conversation without a
114
- # user message, with the iteration limit the offer's turn had. A failure
114
+ # user message, with the iteration limit the offer's turn had, from the
115
+ # run that turn started (TurnFlow#resume_from: its reasoning stays). A failure
115
116
  # keeps the offer, as the REPL does ("continue prompt preserved"), and
116
117
  # asks again, one before the turn began too (logged, no :turn_failed).
117
118
  # The answer's text is carried into the turn as it begins; a turn that
118
119
  # never began leaves it to no other.
119
120
  def run_continue_turn(command)
120
121
  offer = @turn_flow.offer
122
+ resume_from = @turn_flow.resume_from
121
123
  close("answered")
122
124
  steer = @continue_steer
123
125
  @continue_steer = nil
@@ -129,7 +131,8 @@ module Samagotchi
129
131
  Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
130
132
  return announce_offer(offer)
131
133
  end
132
- @run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact,
134
+ @run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact, resumed: true,
135
+ resume_from: resume_from,
133
136
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
134
137
  if error
135
138
  announce_offer(offer)