samagotchi 0.55.0 → 0.57.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +6 -2
  5. data/docs/cli.md +30 -8
  6. data/docs/configuration.md +84 -8
  7. data/docs/hooks.md +2 -2
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/engine.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -0
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +63 -0
  13. data/docs/sessions.md +1 -1
  14. data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +3 -0
  16. data/lib/samagotchi/bootstrap_command.rb +2 -0
  17. data/lib/samagotchi/broadcast_command.rb +2 -0
  18. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  19. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/cache_confirm.rb +51 -0
  27. data/lib/samagotchi/client_id.rb +12 -0
  28. data/lib/samagotchi/config.rb +7 -3
  29. data/lib/samagotchi/context_command.rb +2 -0
  30. data/lib/samagotchi/desktop/macos.rb +3 -0
  31. data/lib/samagotchi/engine.rb +102 -8
  32. data/lib/samagotchi/engine_peers.rb +1 -0
  33. data/lib/samagotchi/gem_update.rb +3 -0
  34. data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
  35. data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
  36. data/lib/samagotchi/host_config.rb +6 -3
  37. data/lib/samagotchi/host_registry.rb +19 -3
  38. data/lib/samagotchi/installed_versions.rb +3 -0
  39. data/lib/samagotchi/kernel_loop.rb +99 -16
  40. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  41. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  42. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  43. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  44. data/lib/samagotchi/model_settings.rb +5 -2
  45. data/lib/samagotchi/plugin/context.rb +5 -0
  46. data/lib/samagotchi/reasoning_history.rb +132 -0
  47. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  48. data/lib/samagotchi/redacted_inspect.rb +28 -0
  49. data/lib/samagotchi/self_report.rb +21 -0
  50. data/lib/samagotchi/session.rb +23 -2
  51. data/lib/samagotchi/session_commands.rb +55 -15
  52. data/lib/samagotchi/session_manager.rb +2 -1
  53. data/lib/samagotchi/session_setup.rb +7 -5
  54. data/lib/samagotchi/setting_explained.rb +20 -0
  55. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  56. data/lib/samagotchi/terminal_ui/attached_loop.rb +23 -5
  57. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  58. data/lib/samagotchi/terminal_ui.rb +13 -6
  59. data/lib/samagotchi/thinking.rb +20 -25
  60. data/lib/samagotchi/thinking_command.rb +33 -11
  61. data/lib/samagotchi/tool_runner.rb +14 -3
  62. data/lib/samagotchi/tools/builtins.rb +6 -2
  63. data/lib/samagotchi/tools/execute.rb +3 -2
  64. data/lib/samagotchi/tools/peers.rb +4 -2
  65. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +29 -6
  68. data/lib/samagotchi/web/public/app.js +44 -9
  69. data/lib/samagotchi/web/public/commands.js +36 -3
  70. data/lib/samagotchi/web/public/composer.js +5 -1
  71. data/lib/samagotchi/web/public/index.html +22 -5
  72. data/lib/samagotchi/web/public/info_bar.js +5 -3
  73. data/lib/samagotchi/web/public/level_chip.js +174 -0
  74. data/lib/samagotchi/web/public/model_pick.js +1 -1
  75. data/lib/samagotchi/web/public/question_card.js +5 -3
  76. data/lib/samagotchi/web/public/question_cards.js +21 -1
  77. data/lib/samagotchi/web/public/read_view.js +74 -0
  78. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  79. data/lib/samagotchi/web/public/ref_scan.js +206 -0
  80. data/lib/samagotchi/web/public/refs.js +19 -10
  81. data/lib/samagotchi/web/public/session_state.js +5 -0
  82. data/lib/samagotchi/web/public/stream_handlers.js +10 -2
  83. data/lib/samagotchi/web/public/thinking_chip.js +33 -138
  84. data/lib/samagotchi/web/public/turn_html.js +4 -2
  85. data/lib/samagotchi/web/public/turn_view.js +5 -3
  86. data/lib/samagotchi/web/token.rb +3 -0
  87. data/lib/samagotchi/worker.rb +17 -5
  88. metadata +13 -1
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: core
3
- version: 0.1.0
3
+ version: 0.2.0
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
@@ -9,3 +9,4 @@ includes:
9
9
  - loop-guard
10
10
  - check-in
11
11
  - guardrails
12
+ - slow-commands
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.6.1
3
+ version: 0.6.2
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; the model searches their tools with find_mcp_tools and calls one with mcp_call (<server>/<tool>); /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:7778b1a8f82a043c06c0289a191bc2847c844665de0c961edb493f787f435ad5
10
+ sha256: sha256:8b2c0cf8a48d99b32e320506676d93d8069f9fda4a2ee101112073780a5c4f8a
11
11
  # 0.37.0 (published 2026-10-07) is the release whose targets: take acts_as:, args: and label:.
12
12
  requires_chi: ">= 0.37.0"
@@ -276,7 +276,15 @@ class Plugin
276
276
  # +client+ is the running process's (a restart replaces it; the
277
277
  # service's stop closes the current one), +restarts+ how many it had.
278
278
  Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
279
- :digest, :cached_at, :relist, :relisting, :client, :restarts, :tokens, :info, keyword_init: true)
279
+ :digest, :cached_at, :relist, :relisting, :client, :restarts, :tokens, :info, keyword_init: true) do
280
+ # Its state and tools, never the env (values often a token) or the
281
+ # command's args (which may carry a token too): a failing spec or a
282
+ # debugger would print those. `command` and `env` are nil before the
283
+ # server is resolved.
284
+ def inspect = "#<Server #{name} state=#{state} command=#{Array(command).first} env=#{(env || {}).keys.join(",")} tools=#{Array(tools).size}>"
285
+
286
+ def pretty_print(printer) = printer.text(inspect)
287
+ end
280
288
 
281
289
  def initialize(settings = {})
282
290
  @settings = settings
@@ -291,6 +299,12 @@ class Plugin
291
299
  @left_out_lock = Mutex.new
292
300
  end
293
301
 
302
+ # The servers' names, never @settings (the raw servers config, env values
303
+ # included): a failing spec prints the plugin.
304
+ def inspect = "#<Plugin mcp servers=#{@servers.map(&:name).join(",")}>"
305
+
306
+ def pretty_print(printer) = printer.text(inspect)
307
+
294
308
  # Each server's tools come from its cache (tools-<server>.json in the
295
309
  # bundle's data dir, keyed by a digest of its config) when there is one:
296
310
  # the server then starts on the first call of one of its tools (start:
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: slow-commands
3
+ version: 0.1.0
4
+ scope: system
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
+ trust_level: reviewed
7
+ plugin:
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"
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The slow-commands bundle (docs/plugins.md, The slow-commands bundle): an
4
+ # `execute` that ran past its limit (or close to it) is remembered for the
5
+ # project, and the next identical `execute` is denied with advice: start it
6
+ # with task_create, then task_wait with a timeout that fits.
7
+ #
8
+ # A command is keyed by its project (the session's git checkout, else its
9
+ # working directory) and its text: the ShellLex words and operators joined
10
+ # by one space, the call's cwd: folded in as a leading `cd <dir> &&`
11
+ # (relative to the project), and a trailing output filter dropped (a final
12
+ # 2>&1, `| tail …`, `| head …`, `| grep …`: they don't change how long it
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, $(…)
15
+ # or heredoc is never recorded, nor one with a $(…), backticks or a heredoc
16
+ # cat/tee read as data: ShellLex makes those one placeholder, so different
17
+ # commands would share a key.
18
+ #
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
21
+ # (a fast failure says nothing about the suite's length), unless a trailing
22
+ # filter was stripped: `rspec | tail` exits with tail's 0 when rspec failed,
23
+ # and a `| head` can end it early. A stopped or
24
+ # denied call records nothing. A key is denied once per turn: the model
25
+ # running it again in the same turn is let through (maybe it got faster),
26
+ # and that run records the new truth. before_turn resets it.
27
+ #
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
30
+ # record counts at once), written whole (AtomicFile), entries older than
31
+ # ttl_days dropped on write.
32
+ #
33
+ # The advice names how long a finished task of the same command took: the
34
+ # project's tmp/tasks/*/task.json read as files (never TaskRuntime's
35
+ # list_records, which mkdirs, runs ps and writes), the exit_code file's
36
+ # mtime minus started_at (finished_at is when chi noticed).
37
+ #
38
+ # Settings (config.yml, bundles: slow-commands:):
39
+ # mode: deny deny | notify (warn the user, never deny)
40
+ # warn_ratio: 0.8 a run this share of the limit or longer is slow
41
+ # ttl_days: 14 entries older than this are forgotten
42
+ #
43
+ # /slow-commands lists this project's entries; /slow-commands forget <n|all>
44
+ # drops them.
45
+ require "json"
46
+ require "pathname"
47
+ require "time"
48
+ require "samagotchi/atomic_file"
49
+ require "samagotchi/guardrails/shell_lex"
50
+ require "samagotchi/tools/execute"
51
+ require "samagotchi/tools/task_runtime"
52
+
53
+ class Plugin
54
+ SOURCE = "bundle slow-commands"
55
+ FILE = "commands.json"
56
+ FILTERS = %w[tail head grep].freeze
57
+ TIMED_OUT = /\A#{Regexp.escape(Samagotchi::Tools::Execute::TIMEOUT_PREFIX)} (\d+)s/
58
+ STOPPED = [Samagotchi::Tools::Execute::STOPPED_BY_USER, Samagotchi::Tools::Execute::NOT_RUN_ON_STOP].freeze
59
+ LEX = Samagotchi::Guardrails::ShellLex
60
+ SHORT_CHARS = 60
61
+ WAIT_DEFAULT = 600
62
+
63
+ def initialize(settings = {})
64
+ @mode = settings["mode"].to_s == "notify" ? :notify : :deny
65
+ ratio = Float(settings["warn_ratio"].to_s, exception: false)
66
+ @warn_ratio = ratio && ratio.positive? && ratio <= 1 ? ratio : 0.8
67
+ @ttl_days = positive(settings["ttl_days"]) || 14
68
+ reset
69
+ end
70
+
71
+ def register(chi)
72
+ chi.on(:before_turn) { |_event| reset }
73
+ chi.on(:before_tool_call) { |event, ctx| before(event, ctx) }
74
+ chi.on(:after_tool_call) { |event, ctx| after(event, ctx) }
75
+ chi.command "/slow-commands", "commands that ran past the execute limit here; forget <n|all> drops them",
76
+ anytime: true do |args, ctx|
77
+ command(args.to_s.strip, ctx)
78
+ end
79
+ end
80
+
81
+ # The command text as the store keys it, or nil when it can't be keyed
82
+ # (empty, unterminated). +cwd+: where it runs (nil: the session's).
83
+ def self.normalize(text, cwd:, project:, base:)
84
+ text = text.to_s.strip
85
+ return nil if text.empty? || LEX.unterminated?(text)
86
+
87
+ tokens = LEX.lex(text)
88
+ return nil if tokens.any? { |kind, value| kind == :word && value.include?("\0") }
89
+
90
+ tokens = strip_filters(tokens)
91
+ return nil if tokens.empty?
92
+
93
+ words = tokens.map { |kind, value| kind == :op ? value.tr("\n", ";") : quote(value) }
94
+ dir = folded_dir(cwd, project: project, base: base)
95
+ words = ["cd", quote(dir), "&&", *words] if dir
96
+ words.join(" ")
97
+ end
98
+
99
+ # Whether the key dropped a trailing output filter (2>&1, | tail …):
100
+ # the exit code is then the filter's, and the run may have been cut short.
101
+ def self.filtered?(text)
102
+ tokens = LEX.lex(text.to_s.strip)
103
+ tokens.pop while tokens.last && tokens.last[0] == :op && [";", "\n"].include?(tokens.last[1])
104
+ strip_filters(tokens).size != tokens.size
105
+ end
106
+
107
+ # Drops a trailing 2>&1 word and `| tail/head/grep …` filters, and a
108
+ # trailing ; or newline, until none is left.
109
+ def self.strip_filters(tokens)
110
+ tokens = tokens.dup
111
+ loop do
112
+ before = tokens.size
113
+ tokens.pop while tokens.last && tokens.last[0] == :op && [";", "\n"].include?(tokens.last[1])
114
+ tokens.pop if tokens.last && tokens.last[0] == :word && tokens.last[1] == "2>&1"
115
+ pipe = tokens.rindex { |kind, value| kind == :op && value == "|" }
116
+ if pipe && tokens[(pipe + 1)..].all? { |kind, _| kind == :word } && FILTERS.include?(tokens[pipe + 1]&.at(1))
117
+ tokens = tokens[0...pipe]
118
+ end
119
+ return tokens if tokens.size == before
120
+ end
121
+ end
122
+
123
+ def self.quote(word)
124
+ word.empty? || word.match?(/[\s'"]/) ? "'#{word.gsub("'", "'\\\\''")}'" : word
125
+ end
126
+
127
+ # The run dir relative to +project+, or nil for the project itself.
128
+ def self.folded_dir(cwd, project:, base:)
129
+ dir = File.expand_path(cwd.to_s.strip.empty? ? base : cwd.to_s.strip, base)
130
+ rel = Pathname.new(dir).relative_path_from(Pathname.new(File.expand_path(project))).to_s
131
+ rel == "." ? nil : rel
132
+ rescue ArgumentError
133
+ dir
134
+ end
135
+
136
+ private
137
+
138
+ def reset
139
+ @denied = {}
140
+ end
141
+
142
+ def before(event, ctx)
143
+ call = event[:call]
144
+ verdict = event[:guardrail]
145
+ return unless call.is_a?(Hash) && call[:name].to_s == Samagotchi::Tools::Execute::NAME && verdict
146
+ return if verdict.deny?
147
+
148
+ key = key_for(call, ctx)
149
+ return unless key
150
+ return if @denied[key]
151
+
152
+ entry = live_entries(ctx).find { |e| e["project"] == key[0] && e["command"] == key[1] }
153
+ return unless entry
154
+
155
+ @denied[key] = true
156
+ if @mode == :notify
157
+ ctx.notify("execute #{short(key[1])}: #{what(entry)} before", level: :warn)
158
+ return
159
+ end
160
+
161
+ verdict.deny!("slow command", source: SOURCE, advice: advice(entry, ctx, key))
162
+ ctx.notify("execute #{short(key[1])}: #{what(entry)} before, denied", level: :warn)
163
+ end
164
+
165
+ def after(event, ctx)
166
+ call = event[:call]
167
+ return unless event[:tool].to_s == Samagotchi::Tools::Execute::NAME && call.is_a?(Hash)
168
+
169
+ elapsed_ms = event[:elapsed_ms]
170
+ result = event[:result].to_s
171
+ return if elapsed_ms.nil? || STOPPED.any? { |text| result.start_with?(text) }
172
+
173
+ key = key_for(call, ctx)
174
+ return unless key
175
+
176
+ 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)
179
+ elsif elapsed_ms >= @warn_ratio * limit * 1000
180
+ remember(ctx, key, limit_s: limit, elapsed_s: (elapsed_ms / 1000.0).round, timed_out: false)
181
+ elsif elapsed_ms < 0.5 * limit * 1000 && exit_zero?(result) && !self.class.filtered?(call[:content])
182
+ forget_key(ctx, key)
183
+ end
184
+ end
185
+
186
+ def key_for(call, ctx)
187
+ project = project_of(ctx)
188
+ command = self.class.normalize(call[:content], cwd: call[:cwd], project: project, base: ctx.cwd)
189
+ command ? [project, command] : nil
190
+ end
191
+
192
+ def project_of(ctx) = ctx.repo_root || ctx.cwd
193
+
194
+ def exit_zero?(result)
195
+ result.scan(/^exit: (-?\d+)/).last == ["0"]
196
+ end
197
+
198
+ # --- the store ---
199
+
200
+ def store_path(ctx) = File.join(ctx.data_dir, FILE)
201
+
202
+ def entries(ctx)
203
+ list = JSON.parse(File.read(store_path(ctx)))
204
+ list.is_a?(Array) ? list.select { |e| e.is_a?(Hash) && e["project"] && e["command"] } : []
205
+ rescue Errno::ENOENT, JSON::ParserError
206
+ []
207
+ end
208
+
209
+ def live_entries(ctx) = entries(ctx).select { |e| live?(e) }
210
+
211
+ def live?(entry)
212
+ Time.iso8601(entry["slow_at"].to_s) > Time.now - (@ttl_days * 86_400)
213
+ rescue ArgumentError
214
+ false
215
+ end
216
+
217
+ def save(ctx, list)
218
+ Samagotchi::AtomicFile.write(store_path(ctx), "#{JSON.pretty_generate(list.select { |e| live?(e) })}\n")
219
+ end
220
+
221
+ def remember(ctx, key, limit_s:, elapsed_s:, timed_out:)
222
+ 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,
224
+ "limit_s" => limit_s, "elapsed_s" => elapsed_s, "timed_out" => timed_out }
225
+ save(ctx, list)
226
+ end
227
+
228
+ def forget_key(ctx, key)
229
+ list = entries(ctx)
230
+ kept = list.reject { |e| e["project"] == key[0] && e["command"] == key[1] }
231
+ save(ctx, kept) if kept.size != list.size
232
+ end
233
+
234
+ # --- the deny's advice ---
235
+
236
+ def advice(entry, ctx, key)
237
+ took = task_duration(ctx, key)
238
+ wait = took ? [WAIT_DEFAULT, ((took * 1.5) / 60.0).ceil * 60].max : WAIT_DEFAULT
239
+ date = entry["slow_at"].to_s[0, 10]
240
+ seen = if entry["timed_out"]
241
+ "This command ran past the execute limit (#{entry["limit_s"]} s) here on #{date}."
242
+ else
243
+ "This command took #{duration(entry["elapsed_s"])} of the execute limit (#{entry["limit_s"]} s) here on #{date}."
244
+ end
245
+ text = "#{seen} Start it with task_create, then task_wait (timeout #{wait})."
246
+ took ? "#{text} A finished task of this command took #{duration(took)}." : text
247
+ end
248
+
249
+ # Seconds the newest finished (exit 0) task of this key took, or nil.
250
+ def task_duration(ctx, key)
251
+ roots = [ctx.cwd, project_of(ctx)].uniq
252
+ records = roots.flat_map do |root|
253
+ Dir.glob(File.join(root, Samagotchi::Tools::TaskRuntime::TASKS_DIR, "*", "task.json"))
254
+ end.uniq
255
+ found = records.filter_map { |path| finished_task(path, ctx, key) }
256
+ found.max_by(&:first)&.last
257
+ end
258
+
259
+ # [started_at, seconds] for a task record of +key+ that ended with exit 0.
260
+ def finished_task(path, ctx, key)
261
+ record = JSON.parse(File.read(path))
262
+ exit_code = File.join(File.dirname(path), Samagotchi::Tools::TaskRuntime::EXIT_CODE_FILENAME)
263
+ return nil unless File.file?(exit_code) && File.read(exit_code).strip == "0"
264
+
265
+ command = self.class.normalize(record["command"], cwd: record["cwd"], project: key[0], base: ctx.cwd)
266
+ return nil unless command == key[1]
267
+
268
+ started = Time.iso8601(record["started_at"].to_s)
269
+ seconds = (File.mtime(exit_code) - started).round
270
+ seconds.negative? ? nil : [started, seconds]
271
+ rescue StandardError
272
+ nil
273
+ end
274
+
275
+ # --- /slow-commands ---
276
+
277
+ def command(args, ctx)
278
+ project = project_of(ctx)
279
+ mine = live_entries(ctx).select { |e| e["project"] == project }.sort_by { |e| e["slow_at"].to_s }
280
+ case args
281
+ when ""
282
+ return "slow-commands: none recorded for #{project}" if mine.empty?
283
+
284
+ lines = mine.each_with_index.map do |e, i|
285
+ "#{i + 1}. #{e["command"]}: #{what(e)} (#{e["slow_at"].to_s[0, 10]})"
286
+ end
287
+ "slow-commands in #{project}:\n#{lines.join("\n")}\n/slow-commands forget <n|all> drops an entry"
288
+ when /\Aforget\s+(all|\d+)\z/
289
+ which = Regexp.last_match(1)
290
+ drop = which == "all" ? mine : [mine[which.to_i - 1]].compact
291
+ return "slow-commands: no entry #{which} (/slow-commands lists them)" if drop.empty?
292
+
293
+ 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"}"
295
+ else
296
+ "usage: /slow-commands (list this project's slow commands), /slow-commands forget <n|all>"
297
+ end
298
+ end
299
+
300
+ # --- text ---
301
+
302
+ def what(entry)
303
+ entry["timed_out"] ? "ran past #{entry["limit_s"]} s" : "took #{duration(entry["elapsed_s"])} of #{entry["limit_s"]} s"
304
+ end
305
+
306
+ def duration(seconds)
307
+ seconds = seconds.to_i
308
+ seconds < 60 ? "#{seconds}s" : "#{seconds / 60}m#{format("%02d", seconds % 60)}s"
309
+ end
310
+
311
+ def short(command)
312
+ text = command.to_s
313
+ text.length > SHORT_CHARS ? "#{text[0, SHORT_CHARS - 1]}…" : text
314
+ end
315
+
316
+ def positive(value)
317
+ number = Integer(value.to_s, exception: false)
318
+ number&.positive? ? number : nil
319
+ end
320
+ end
@@ -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: 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).
61
+ - `models:` map of model id or alias → `{profile, vision, sampling, thinking, reasoning_history}` (`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". `reasoning_history:` (here or on a host) is `drop|turn|all`, which of its reasoning a chat host's model gets back. Precedence: the session's own level (`/reasoning-history LEVEL`, saved in the session) > `SAMAGOTCHI_REASONING_HISTORY` > `models:` > `hosts.<name>.reasoning_history` > `turn`; `docs/configuration.md` "Reasoning history". `/thinking` and `/reasoning-history` ask before a change that restarts the prompt cache; `--yes` applies it (`chi send` commands apply directly). `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.55.0
3
+ version: 0.57.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:45064ac6b19863e1646d15891d0e9cc7a8f10ab981a0d25d6e13891591538b8a
10
- memory_guide.md: sha256:15cd3b24eda853cc458371c6c75809a2d4b5352311d075aaa4384926b5221c27
9
+ config_modification_protocol.md: sha256:d908921b5988244fb2a03fe04e693d0e7a1dfadf8cd77704e9232826db7eb2d7
10
+ memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -94,7 +94,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
94
94
  - Integrity: each file's sha256 is recorded at install; a hook/plugin/rule file changed afterwards is not loaded (rules: every call denied) until reinstalled. It is an integrity check, not proof of authorship.
95
95
  - Installing = trusting its Ruby code (hooks, plugin), like a gem. Install only copies; the code runs at the next session start (`Engine.new`), so a running worker needs a restart to pick it up.
96
96
  - A bundle without `*.md` (btw, mcp, loop-guard) adds no line to the prompt's memory index.
97
- - Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
97
+ - Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
98
98
 
99
99
  ### CLI — `chi bundle`
100
100
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The confirm step before a session setting change that restarts the
5
+ # prompt cache (/thinking, /reasoning-history): such a change, sent
6
+ # without --yes from a person at a web tab or a chi TUI, changes nothing
7
+ # and replies with what it costs and the line that applies it. Two steps,
8
+ # no stored state: the reply carries a Confirm (SessionCommands::Result,
9
+ # the worker's command_ran), the TUI prefills its line, the web draws an
10
+ # Apply button that sends it.
11
+ module CacheConfirm
12
+ YES_FLAGS = %w[--yes -y].freeze
13
+ # The costs worth asking about (Thinking.cache_cost,
14
+ # ReasoningHistory.cache_cost); :tail and nil are not.
15
+ COSTLY = %i[full provider history].freeze
16
+
17
+ # The line that applies a change, and the question the reply ends with.
18
+ Confirm = Data.define(:line, :text) do
19
+ # The plain form the worker's command_ran carries.
20
+ # @return [Hash] symbol keys
21
+ def to_h = { line: line, text: text }
22
+ end
23
+
24
+ module_function
25
+
26
+ # A command's argument words with --yes / -y (anywhere) taken out.
27
+ # @param args [String] what follows the command name
28
+ # @return [Array(String, Boolean)] the rest, stripped and downcased, and
29
+ # whether --yes was there
30
+ def split(args)
31
+ words = args.to_s.strip.downcase.split
32
+ yes = words.any? { |word| YES_FLAGS.include?(word) }
33
+ [(words - YES_FLAGS).join(" "), yes]
34
+ end
35
+
36
+ # Whether a change must ask first: the effective level changes, the
37
+ # cost is one of COSTLY, and the session has a model reply (a cache to
38
+ # restart at all).
39
+ def needed?(cost:, history:, changed:)
40
+ changed && history && COSTLY.include?(cost)
41
+ end
42
+
43
+ # The Confirm for +name+ set to +word+: the line with --yes, and the
44
+ # question naming it.
45
+ # @return [Confirm]
46
+ def confirm(name, word)
47
+ line = "#{name} #{word} --yes"
48
+ Confirm.new(line: line, text: "Nothing changed yet: send `#{line}` to apply.")
49
+ end
50
+ end
51
+ end
@@ -39,8 +39,20 @@ module Samagotchi
39
39
  HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
40
40
  HUMAN_IDS = [CLI_SEND].freeze
41
41
 
42
+ # A person at a screen that shows a command's reply and can answer it
43
+ # (#interactive?): a web tab, an attached chi TUI.
44
+ INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
45
+
42
46
  module_function
43
47
 
48
+ # Whether +client_id+ is a web tab or a chi TUI, which see a command's
49
+ # reply and can send the line it asks for (CacheConfirm); chi send, a
50
+ # delegate, a plugin, chi itself and nil are not.
51
+ def interactive?(client_id)
52
+ id = client_id.to_s
53
+ INTERACTIVE_PREFIXES.any? { |prefix| id.start_with?(prefix) }
54
+ end
55
+
44
56
  # Whether +client_id+ (a turn's or a line's origin) is a human's input.
45
57
  def human?(client_id)
46
58
  return true if client_id.nil?
@@ -187,6 +187,10 @@ module Samagotchi
187
187
  # is bin/chi's own --thinking; models: and hosts: entries rank below
188
188
  # the flag and the env, above config.yml's value.
189
189
  Entry.new(key: "thinking.level", yaml_path: %w[thinking level], type: :string, default: "default", expose: %i[env config]),
190
+ # Which of its reasoning a chat host's model gets back (ReasoningHistory):
191
+ # the environment only, for A/B runs; models: and hosts: entries are
192
+ # the config surface.
193
+ Entry.new(key: "reasoning_history", yaml_path: %w[reasoning_history], type: :string, default: nil, expose: %i[env]),
190
194
 
191
195
  Entry.new(key: "default.max_tokens", yaml_path: %w[default max_tokens], type: :integer, default: nil, expose: %i[env config cli]),
192
196
  Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli]),
@@ -269,9 +273,9 @@ module Samagotchi
269
273
  # Maps of named entries and the keys an entry may hold
270
274
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
271
275
  MAP_ENTRY_KEYS = {
272
- "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled
273
- remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
274
- "models" => %w[profile vision sampling thinking window_tokens llm_context_strategy llm_context_apply
276
+ "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
275
279
  llm_context_budget_tokens].freeze
276
280
  }.freeze
277
281
  # The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
@@ -10,6 +10,7 @@ require_relative "session"
10
10
  require_relative "cli/command"
11
11
  require_relative "cli/exit"
12
12
  require_relative "cli/flags"
13
+ require_relative "redacted_inspect"
13
14
 
14
15
  module Samagotchi
15
16
  # `chi context`: attach live external text to sessions (ContextSources).
@@ -18,6 +19,7 @@ module Samagotchi
18
19
  # reads the text with context_read.
19
20
  class ContextCommand
20
21
  include CLI::Command
22
+ include RedactedInspect
21
23
 
22
24
  USAGE = <<~TEXT
23
25
  Usage: chi context <add|push|ls|show|refresh|rm|mute|unmute> [options] [TARGET]
@@ -11,6 +11,7 @@ require_relative "../version"
11
11
  require_relative "../self_report"
12
12
  require_relative "../installed_gem"
13
13
  require_relative "../config"
14
+ require_relative "../redacted_inspect"
14
15
 
15
16
  module Samagotchi
16
17
  module Desktop
@@ -22,6 +23,8 @@ module Samagotchi
22
23
  # command goes through the runner, and the dirs are injectable, so specs
23
24
  # (and smokes with register: false) never touch the real system.
24
25
  class MacOS
26
+ include RedactedInspect
27
+
25
28
  class Error < StandardError
26
29
  end
27
30