samagotchi 0.19.0 → 0.21.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +130 -1
  3. data/docs/architecture.md +8 -1
  4. data/docs/cli.md +16 -4
  5. data/docs/configuration.md +4 -1
  6. data/docs/guardrails.md +92 -14
  7. data/docs/plugins.md +27 -8
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  10. data/lib/samagotchi/bootstrap_command.rb +26 -5
  11. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  13. data/lib/samagotchi/bridge.rb +16 -4
  14. data/lib/samagotchi/bridge_client.rb +33 -9
  15. data/lib/samagotchi/bundle_command.rb +28 -4
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  20. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/commands/registry.rb +4 -3
  27. data/lib/samagotchi/config.rb +3 -0
  28. data/lib/samagotchi/context_usage.rb +3 -3
  29. data/lib/samagotchi/engine.rb +31 -4
  30. data/lib/samagotchi/events.rb +1 -1
  31. data/lib/samagotchi/guardrail_wiring.rb +2 -1
  32. data/lib/samagotchi/guardrails/approval.rb +4 -3
  33. data/lib/samagotchi/guardrails/approvals.rb +11 -5
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/idle_recap.rb +7 -2
  46. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  47. data/lib/samagotchi/llm/openai_chat.rb +19 -5
  48. data/lib/samagotchi/llm/usage.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +4 -2
  50. data/lib/samagotchi/memory_bundle/builder.rb +1 -5
  51. data/lib/samagotchi/memory_bundle/installer.rb +46 -13
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  53. data/lib/samagotchi/memory_bundle/status.rb +12 -4
  54. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
  55. data/lib/samagotchi/memory_paths.rb +6 -0
  56. data/lib/samagotchi/self_report.rb +38 -2
  57. data/lib/samagotchi/session.rb +35 -6
  58. data/lib/samagotchi/session_commands.rb +13 -2
  59. data/lib/samagotchi/session_manager.rb +26 -9
  60. data/lib/samagotchi/session_metrics.rb +230 -23
  61. data/lib/samagotchi/steer.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  63. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  64. data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
  65. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  66. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  67. data/lib/samagotchi/token_usage.rb +57 -41
  68. data/lib/samagotchi/tool_runner.rb +21 -2
  69. data/lib/samagotchi/tools/execute.rb +9 -8
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +19 -0
  72. data/lib/samagotchi/web/public/app.js +68 -22
  73. data/lib/samagotchi/web/public/card.js +34 -2
  74. data/lib/samagotchi/web/public/ctx.js +59 -0
  75. data/lib/samagotchi/web/public/format.js +11 -0
  76. data/lib/samagotchi/web/public/index.html +33 -2
  77. data/lib/samagotchi/web/public/model_pick.js +6 -2
  78. data/lib/samagotchi/web/public/model_picker.js +2 -1
  79. data/lib/samagotchi/web/public/notifications.js +8 -3
  80. data/lib/samagotchi/web/public/notify.js +49 -7
  81. data/lib/samagotchi/web/public/question_card.js +2 -1
  82. data/lib/samagotchi/web/public/timing.js +11 -5
  83. data/lib/samagotchi/web/public/turn_events.js +17 -1
  84. data/lib/samagotchi/web/session_summary.rb +6 -2
  85. metadata +5 -1
@@ -3,6 +3,7 @@
3
3
  require "rbconfig"
4
4
  require_relative "config"
5
5
  require_relative "model_profile"
6
+ require_relative "host_registry"
6
7
  require_relative "bootstrap/probe"
7
8
  require_relative "bootstrap/config_writer"
8
9
  require_relative "bootstrap/bundles"
@@ -235,9 +236,11 @@ module Samagotchi
235
236
 
236
237
  def test(result, model, key_env)
237
238
  done = Queue.new
238
- waiting = Thread.new do
239
- @stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
240
- end
239
+ waiting = if loading_hint?(result)
240
+ Thread.new do
241
+ @stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
242
+ end
243
+ end
241
244
  seconds = @probe.test_turn(result.candidate, model, key_env: key_env)
242
245
  @stdout.puts(format("test: answered in %.1f s", seconds))
243
246
  true
@@ -249,17 +252,25 @@ module Samagotchi
249
252
  waiting&.join
250
253
  end
251
254
 
255
+ # The "loading the model?" hint fits a local server (a llama.cpp loading
256
+ # weights); a remote provider's slow answer is the network, not a load.
257
+ def loading_hint?(result)
258
+ !HostRegistry.remote_address?(result.candidate.scheme, result.candidate.host)
259
+ end
260
+
252
261
  # 0 written, 1 written but not as planned, nil with @exit on a refusal.
253
262
  def write(result, model, key_env, options)
254
263
  writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
255
264
  fields = host_fields(result.candidate, result.native?, key_env)
256
265
  ids = result.models.map(&:id)
257
- name = begin
258
- writer.host_name(Bootstrap::ConfigWriter.derived_name(result.candidate.host), requested: options[:name], model_ids: ids)
266
+ base = Bootstrap::ConfigWriter.derived_name(result.candidate.host)
267
+ name, reason = begin
268
+ writer.host_name_with_reason(base, requested: options[:name], model_ids: ids)
259
269
  rescue Bootstrap::ConfigWriter::Error => e
260
270
  @exit = usage_error(e.message)
261
271
  return nil
262
272
  end
273
+ explain_name(base, name, reason)
263
274
  outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
264
275
  code = report(outcome, model)
265
276
  return code unless INSTALLS_BUNDLES.include?(outcome.kind)
@@ -269,6 +280,16 @@ module Samagotchi
269
280
  code
270
281
  end
271
282
 
283
+ # One line when the name wasn't free as derived: why chi picked another.
284
+ def explain_name(base, name, reason)
285
+ case reason
286
+ when :taken
287
+ @stdout.puts("a host named #{base} already exists; saved as #{name}, use --name to choose")
288
+ when :model_prefix
289
+ @stdout.puts("a model id starts with #{base} (chi would read it as a host); saved as #{name}, use --name to choose")
290
+ end
291
+ end
292
+
272
293
  # The system bundle, core, and dev when a terminal says yes: one line
273
294
  # each. 1 when something failed.
274
295
  def install_bundles(dry_run: false)
@@ -39,6 +39,8 @@ module Samagotchi
39
39
  class SSEWriter
40
40
  DEFAULT_MAX_QUEUE = 1024
41
41
  DEFAULT_HEARTBEAT_INTERVAL = 15.0
42
+ # Queued by the hang-up watcher (#watch_hangup) to wake #drain!.
43
+ HANGUP = Object.new.freeze
42
44
 
43
45
  # @param engine [Samagotchi::Engine] the owning engine (live fan-out)
44
46
  # @param ring [Samagotchi::Bridge::RingBuffer] shared capture buffer
@@ -77,6 +79,7 @@ module Samagotchi
77
79
  @foreign_cursor = !cursor_epoch.nil? && cursor_epoch != @epoch
78
80
  @high_water = @foreign_cursor ? 0 : cursor_seq
79
81
  @handle = nil
82
+ @hung_up = false
80
83
  end
81
84
 
82
85
  # Non-blocking enqueue used as the per-connection live observer. Any
@@ -97,6 +100,7 @@ module Samagotchi
97
100
  # the client disconnects or the bridge stops.
98
101
  def serve!(io)
99
102
  write_sse_headers(io)
103
+ watcher = watch_hangup(io)
100
104
  begin
101
105
  if @join_with_snapshot
102
106
  # Subscribe and snapshot as one step of the event log, then go live.
@@ -119,11 +123,36 @@ module Samagotchi
119
123
  # Client hung up; nothing to do.
120
124
  ensure
121
125
  @engine.unsubscribe(handle: @handle) if @handle
126
+ watcher&.kill
122
127
  end
123
128
  end
124
129
 
125
130
  private
126
131
 
132
+ # A client that hangs up is noticed now, not at the next write (a
133
+ # heartbeat, up to its interval away): the Bridge stops counting its
134
+ # stream (Bridge#open_streams_except), so an attached /exit from the
135
+ # TUI attached after it isn't held by it. A thread waits for the
136
+ # socket to read EOF and wakes #drain!. A client sends nothing after
137
+ # its request; bytes it does send are dropped. Real sockets only.
138
+ def watch_hangup(io)
139
+ return nil unless io.is_a?(IO)
140
+
141
+ Thread.new do
142
+ loop do
143
+ case io.read_nonblock(4096, exception: false)
144
+ when nil then break
145
+ when :wait_readable then io.wait_readable
146
+ end
147
+ end
148
+ rescue IOError, SystemCallError
149
+ nil
150
+ ensure
151
+ @hung_up = true
152
+ @queue.push(HANGUP)
153
+ end.tap { |thread| thread.report_on_exception = false }
154
+ end
155
+
127
156
  def replay!(io, snapshot_seq)
128
157
  from_seq = @high_water
129
158
  oldest = @ring.oldest_seq
@@ -146,6 +175,9 @@ module Samagotchi
146
175
  def drain!(io)
147
176
  loop do
148
177
  payload = @queue.pop(@heartbeat_interval)
178
+ # The watcher's flag, not only its HANGUP: an overflow can drop that.
179
+ break if @hung_up
180
+
149
181
  if payload
150
182
  if payload.is_a?(Hash) && payload[:sse_reset]
151
183
  emit_reset(io)
@@ -38,8 +38,8 @@ module Samagotchi
38
38
  end
39
39
 
40
40
  # @return [Hash, nil] a copy of the turn in progress: prompt, origin,
41
- # continue, ordered parts (thinking / text / tool / input / steer /
42
- # reminder / notice), pending_question and the last event_seq folded
41
+ # continue, ordered parts (generation / thinking / text / tool /
42
+ # input / steer / reminder / notice), pending_question and the last event_seq folded
43
43
  # in. A finished tool part keeps the live row's action and duration_ms
44
44
  # too, a running one the time it has run so far (elapsed_ms), so a
45
45
  # join times its row from its start. A notice part holds the event
@@ -101,7 +101,9 @@ module Samagotchi
101
101
  # The turn in progress (#current_turn's shape, symbol keys) as the live
102
102
  # events that would have drawn it, in order, as turn_events.js
103
103
  # snapshotEvents replays it for the web (spec/shared/turn_snapshot.json
104
- # pins both): a generation_chunk per thinking or text part (a
104
+ # pins both): a generation_started per generation part (a join during
105
+ # a hold shows the step the model is on), a generation_chunk per
106
+ # thinking or text part (a
105
107
  # generation_completed closes text before the next step's), a tool's
106
108
  # tool_call_started and, once finished, its tool_call_completed (with
107
109
  # the live activity: action, tool, params, status), a merged_input
@@ -123,6 +125,9 @@ module Samagotchi
123
125
  end
124
126
  Array(turn[:parts]).each do |part|
125
127
  case part[:kind]
128
+ when "generation"
129
+ close_text.call
130
+ events << { type: :generation_started, iteration: part[:iteration] }
126
131
  when "thinking" then events << { type: :generation_chunk, text: "", thinking: part[:text], iteration: part[:iteration] }
127
132
  when "text"
128
133
  close_text.call if !text_iteration.nil? && text_iteration != part[:iteration]
@@ -233,6 +238,8 @@ module Samagotchi
233
238
  def fold_turn_event(event)
234
239
  parts = @turn[:parts]
235
240
  case event[:type]
241
+ when :generation_started
242
+ parts << { kind: "generation", iteration: event[:iteration] }
236
243
  when :generation_chunk
237
244
  if event.key?(:text)
238
245
  # Profile-split stream: thinking and visible text separately.
@@ -134,7 +134,8 @@ module Samagotchi
134
134
 
135
135
  # Streams held by anyone but +client_id+: a client's own stream (or two,
136
136
  # while it reconnects and the old one isn't noticed dead yet) doesn't
137
- # count. A closed stream counts until its next write fails (heartbeat).
137
+ # count. A closed stream stops counting once its writer reads the
138
+ # hang-up (SSEWriter#watch_hangup).
138
139
  # @return [Integer]
139
140
  def open_streams_except(client_id)
140
141
  @mutex.synchronize { @open_streams - (client_id ? @streams_by_client[client_id] : 0) }
@@ -698,7 +699,10 @@ module Samagotchi
698
699
  # event log held, so no POST /turn lands between its check and its
699
700
  # answer; it leaves from its loop after this reply.
700
701
  # 200 {status: "exiting", discard?: the session is empty and goes} ({status: "restarting"} for a restart), or 409
701
- # {status: "held", reason:} naming what keeps it up. Returns [headers, status, body].
702
+ # {status: "held", reason:} naming what keeps it up. A past +deadline+
703
+ # (see #handle_post_turn), checked with the event log held: 408
704
+ # deadline_passed, the worker not asked (its client said it didn't stop).
705
+ # Returns [headers, status, body].
702
706
  def handle_exit_request(session_id, body)
703
707
  return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
704
708
 
@@ -709,7 +713,15 @@ module Samagotchi
709
713
  delete = fetched(parsed, "delete") == true
710
714
  restart = fetched(parsed, "restart") == true
711
715
  options = restart ? { delete: delete, restart: true } : { delete: delete }
712
- reason = @engine.synchronize_events { @on_exit_request.call(client_id, **options) }
716
+ deadline = fetched(parsed, "deadline")
717
+ return BAD_DEADLINE unless deadline_valid?(deadline)
718
+
719
+ reason = @engine.synchronize_events do
720
+ next :expired if expired?("exit_expired", deadline, sid: session_id, client_id: client_id)
721
+
722
+ @on_exit_request.call(client_id, **options)
723
+ end
724
+ return deadline_passed("exit request") if reason == :expired
713
725
  if reason.nil?
714
726
  return [{}, 200, { status: "restarting", session_id: @session_id }] if restart
715
727
 
@@ -849,7 +861,7 @@ module Samagotchi
849
861
  end
850
862
 
851
863
  # The 408 for a request read after its deadline: +what+ (turn, command,
852
- # answer, dismissal) was dropped.
864
+ # answer, dismissal, exit request) was dropped.
853
865
  def deadline_passed(what)
854
866
  [{}, 408, { error: "deadline_passed", detail: "the #{what} arrived after its client stopped waiting; not run" }]
855
867
  end
@@ -20,6 +20,10 @@ module Samagotchi
20
20
  # A reply's status line; its code is group 1.
21
21
  STATUS_LINE = %r{\AHTTP/1\.[01] (\d{3})}
22
22
  STREAM_CONNECT_ATTEMPTS = 3
23
+ # Seconds #stream waits for the Bridge's response headers, per attempt:
24
+ # a worker that took the connection and froze must not hold the web's
25
+ # proxy thread (as EventStream::HEADER_TIMEOUT for the TUI's).
26
+ STREAM_HEADER_TIMEOUT = 5.0
23
27
  # Seconds #stream waits for bytes before it asks `running` again.
24
28
  STREAM_POLL = 0.5
25
29
  # Seconds a one-shot request waits for the whole reply. Every route
@@ -28,7 +32,7 @@ module Samagotchi
28
32
  # raises Errno::ECONNREFUSED. The event stream has no such limit.
29
33
  READ_TIMEOUT = 30
30
34
  # Share of the read timeout a request's deadline allows (25 s of 30): the
31
- # Bridge drops a turn, command, answer or dismissal it reads after the
35
+ # Bridge drops a turn, command, answer, dismissal or exit it reads after the
32
36
  # deadline, so one this client timed out on, and said did not go
33
37
  # through, never runs when a frozen worker wakes. The rest covers the
34
38
  # write and the reply.
@@ -159,13 +163,14 @@ module Samagotchi
159
163
  # POST /session/:id/exit: ask the worker to exit now (+restart+: hand
160
164
  # the session to a new worker, a worker with the "restart" feature). 200
161
165
  # = it will (status "exiting"/"restarting"), 409 = something keeps it up (reason), 404 with
162
- # error "not_found" = a worker older than the route.
166
+ # error "not_found" = a worker older than the route, 408 = too late.
163
167
  # @param client_id [String] the asking UI, whose own streams don't hold
164
168
  # @return [Response]
165
169
  def request_exit(client_id:, delete: false, restart: false)
166
170
  body = { client_id: client_id }
167
171
  body[:delete] = true if delete
168
172
  body[:restart] = true if restart
173
+ body[:deadline] = deadline
169
174
  post("exit", body, read_body: true)
170
175
  end
171
176
 
@@ -258,7 +263,8 @@ module Samagotchi
258
263
  # @param running [#call, nil] asked before every read, and every
259
264
  # STREAM_POLL seconds of silence: the stream ends once it returns
260
265
  # false. A quiet stream is never cut otherwise.
261
- def stream(query: "", last_event_id: nil, running: nil)
266
+ # @param header_timeout [Float] see STREAM_HEADER_TIMEOUT
267
+ def stream(query: "", last_event_id: nil, running: nil, header_timeout: STREAM_HEADER_TIMEOUT)
262
268
  sock = nil
263
269
  # Absorb the probe→connect race around a resumed worker's bridge:
264
270
  # the sidecar probe can succeed a moment before the worker dies (or
@@ -266,7 +272,7 @@ module Samagotchi
266
272
  # avoid the silent empty-200 that EventSource would otherwise keep
267
273
  # re-opening.
268
274
  STREAM_CONNECT_ATTEMPTS.times do |attempt|
269
- sock, = connect_stream(query: query, last_event_id: last_event_id)
275
+ sock, = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
270
276
  break if sock
271
277
 
272
278
  sleep(0.15 * (attempt + 1))
@@ -302,7 +308,7 @@ module Samagotchi
302
308
  end
303
309
 
304
310
  # Open GET /session/:id/stream and read past the response headers.
305
- # @param timeout [Float, nil] give up when the headers don't start in time
311
+ # @param timeout [Float, nil] give up when the headers aren't all in by then
306
312
  # @return [Array(TCPSocket, Integer), nil] the socket positioned at the
307
313
  # body and the HTTP status, or nil when the Bridge can't be reached
308
314
  def connect_stream(query: "", last_event_id: nil, timeout: nil)
@@ -310,10 +316,9 @@ module Samagotchi
310
316
  lei = last_event_id.to_s.strip
311
317
  last_event_line = lei.empty? ? "" : "Last-Event-ID: #{lei}\r\n"
312
318
  sock.write("GET /session/#{@session_id}/stream#{query} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nAccept: text/event-stream\r\n#{last_event_line}Connection: keep-alive\r\n\r\n")
313
- raise Errno::ETIMEDOUT if timeout && !sock.wait_readable(timeout)
314
-
315
- status = sock.gets.to_s[STATUS_LINE, 1].to_i
316
- while (line = sock.gets)
319
+ deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
320
+ status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
321
+ while (line = header_line(sock, deadline))
317
322
  break if line.strip.empty?
318
323
  end
319
324
  [sock, status]
@@ -324,6 +329,25 @@ module Samagotchi
324
329
 
325
330
  private
326
331
 
332
+ # One line of the response headers, or nil at EOF; raises
333
+ # Errno::ETIMEDOUT once +deadline+ (monotonic, nil: none) has passed. A
334
+ # line read in pieces, so a Bridge that stops mid-line can't hold it.
335
+ def header_line(sock, deadline)
336
+ return sock.gets unless deadline
337
+
338
+ line = +""
339
+ loop do
340
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
341
+ raise Errno::ETIMEDOUT unless remaining.positive? && sock.wait_readable(remaining)
342
+
343
+ char = sock.getc
344
+ return line.empty? ? nil : line if char.nil?
345
+
346
+ line << char
347
+ return line if char == "\n"
348
+ end
349
+ end
350
+
327
351
  # When this client stops waiting for a reply, as epoch seconds (see
328
352
  # DEADLINE_SHARE). Wall clock: the worker runs on this machine and reads
329
353
  # the same one.
@@ -200,6 +200,10 @@ module Samagotchi
200
200
  end
201
201
 
202
202
  # Runs +installer+, prints its summary, then yields the manifest.
203
+ # Ensures the extracted source dir is cleaned up even if an exception
204
+ # occurs between installer.run and the block (e.g. summary,
205
+ # print_hooks_and_plugin). The block (upgrade_conflicts) still sees
206
+ # the files because cleanup happens after the block returns or raises.
203
207
  # @return [Integer] the block's exit status, or 1 after "<failed> failed: …"
204
208
  def run_installer(installer, failed:)
205
209
  _nd, manifest = installer.run
@@ -208,6 +212,8 @@ module Samagotchi
208
212
  rescue Samagotchi::MemoryBundle::Installer::InstallError => e
209
213
  @stderr.puts "#{failed} failed: #{e.message}"
210
214
  1
215
+ ensure
216
+ installer.cleanup_source!
211
217
  end
212
218
 
213
219
  def print_hooks_and_plugin(manifest, plugin: true)
@@ -263,6 +269,10 @@ module Samagotchi
263
269
  @stdout.puts "Provenance updated after interactive resolution."
264
270
  @stdout.puts "Upgrade resolved interactively."
265
271
  0
272
+ ensure
273
+ # An owned source (git/zip/tar) was kept for the incoming files above;
274
+ # the agent step is done with them now.
275
+ installer.cleanup_source!
266
276
  end
267
277
 
268
278
  def uninstall(rest)
@@ -353,9 +363,15 @@ module Samagotchi
353
363
  @stdout.puts "Bundle '#{name}' not installed."
354
364
  return 0
355
365
  end
356
- @stdout.puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{st[:scope]} installed=#{st[:provenance][:installed_at]}"
357
- @stdout.puts "Target: #{st[:target_dir]}"
366
+ @stdout.puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{status_scope(st)} installed=#{st[:provenance][:installed_at]}"
367
+ if st[:scope_error]
368
+ @stdout.puts "Target: (unknown scope: this chi can't resolve it; upgrade chi or reinstall the bundle)"
369
+ else
370
+ @stdout.puts "Target: #{st[:target_dir]}"
371
+ end
358
372
  st[:files].each do |k, info|
373
+ next @stdout.puts(" #{k}: unchecked") if info[:unchecked]
374
+
359
375
  mods = []
360
376
  mods << "conflict (kept your edits over v#{st[:provenance][:version]}: chi bundle diff #{name} #{k})" if info[:conflict]
361
377
  mods << "modified" if info[:modified]
@@ -409,15 +425,19 @@ module Samagotchi
409
425
  plugin = st[:plugin]
410
426
  mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
411
427
  mods += 1 if st[:hooks_requires_failure]
428
+ mods += 1 if st[:scope_error]
412
429
  plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
413
430
  includes = st[:provenance][:includes]
414
431
  includes_info = includes ? " includes=#{includes.join(",")}" : ""
415
- @stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{st[:scope]} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
432
+ @stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
416
433
  end
417
434
  end
418
435
  return 0
419
436
  end
420
437
 
438
+ # "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
439
+ def status_scope(st) = st[:scope_error] ? "#{st[:scope]} (unknown)" : st[:scope]
440
+
421
441
  def diff(rest)
422
442
  args = rest.reject { |a| a.start_with?("--") }
423
443
  bname = args[0]
@@ -431,7 +451,11 @@ module Samagotchi
431
451
  @stderr.puts "Bundle '#{bname}' not installed"; return 1
432
452
  end
433
453
  scope = data[:scope]&.to_s || "system"
434
- target_dir = Samagotchi::MemoryBundle::Installer.new(source: ".", name: bname, scope: scope).send(:resolve_target_dir, scope)
454
+ begin
455
+ target_dir = Samagotchi::MemoryPaths.scope_dir!(scope)
456
+ rescue ArgumentError => e
457
+ @stderr.puts "Bundle '#{bname}': #{e.message}"; return 1
458
+ end
435
459
  files = data[:files] || {}
436
460
  hooks = data[:hooks] || {}
437
461
  plugin_path = prov.plugin_path(data)
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: check-in
3
- version: 0.2.0
3
+ version: 0.2.1
4
4
  scope: system
5
5
  description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:0a5ba60962206b3ec90bbaa0934a7284416aef495df8a70391d9ea47f7eb9ec1
9
+ sha256: sha256:afbd0ecf5a41eefe79786e089aa918728ebb0fed0e3b75f84d754a47ec6ade18
10
10
  requires_chi: ">= 0.3.0"
@@ -103,7 +103,11 @@ class Plugin
103
103
  def check_in(ctx, count, mode)
104
104
  case mode
105
105
  when "nudge"
106
- ctx.notify("nudged the model after #{count} tool calls") if ctx.steer(message(count))
106
+ text = message(count)
107
+ if ctx.steer(text)
108
+ @mutex.synchronize { @nudge = { text: text, count: count } }
109
+ ctx.notify("nudged the model after #{count} tool calls")
110
+ end
107
111
  when "notify"
108
112
  ctx.notify("#{count} tool calls in this turn, no answer yet")
109
113
  else
@@ -117,15 +121,20 @@ class Plugin
117
121
  nudge_not_sent(event, ctx)
118
122
  end
119
123
 
120
- # The card said "Nudged": if the turn ended before the nudge joined it
121
- # (the model answered first, or the turn was stopped), the card says so.
124
+ # A nudge that was accepted but dropped before it joined the turn (the
125
+ # model answered first, or the turn was stopped): the card says so, or,
126
+ # for a mode nudge (no card), a notice does.
122
127
  def nudge_not_sent(event, ctx)
123
128
  id, nudge = @mutex.synchronize { [@card_id, @nudge] }
124
- return unless id && nudge
129
+ return unless nudge
125
130
  return if steered?(event[:messages], nudge[:text])
126
131
 
127
132
  first = event[:status].to_s == "canceled" ? "The turn ended first" : "The answer came first"
128
- ctx.card(id: id, title: "check-in", body: "#{first}; nudge not sent.")
133
+ if id
134
+ ctx.card(id: id, title: "check-in", body: "#{first}; nudge not sent.")
135
+ else
136
+ ctx.notify("#{first}; nudge not sent.", level: :warn)
137
+ end
129
138
  end
130
139
 
131
140
  # Whether this turn's messages (after its last prompt) have the steer.
@@ -5,8 +5,12 @@
5
5
  #
6
6
  # Fields: id, tool (a name, a list, or shell = execute + task_create),
7
7
  # command (Ruby regex), path (outside_repo or a glob), git (outside_repo:
8
- # a shell call runs commit/add/reset/… in another checkout), verdict (ask|deny),
9
- # reason, scopes (for an ask: once, session, repo, rule; default all).
8
+ # a shell call runs commit/add/reset/… in another checkout), touches
9
+ # (chi_dirs: a shell command names a path in chi's own dirs), skip_read_only
10
+ # (true: a command that only reads, ls/cat/rg/sed -n/git log…, doesn't
11
+ # match), modes (strict: only with guardrails.mode: strict; untagged rules
12
+ # ask in every mode), verdict (ask|deny), reason, scopes (for an ask: once,
13
+ # session, repo, rule; default all).
10
14
  # [^;&|\n]* keeps a match inside one command of a chain; the git rules match
11
15
  # the subcommand after optional global options (git -C dir push).
12
16
  rules:
@@ -31,9 +35,11 @@ rules:
31
35
  command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+branch\b[^;&|\n]*\s(-[a-zA-Z]*D|--delete\s+--force|--force\s+--delete)\b'
32
36
  verdict: ask
33
37
  reason: git branch -D deletes a branch even if it is not merged
38
+ # Strict mode only: a rebase rewrites local history, which the reflog keeps.
34
39
  - id: git-rebase
35
40
  tool: shell
36
41
  command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+rebase\b'
42
+ modes: [strict]
37
43
  verdict: ask
38
44
  reason: git rebase rewrites history
39
45
  - id: git-history-rewrite
@@ -44,10 +50,13 @@ rules:
44
50
 
45
51
  # ── deleting ───────────────────────────────────────────────────────────
46
52
  # rm with both -r and -f on an absolute path, ~, $HOME or a path with ..
47
- # (a text heuristic; rm -rf build/ in the repo is not asked).
53
+ # (a text heuristic; rm -rf build/ in the repo is not asked), unless
54
+ # every rm -rf target is inside a tmp dir (rm: outside_tmp; /tmp itself,
55
+ # /tmp/* and a link out of it still ask).
48
56
  - id: rm-rf-wide
49
57
  tool: shell
50
58
  command: '\brm\b(?=[^;&|\n]*\s(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(\s|$))(?=[^;&|\n]*\s(-[a-zA-Z]*f[a-zA-Z]*|--force)(\s|$))[^;&|\n]*\s(/|~|\$HOME|\S*\.\.)'
59
+ rm: outside_tmp
51
60
  verdict: ask
52
61
  reason: rm -rf on a path outside the working tree
53
62
 
@@ -65,10 +74,13 @@ rules:
65
74
 
66
75
  # ── work outside this session's repo, and git hooks ───────────────────
67
76
  # outside_repo: not in the session's repo (its folder outside a repo);
68
- # tmp dirs and memory files don't count.
77
+ # tmp dirs and memory files don't count. Strict mode only, as is
78
+ # git-outside-repo; chi's own config and hooks still ask (core protected
79
+ # paths, shell-touches-chi).
69
80
  - id: write-outside-repo
70
81
  tool: [write, edit]
71
82
  path: outside_repo
83
+ modes: [strict]
72
84
  verdict: ask
73
85
  reason: writes outside this session's repo
74
86
  # cd/git -C into another checkout, then commit, add, reset, checkout,
@@ -80,6 +92,7 @@ rules:
80
92
  - id: git-outside-repo
81
93
  tool: shell
82
94
  git: outside_repo
95
+ modes: [strict]
83
96
  verdict: ask
84
97
  scopes: [once, session, rule]
85
98
  reason: runs git that changes another checkout, outside this session's repo
@@ -90,9 +103,15 @@ rules:
90
103
  reason: git hooks run code on every git command
91
104
 
92
105
  # ── chi's own config, hooks, rules and approvals, from the shell ───────
106
+ # The real dirs (config.yml's dir, hooks, the approval store, installed
107
+ # bundles, this repo's git hooks), not a look-alike under /tmp or chi's
108
+ # own lib/samagotchi/hooks source; a word that can't be resolved ($VAR,
109
+ # $(…), a glob) is matched as text. A command that only reads them (ls,
110
+ # cat, rg, sed -n) isn't asked about.
93
111
  - id: shell-touches-chi
94
112
  tool: shell
95
- command: '(\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles|\.git/hooks)'
113
+ touches: chi_dirs
114
+ skip_read_only: true
96
115
  verdict: ask
97
116
  reason: the command names chi's config, hooks, guardrails or git hooks
98
117
 
@@ -1,10 +1,11 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.4.0
3
+ version: 0.5.0
4
4
  scope: system
5
- description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes and git in another checkout outside the session's repo, git hooks, answering chi around chi answer), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
5
+ description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config from the shell, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
7
- # models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0
8
- requires_chi: ">= 0.18.0"
7
+ # models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
8
+ # skip_read_only:, touches:, rm: and modes: need 0.20.0
9
+ requires_chi: ">= 0.20.0"
9
10
  files:
10
11
  guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: loop-guard
3
- version: 0.2.2
3
+ version: 0.3.1
4
4
  scope: system
5
5
  description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:e62c68d1a2280adf55b150488d0d85bbbc35ffce54dfed387a86adceb85e4d34
9
+ sha256: sha256:32d04fa3f4b3394e80160dd780615a4a1ddeb3be0f2e327a6497c7766dbd9f11
10
10
  requires_chi: ">= 0.6.0"