samagotchi 0.17.0 → 0.18.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/docs/architecture.md +25 -2
  4. data/docs/cli.md +34 -8
  5. data/docs/configuration.md +4 -2
  6. data/docs/guardrails.md +51 -8
  7. data/docs/hooks.md +4 -1
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +3 -2
  10. data/docs/sessions.md +6 -5
  11. data/docs/sub-agent.md +42 -4
  12. data/lib/samagotchi/answer_command.rb +23 -2
  13. data/lib/samagotchi/answer_tail.rb +52 -0
  14. data/lib/samagotchi/bridge.rb +49 -8
  15. data/lib/samagotchi/bridge_client.rb +18 -3
  16. data/lib/samagotchi/bundle_command.rb +108 -2
  17. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  18. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/commands/registry.rb +28 -0
  22. data/lib/samagotchi/config.rb +7 -1
  23. data/lib/samagotchi/continue_offer.rb +177 -0
  24. data/lib/samagotchi/engine.rb +42 -14
  25. data/lib/samagotchi/guardrails/outside.rb +46 -0
  26. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  27. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  28. data/lib/samagotchi/guardrails/rules.rb +17 -5
  29. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  30. data/lib/samagotchi/guardrails/targets.rb +30 -13
  31. data/lib/samagotchi/guardrails.rb +2 -0
  32. data/lib/samagotchi/iteration_limit.rb +38 -0
  33. data/lib/samagotchi/kernel_loop.rb +2 -1
  34. data/lib/samagotchi/llm/backend.rb +2 -1
  35. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  36. data/lib/samagotchi/llm/native_backend.rb +2 -1
  37. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  38. data/lib/samagotchi/parent_report.rb +39 -9
  39. data/lib/samagotchi/question_desk.rb +205 -46
  40. data/lib/samagotchi/reply_wait.rb +10 -4
  41. data/lib/samagotchi/send_command.rb +2 -1
  42. data/lib/samagotchi/session.rb +3 -2
  43. data/lib/samagotchi/session_commands.rb +3 -2
  44. data/lib/samagotchi/session_manager.rb +4 -1
  45. data/lib/samagotchi/sessions_command.rb +3 -2
  46. data/lib/samagotchi/system_prompt.rb +1 -1
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -5
  48. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  49. data/lib/samagotchi/terminal_ui.rb +11 -2
  50. data/lib/samagotchi/tool_declarations.rb +2 -2
  51. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  52. data/lib/samagotchi/tools/edit.rb +6 -1
  53. data/lib/samagotchi/tools/execute.rb +5 -4
  54. data/lib/samagotchi/tools/read.rb +2 -1
  55. data/lib/samagotchi/turn_flow.rb +8 -7
  56. data/lib/samagotchi/turn_note.rb +7 -0
  57. data/lib/samagotchi/version.rb +1 -1
  58. data/lib/samagotchi/web/app.rb +187 -42
  59. data/lib/samagotchi/web/public/app.js +122 -25
  60. data/lib/samagotchi/web/public/data.js +12 -13
  61. data/lib/samagotchi/web/public/index.html +6 -1
  62. data/lib/samagotchi/web/public/notify.js +5 -2
  63. data/lib/samagotchi/web/public/question_card.js +28 -1
  64. data/lib/samagotchi/web/public/question_cards.js +74 -3
  65. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  66. data/lib/samagotchi/web/public/timing.js +41 -0
  67. data/lib/samagotchi/web/public/turn_events.js +61 -0
  68. data/lib/samagotchi/web/public/update_notice.js +15 -0
  69. data/lib/samagotchi/worker.rb +30 -58
  70. data/lib/samagotchi/worker_sidecar.rb +6 -3
  71. metadata +8 -1
@@ -6,6 +6,7 @@ require_relative "bridge_client"
6
6
  require_relative "reply_wait"
7
7
  require_relative "parent_report"
8
8
  require_relative "guardrails/parent_approvals"
9
+ require_relative "guardrails/parent_continue"
9
10
  require_relative "cli/command"
10
11
  require_relative "cli/flags"
11
12
  require_relative "cli/parent_wait"
@@ -46,6 +47,9 @@ module Samagotchi
46
47
  An approval (a guardrail's ask) can be denied here: deny it, and
47
48
  tell your user; allowing it is the user's. With
48
49
  guardrails.parent_approvals: once, "Allow once" is let through.
50
+ A step-limit question (kind continue): --option Continue, or
51
+ --option Stop [--text WHY] (exit 0, status not_continued); it
52
+ can't be dismissed.
49
53
  Exit: 0 answered, 1 failed, refused or the worker is gone, 2
50
54
  usage or an option the question doesn't offer, 3 a question waits,
51
55
  4 still running (--timeout), 130 Ctrl-C.
@@ -105,8 +109,10 @@ module Samagotchi
105
109
  baseline = ReplyWait.baseline_of(session, question_id: qid)
106
110
 
107
111
  if pending && pending[:id].to_s == qid
112
+ return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
113
+
108
114
  selected = selection(pending, options) or return @selection_status
109
- refusal = approval_refusal(pending, selected, id)
115
+ refusal = approval_refusal(pending, selected, id) || continue_refusal(pending, selected)
110
116
  return failure(refusal) if refusal
111
117
 
112
118
  posted = post(id, qid, selected, options)
@@ -154,6 +160,18 @@ module Samagotchi
154
160
  reason && Guardrails::ParentApprovals.message(reason)
155
161
  end
156
162
 
163
+ # Why a parent may not answer Continue (turn.parent_continue: false in
164
+ # this process's config.yml), or nil; the worker checks it again.
165
+ def continue_refusal(pending, selected)
166
+ Guardrails::ParentContinue.refusal(pending, selected) && Guardrails::ParentContinue.message
167
+ end
168
+
169
+ # A step-limit question can't be left unanswered: the turn waits on it.
170
+ def not_dismissable(pending)
171
+ "the step-limit question can't be dismissed: answer #{Array(pending[:options]).join(' or ')} " \
172
+ "(--option Stop --text WHY stops it)"
173
+ end
174
+
157
175
  # Post the answer (or the dismissal) to the live worker's Bridge.
158
176
  # @return [Integer, nil] an exit status when it didn't go in; nil to
159
177
  # wait (it went in, or the question was no longer open: 409)
@@ -174,7 +192,10 @@ module Samagotchi
174
192
 
175
193
  case reply.status
176
194
  when 200 then nil
177
- when 409 then not_open(qid)
195
+ when 409
196
+ return failure(reply.json&.dig("detail").to_s) if reply.json&.dig("error") == "not_dismissable"
197
+
198
+ not_open(qid)
178
199
  when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
179
200
  # The worker's own guardrails.parent_approvals refused the allow.
180
201
  when 403 then failure(reply.json&.dig("detail") || "the worker refused the allow: deny it, and tell your user")
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "context_note"
4
+ require_relative "output_formatter"
5
+ require_relative "steer"
6
+ require_relative "turn_note"
7
+
8
+ module Samagotchi
9
+ # "The last answer a UI shows": the newest message the web's history
10
+ # shows as an assistant message. One rule for the worker's Bridge (`/tail`,
11
+ # Engine#last_answer_message) and the web's disk path, so the two can't
12
+ # drift. Any change to what the web shows as an answer goes through here.
13
+ module AnswerTail
14
+ module_function
15
+
16
+ # @param messages [Array<Hash>] a session's messages (symbol keys from
17
+ # disk or the Engine, string keys from a Bridge snapshot)
18
+ # @param turn_id [String, nil] that turn's answer: the last one between
19
+ # the prompt carrying this turn id and the next prompt (a queued turn's
20
+ # page re-reads after the next turn ran). An unknown or missing id:
21
+ # the newest answer.
22
+ # @return [Hash, nil] the raw message (not a copy), or nil
23
+ def find(messages, turn_id: nil)
24
+ list = Array(messages)
25
+ list = turn_slice(list, turn_id.to_s) || list unless turn_id.to_s.empty?
26
+ list.reverse_each { |m| return m if answer?(m) }
27
+ nil
28
+ end
29
+
30
+ # The messages of the turn whose prompt carries +turn_id+, from its
31
+ # prompt up to the next prompt (merged input and steers stay in it), or
32
+ # nil when no prompt carries it.
33
+ def turn_slice(list, turn_id)
34
+ start = list.rindex { |m| Steer.turn_prompt?(m) && (m[:turn_id] || m["turn_id"]).to_s == turn_id }
35
+ return nil unless start
36
+
37
+ stop = list.each_index.find { |i| i > start && Steer.turn_prompt?(list[i]) } || list.size
38
+ list[start...stop]
39
+ end
40
+
41
+ # Shown as an assistant message: role assistant or model, not a context
42
+ # note, a steer or a no-answer turn's marker, and some text left once the
43
+ # wire-format markup is stripped (a tool-call-only step has none).
44
+ def answer?(message)
45
+ return false unless message.is_a?(Hash)
46
+ return false if ContextNote.note?(message) || Steer.steer?(message) || TurnNote.empty_answer(message)
47
+ return false unless %w[assistant model].include?((message[:role] || message["role"]).to_s)
48
+
49
+ !OutputFormatter.strip_markup((message[:content] || message["content"]).to_s).empty?
50
+ end
51
+ end
52
+ end
@@ -209,8 +209,11 @@ module Samagotchi
209
209
  # last cards and between-turns notices (CardStore#list), the session's commands (Commands::Registry#listing:
210
210
  # what an attached TUI routes and completes, the web's autocomplete), and the event_seq it all covers.
211
211
  # Taken with the log held, so no event is half-applied.
212
- # @return [Hash] {messages:, current_turn:, queued:, recap:, saved_recap:, continue_offer:, guardrail_warning:,
213
- # plugin_warning:, init_tasks:, cards:, commands:, event_seq:, event_id:}
212
+ # The pending question at the top level too: one asked between turns
213
+ # (the step-limit question) belongs to no turn, so the accumulator
214
+ # doesn't keep it.
215
+ # @return [Hash] {messages:, current_turn:, queued:, recap:, saved_recap:, continue_offer:, pending_question:,
216
+ # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, event_seq:, event_id:}
214
217
  def snapshot
215
218
  @engine.synchronize_events do
216
219
  seq = @engine.event_count
@@ -221,6 +224,7 @@ module Samagotchi
221
224
  recap: @accumulator.recap,
222
225
  saved_recap: @engine.saved_recap,
223
226
  continue_offer: @accumulator.continue_offer,
227
+ pending_question: @engine.pending_question,
224
228
  guardrail_warning: @engine.guardrail_warning,
225
229
  plugin_warning: @engine.plugin_warning,
226
230
  init_tasks: @engine.init_tasks,
@@ -244,12 +248,30 @@ module Samagotchi
244
248
  end
245
249
  end
246
250
 
251
+ # What the page re-reads at a turn's end and when a card arrives: the
252
+ # session state, the last answer (Engine#last_answer_message: one
253
+ # message, not the conversation) and the cards, as one step of the event
254
+ # log (one event_seq). Light whatever the session's length: GET tail.
255
+ # @return [Hash] {session_id:, session_state_snapshot:, answer:, cards:, event_seq:, event_id:}
256
+ # +turn_id+: that turn's answer, not the newest (Engine#last_answer_message).
257
+ def tail_frame(turn_id: nil)
258
+ @engine.synchronize_events do
259
+ seq = @engine.event_count
260
+ state = @engine.session_state_snapshot.merge(event_seq: seq, event_id: event_id(seq))
261
+ { session_id: @session_id, session_state_snapshot: state, answer: @engine.last_answer_message(turn_id: turn_id),
262
+ cards: @cards.list, event_seq: seq, event_id: event_id(seq) }
263
+ end
264
+ end
265
+
247
266
  # The worker's own way in for a session command that came as a message
248
267
  # (its first prompt, an input file): queued and announced as a POST
249
268
  # /command's is (#handle_command).
250
269
  # @return [String] its command_id
251
- def queue_command(line, client_id: nil)
270
+ def queue_command(line, client_id: nil, card: false)
252
271
  command = { command_id: SecureRandom.uuid, client_id: client_id, line: line.to_s.strip }
272
+ # A card's action (the step-limit question's answer): the UIs leave
273
+ # its line out.
274
+ command[:card] = true if card
253
275
  @engine.synchronize_events { queue_command_locked(command) }
254
276
  command[:command_id]
255
277
  end
@@ -347,7 +369,7 @@ module Samagotchi
347
369
  client_id: stream_client_id(request[:query]))
348
370
  break # SSE owns the connection until the client disconnects.
349
371
  else
350
- payload, status, body = dispatch(route.first, route.last, request[:body])
372
+ payload, status, body = dispatch(route.first, route.last, request[:body], request[:query])
351
373
  write_json(io, status, payload, body)
352
374
  end
353
375
 
@@ -446,7 +468,8 @@ module Samagotchi
446
468
  %w[POST relay/status] => :handle_relay_status,
447
469
  %w[GET state] => :handle_state,
448
470
  %w[GET stats] => :handle_stats,
449
- %w[GET snapshot] => :handle_snapshot
471
+ %w[GET snapshot] => :handle_snapshot,
472
+ %w[GET tail] => :handle_tail
450
473
  }.freeze
451
474
  ROUTE_PATH = %r|\A/session/([^/]+)/(.+)\z|u
452
475
 
@@ -462,8 +485,10 @@ module Samagotchi
462
485
  # #own_session?), and a handler that raises answers 500 bridge_error,
463
486
  # logged (the connection thread doesn't report).
464
487
  # @return [Array(Hash, Integer, Hash)] headers, status, body
465
- def dispatch(handler, session_id, body)
488
+ # Only GET tail takes the request's +query+.
489
+ def dispatch(handler, session_id, body, query = nil)
466
490
  return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
491
+ return handle_tail(session_id, body, query) if handler == :handle_tail
467
492
 
468
493
  send(handler, session_id, body)
469
494
  rescue StandardError => e
@@ -533,7 +558,7 @@ module Samagotchi
533
558
  [{}, 409, { error: "question_not_pending", detail: e.message }]
534
559
  rescue QuestionDesk::Refused => e
535
560
  Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
536
- [{}, 403, { error: "parent_approval_refused",
561
+ [{}, 403, { error: e.reason == :stop_only ? "parent_continue_refused" : "parent_approval_refused",
537
562
  detail: Guardrails::ParentApprovals.message(e.reason) }]
538
563
  rescue ArgumentError => e
539
564
  [{}, 400, { error: "invalid_answer", detail: e.message }]
@@ -589,7 +614,12 @@ module Samagotchi
589
614
  return BAD_DEADLINE unless deadline_valid?(deadline)
590
615
  return deadline_passed("dismissal") if expired?("dismiss_expired", deadline, sid: session_id, id: qid)
591
616
 
592
- dismissed = @engine.cancel_question("dismissed", id: qid)
617
+ begin
618
+ dismissed = @engine.cancel_question("dismissed", id: qid)
619
+ rescue QuestionDesk::NotDismissable => e
620
+ # A step-limit question: its answer is Continue or Stop.
621
+ return [{}, 409, { error: "not_dismissable", detail: e.message }]
622
+ end
593
623
  return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless dismissed
594
624
 
595
625
  [{}, 200, { status: "dismissed", id: qid, session_id: @session_id }]
@@ -764,6 +794,17 @@ module Samagotchi
764
794
  [{}, 200, snapshot_frame]
765
795
  end
766
796
 
797
+ # The page's light re-read (#tail_frame): no message list. `?turn_id=`:
798
+ # that turn's answer (a queued turn's page re-reads late). The one
799
+ # handler that gets the query (#dispatch); an older worker's route
800
+ # ignores it and sends the newest answer.
801
+ def handle_tail(session_id, _body = nil, query = nil)
802
+ turn_id = query && URI.decode_www_form(query).to_h["turn_id"].to_s
803
+ [{}, 200, tail_frame(turn_id: turn_id.to_s.empty? ? nil : turn_id)]
804
+ rescue ArgumentError
805
+ [{}, 200, tail_frame]
806
+ end
807
+
767
808
  # The stream cursor for +seq+ in this worker.
768
809
  def event_id(seq)
769
810
  EventId.format(seq.to_i, @epoch)
@@ -210,17 +210,32 @@ module Samagotchi
210
210
  # GET /session/:id/<path> as JSON.
211
211
  # @return [Hash, nil] the parsed body, or nil unless the Bridge answered 200
212
212
  def get_json(path)
213
+ status, body = get(path)
214
+ status == 200 ? body : nil
215
+ end
216
+
217
+ # GET /session/:id/<path>, telling a refusal apart from no answer: an
218
+ # older worker's 404 for a route it lacks from a refused connect, a
219
+ # timeout or a 500.
220
+ # @return [Array(Integer, Object)] the status and the parsed JSON body
221
+ # (nil when it isn't JSON); [nil, nil] when no reply came
222
+ def get(path)
213
223
  sock = TCPSocket.new(@host, @port)
214
224
  sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nConnection: close\r\n\r\n")
215
225
  response = read_reply(sock, path)
216
226
  sock.close rescue nil
217
227
  # A 500 whose text has "200" in it is not a 200.
218
- return nil unless response && response[STATUS_LINE, 1] == "200"
228
+ status = response && response[STATUS_LINE, 1]
229
+ return [nil, nil] unless status
219
230
 
220
231
  body = response.split("\r\n\r\n", 2)[1] || ""
221
- JSON.parse(body)
232
+ [status.to_i, begin
233
+ JSON.parse(body)
234
+ rescue JSON::ParserError
235
+ nil
236
+ end]
222
237
  rescue StandardError
223
- nil
238
+ [nil, nil]
224
239
  end
225
240
 
226
241
  # Monotonic SSE cursor of the live Engine, from GET /session/:id/state.
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  # subcommands have their own flags).
11
11
  class BundleCommand
12
12
  USAGE = <<~TEXT
13
- Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build> [options]
13
+ Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash> [options]
14
14
 
15
15
  install <source> [--scope system|project] [--force]
16
16
  Install a memory bundle from a directory, zip, tar archive, or git URL.
@@ -37,6 +37,10 @@ module Samagotchi
37
37
  Build local memories and installed hooks (and plugin) into a shareable bundle (dir or zip).
38
38
  --scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
39
39
  FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
40
+
41
+ trash [--empty] [--dry-run] [--older-than DAYS]
42
+ List bundle trash (moved files from uninstalls/upgrades).
43
+ --empty deletes all trash folders. --older-than DAYS keeps only recent ones.
40
44
  TEXT
41
45
 
42
46
  # Each subcommand's --help (and only --help: -h and help are taken as
@@ -64,6 +68,7 @@ module Samagotchi
64
68
  chi bundle build --scope project --name my-bundle --version 1.0.0 --out bundle.zip
65
69
  chi bundle build --scope system --out ./my-bundle/ identity.md work.md
66
70
  TEXT
71
+ TRASH_HELP = "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
67
72
 
68
73
  # The subcommands' flags: --help only; a word not starting with -- is
69
74
  # a positional (install's source, -h included). --scope takes whatever
@@ -83,6 +88,11 @@ module Samagotchi
83
88
  BUILD_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/, dash_values: false) do |f|
84
89
  %w[--scope --name --version --description --out].each { |name| f.value name }
85
90
  end
91
+ TRASH_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
92
+ f.switch "--empty"
93
+ f.switch "--dry-run"
94
+ f.value "--older-than"
95
+ end
86
96
 
87
97
  # @param argv [Array<String>] the arguments after "bundle"
88
98
  def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
@@ -108,8 +118,9 @@ module Samagotchi
108
118
  when "diff" then diff(@argv[1..])
109
119
  when "list" then list
110
120
  when "build" then build(@argv[1..])
121
+ when "trash" then trash(@argv[1..])
111
122
  else
112
- @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
123
+ @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash"
113
124
  return CLI::Exit::USAGE
114
125
  end
115
126
  end
@@ -579,6 +590,101 @@ module Samagotchi
579
590
  end
580
591
  end
581
592
 
593
+ def trash(rest)
594
+ parsed = parse_flags(TRASH_FLAGS, rest, "trash", help: TRASH_HELP)
595
+ return parsed if parsed.is_a?(Integer)
596
+
597
+ opts = parsed.options
598
+ empty = opts.fetch(:empty, false)
599
+ dry_run = opts.fetch(:dry_run, false)
600
+ older_than = opts[:older_than]
601
+
602
+ # --older-than and --dry-run without --empty are a usage error
603
+ if (older_than || dry_run) && !empty
604
+ @stderr.puts "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
605
+ return CLI::Exit::USAGE
606
+ end
607
+
608
+ # Validate older_than if given
609
+ if older_than
610
+ if older_than.to_s.match?(/\A\d+\z/) && older_than.to_i > 0
611
+ older_than = older_than.to_i
612
+ else
613
+ @stderr.puts "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
614
+ return CLI::Exit::USAGE
615
+ end
616
+ end
617
+
618
+ if empty
619
+ do_empty(older_than_days: older_than, dry_run: dry_run)
620
+ else
621
+ do_list
622
+ end
623
+ end
624
+
625
+ def do_list
626
+ entries = Samagotchi::MemoryBundle::Trash.entries
627
+ if entries.empty?
628
+ @stdout.puts "The bundle trash is empty."
629
+ return 0
630
+ end
631
+
632
+ entries.each do |e|
633
+ @stdout.puts " #{e.name} #{e.files} file#{'s' unless e.files == 1} #{format_bytes(e.bytes)} #{format_age(e.time)}"
634
+ end
635
+ @stdout.puts "\nEmpty it with: chi bundle trash --empty [--older-than DAYS]"
636
+ 0
637
+ end
638
+
639
+ def do_empty(older_than_days:, dry_run:)
640
+ entries = Samagotchi::MemoryBundle::Trash.empty!(
641
+ older_than_days: older_than_days,
642
+ dry_run: dry_run,
643
+ )
644
+ if entries.empty?
645
+ @stdout.puts "The bundle trash is empty."
646
+ return 0
647
+ end
648
+
649
+ total_files = entries.sum(&:files)
650
+ if dry_run
651
+ entries.each do |e|
652
+ @stdout.puts "Would delete: #{e.name} (#{e.files} file#{'s' unless e.files == 1}, #{format_bytes(e.bytes)})"
653
+ end
654
+ @stdout.puts "\n(dry-run: #{entries.size} folder#{'s' unless entries.size == 1}, #{total_files} file#{'s' unless total_files == 1})"
655
+ else
656
+ @stdout.puts "Deleted #{entries.size} folder#{'s' unless entries.size == 1} (#{total_files} file#{'s' unless total_files == 1}) from the trash."
657
+ end
658
+ 0
659
+ end
660
+
661
+ # Format bytes as human-readable (B, KB, MB with one decimal)
662
+ def format_bytes(bytes)
663
+ return "0 B" if bytes == 0
664
+ if bytes < 1024
665
+ "#{bytes} B"
666
+ elsif bytes < 1_048_576
667
+ "#{(bytes / 1024.0).round(1)} KB"
668
+ else
669
+ "#{(bytes / 1_048_576.0).round(1)} MB"
670
+ end
671
+ end
672
+
673
+ # Format age as "just now", "N minutes ago", "N hours ago", "N days ago"
674
+ def format_age(time)
675
+ now = Time.now
676
+ diff = now - time
677
+ if diff < 60
678
+ "just now"
679
+ elsif diff < 3600
680
+ "#{(diff / 60).to_i} minutes ago"
681
+ elsif diff < 86_400
682
+ "#{(diff / 3600).to_i} hours ago"
683
+ else
684
+ "#{(diff / 86_400).to_i} days ago"
685
+ end
686
+ end
687
+
582
688
  # +rest+ through +flags+, stopping at the first --help or error.
583
689
  #
584
690
  # @return [CLI::Flags::Result, Integer] the result, or the exit status
@@ -4,7 +4,8 @@
4
4
  # earlier or `git -C` variants can get past them.
5
5
  #
6
6
  # Fields: id, tool (a name, a list, or shell = execute + task_create),
7
- # command (Ruby regex), path (outside_repo or a glob), verdict (ask|deny),
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),
8
9
  # reason, scopes (for an ask: once, session, repo, rule; default all).
9
10
  # [^;&|\n]* keeps a match inside one command of a chain; the git rules match
10
11
  # the subcommand after optional global options (git -C dir push).
@@ -62,12 +63,26 @@ rules:
62
63
  verdict: ask
63
64
  reason: runs decoded text as a script
64
65
 
65
- # ── files outside the repo, and git hooks ──────────────────────────────
66
+ # ── work outside this session's repo, and git hooks ───────────────────
67
+ # outside_repo: not in the session's repo (its folder outside a repo);
68
+ # tmp dirs and memory files don't count.
66
69
  - id: write-outside-repo
67
70
  tool: [write, edit]
68
71
  path: outside_repo
69
72
  verdict: ask
70
- reason: writes outside the repository
73
+ reason: writes outside this session's repo
74
+ # cd/git -C into another checkout, then commit, add, reset, checkout,
75
+ # switch, rebase, merge, push, stash, rm, mv, cherry-pick, revert, pull,
76
+ # restore or am. Read-only git (status, log, diff) and tests there run.
77
+ # "This call in this repo" would never match again (the command holds
78
+ # the commit message), so it isn't offered: "rule in this repo" is the
79
+ # always-for-here answer.
80
+ - id: git-outside-repo
81
+ tool: shell
82
+ git: outside_repo
83
+ verdict: ask
84
+ scopes: [once, session, rule]
85
+ reason: runs git that changes another checkout, outside this session's repo
71
86
  - id: git-hooks-write
72
87
  tool: [write, edit]
73
88
  path: '**/.git/hooks/**'
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.3.0
3
+ version: 0.4.0
4
4
  scope: system
5
- description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes outside the 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, 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
6
6
  trust_level: reviewed
7
- # models: in small-models.yml (guardrails.small_models)
8
- requires_chi: ">= 0.8.1"
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"
9
9
  files:
10
10
  guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
@@ -60,7 +60,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
60
60
  - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`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; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
61
61
  - `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
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`, `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`); `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.
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
 
65
65
  ```yaml
66
66
  guardrails:
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.17.0
3
+ version: 0.18.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:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
9
- config_modification_protocol.md: sha256:d0e9d4a84652bf54ec2fd664f61c536637af9decdd22fcd982f52195bf9f5fb4
9
+ config_modification_protocol.md: sha256:8d8921854e616d840b393cdda6fd911b7d5e8172501bb8293c01f734121ccc21
10
10
  memory_guide.md: sha256:e535e31fcf29b4b32c270042851ecdd5f412ef2e3c56b0076963e3e1f1a08ef8
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "did_you_mean"
4
+
3
5
  module Samagotchi
4
6
  module Commands
5
7
  # The slash (and bang) commands a session knows, in lookup order: the
@@ -74,6 +76,32 @@ module Samagotchi
74
76
 
75
77
  def command?(line) = !lookup(line).nil?
76
78
 
79
+ # A line that is one word: a slash and a name, no spaces, no second
80
+ # slash. `/modle` is one; `/foo bar` and `/usr/bin/env` are not (they
81
+ # are prompts).
82
+ UNKNOWN_COMMAND_WORD = /\A\/[A-Za-z][A-Za-z0-9_-]*\z/
83
+
84
+ # Whether +line+ is a command word no command answers: a typo like
85
+ # `/modle`, which a UI must not send to the model as a prompt.
86
+ def unknown_command_word?(line)
87
+ text = line.to_s.strip
88
+ return false unless UNKNOWN_COMMAND_WORD.match?(text)
89
+
90
+ lookup(text).nil? && lookup_local(text).nil?
91
+ end
92
+
93
+ # What a UI shows for an unknown command word, or nil when +line+ is
94
+ # not one: `Unknown command /modle. Did you mean /model? /help lists
95
+ # the commands.` The "Did you mean" part needs a close name among the
96
+ # entries (DidYouMean::SpellChecker).
97
+ def unknown_command_hint(line)
98
+ return nil unless unknown_command_word?(line)
99
+
100
+ text = line.to_s.strip
101
+ close = DidYouMean::SpellChecker.new(dictionary: @entries.map(&:name)).correct(text).first
102
+ "Unknown command #{text}. #{close ? "Did you mean #{close}? " : ""}/help lists the commands."
103
+ end
104
+
77
105
  # @return [Entry, nil] the first entry the UI runs itself (local) that
78
106
  # matches +line+, whatever its uis (a UI answers the others' too:
79
107
  # the REPL's /detach note)
@@ -142,6 +142,12 @@ module Samagotchi
142
142
  # Asks again in the same turn after an empty answer (EmptyAnswerRetry);
143
143
  # 0 = off, capped at 3. No CLI flag: workers get no CLI args.
144
144
  Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
145
+ # A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
146
+ # --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
147
+ Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 100, expose: %i[env config]),
148
+ # Whether a parent agent (chi answer) may answer Continue to a session's step-limit question; false: Stop only.
149
+ # config.yml only, like guardrails.parent_approvals: a parent must not decide it from its own environment.
150
+ Entry.new(key: "turn.parent_continue", yaml_path: %w[turn parent_continue], type: :bool, default: true, expose: %i[config]),
145
151
  # What `chi update` touches; its --no-gem/--no-bundles/--no-desktop turn one off for a run.
146
152
  Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config]),
147
153
  Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config]),
@@ -161,7 +167,7 @@ module Samagotchi
161
167
  Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
162
168
  Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
163
169
  # Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
164
- Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 30, expose: %i[env config cli]),
170
+ Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
165
171
 
166
172
  Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
167
173
  Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),