samagotchi 0.17.0 → 0.18.1

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +25 -2
  5. data/docs/cli.md +41 -9
  6. data/docs/configuration.md +4 -2
  7. data/docs/guardrails.md +59 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/memory.md +5 -3
  10. data/docs/plugins.md +3 -2
  11. data/docs/sessions.md +6 -5
  12. data/docs/sub-agent.md +42 -4
  13. data/lib/samagotchi/answer_command.rb +23 -2
  14. data/lib/samagotchi/answer_tail.rb +52 -0
  15. data/lib/samagotchi/bridge.rb +49 -8
  16. data/lib/samagotchi/bridge_client.rb +18 -3
  17. data/lib/samagotchi/bundle_command.rb +108 -2
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/client.rb +58 -10
  23. data/lib/samagotchi/commands/registry.rb +28 -0
  24. data/lib/samagotchi/config.rb +7 -1
  25. data/lib/samagotchi/continue_offer.rb +177 -0
  26. data/lib/samagotchi/engine.rb +68 -22
  27. data/lib/samagotchi/guardrail_wiring.rb +56 -3
  28. data/lib/samagotchi/guardrails/outside.rb +46 -0
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  30. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  31. data/lib/samagotchi/guardrails/rules.rb +17 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  33. data/lib/samagotchi/guardrails/targets.rb +30 -13
  34. data/lib/samagotchi/guardrails.rb +2 -0
  35. data/lib/samagotchi/iteration_limit.rb +38 -0
  36. data/lib/samagotchi/kernel_loop.rb +2 -1
  37. data/lib/samagotchi/llm/backend.rb +2 -1
  38. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  39. data/lib/samagotchi/llm/http.rb +9 -3
  40. data/lib/samagotchi/llm/native_backend.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  42. data/lib/samagotchi/model_profile.rb +2 -2
  43. data/lib/samagotchi/output_formatter.rb +9 -4
  44. data/lib/samagotchi/parent_report.rb +39 -9
  45. data/lib/samagotchi/question_desk.rb +205 -46
  46. data/lib/samagotchi/reply_wait.rb +10 -4
  47. data/lib/samagotchi/send_command.rb +2 -1
  48. data/lib/samagotchi/served_model.rb +14 -4
  49. data/lib/samagotchi/session.rb +3 -2
  50. data/lib/samagotchi/session_commands.rb +3 -2
  51. data/lib/samagotchi/session_manager.rb +4 -1
  52. data/lib/samagotchi/sessions_command.rb +3 -2
  53. data/lib/samagotchi/system_prompt.rb +1 -1
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
  55. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  56. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  57. data/lib/samagotchi/terminal_ui.rb +75 -17
  58. data/lib/samagotchi/tool_declarations.rb +4 -4
  59. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  60. data/lib/samagotchi/tools/edit.rb +6 -1
  61. data/lib/samagotchi/tools/execute.rb +102 -23
  62. data/lib/samagotchi/tools/read.rb +2 -1
  63. data/lib/samagotchi/turn_flow.rb +8 -7
  64. data/lib/samagotchi/turn_note.rb +7 -0
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +187 -42
  67. data/lib/samagotchi/web/public/app.js +122 -25
  68. data/lib/samagotchi/web/public/data.js +12 -13
  69. data/lib/samagotchi/web/public/index.html +6 -1
  70. data/lib/samagotchi/web/public/notify.js +5 -2
  71. data/lib/samagotchi/web/public/question_card.js +28 -1
  72. data/lib/samagotchi/web/public/question_cards.js +74 -3
  73. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  74. data/lib/samagotchi/web/public/timing.js +41 -0
  75. data/lib/samagotchi/web/public/turn_events.js +61 -0
  76. data/lib/samagotchi/web/public/update_notice.js +15 -0
  77. data/lib/samagotchi/worker.rb +30 -58
  78. data/lib/samagotchi/worker_sidecar.rb +6 -3
  79. metadata +9 -1
data/docs/sub-agent.md CHANGED
@@ -34,9 +34,10 @@ Exit codes, both commands:
34
34
  |---|---|---|
35
35
  | 0 | `answered` | `text` is chi's reply |
36
36
  | 0 | `command` | (`chi send`) the message was a session command (`/model x`): it ran as one, no reply comes |
37
+ | 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
37
38
  | 3 | `question` | chi waits for an answer: `question`, `answer_with` |
38
39
  | 4 | `running` | `--timeout` passed; the turn goes on, wait again |
39
- | 1 | `failed`, `canceled`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened |
40
+ | 1 | `failed`, `canceled`, `limit`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it) |
40
41
  | 2 | (no JSON) | usage error, or an option the question doesn't offer |
41
42
  | 130 | `running` | Ctrl-C; the turn goes on |
42
43
 
@@ -49,8 +50,33 @@ With `--format json` stdout is one JSON object (see
49
50
  "allow_freeform":false},"answer_with":"chi answer 297da360-… --question 3f1c… --option N"}
50
51
  ```
51
52
 
52
- `kind` is `question` (the model's), `hook`, or `approval`, which adds the tool,
53
- command, folder, rule and reason.
53
+ `kind` is `question` (the model's), `hook`, `approval`, which adds the tool,
54
+ command, folder, rule and reason, or `continue`, the step-limit question below.
55
+
56
+ ## The step limit
57
+
58
+ A turn that runs out of steps (`turn.max_iterations`, default 100) before it
59
+ answers waits as a question of kind `continue` (`"limit": 100`, options
60
+ `Continue` and `Stop`). `answer_with` continues it:
61
+ `chi answer ID --question QID --option Continue`, and the continued turn's
62
+ reply (or its next question) comes back on that same wait. To stop it:
63
+ `--option Stop`, with `--text "why"` for the model (Continue takes no text);
64
+ that wait ends with `status: not_continued`, exit 0, and the turn's work so far
65
+ stays in the session. A message instead (`chi send ID -m "…"`) drops the
66
+ question and starts a new turn. It can't be dismissed.
67
+
68
+ A parent may Continue by default: Continue grants no permission (every tool
69
+ call still meets the guardrails), it only spends time and tokens. With
70
+ `turn.parent_continue: false` in chi's config.yml (config.yml only, no
71
+ environment variable) a parent may only Stop: `chi answer` and the worker
72
+ refuse a Continue (exit 1, `a parent agent may only stop this turn here`), and
73
+ the user continues it from the web or the terminal.
74
+
75
+ A chi parent's `delegate_result` gets a child's step-limit question back as a
76
+ `question` with these commands, for its model to decide: continue it (the
77
+ `chi answer` command, through `execute`), send a narrower follow-up with
78
+ `delegate session:` (which drops the question), or report back. It isn't
79
+ relayed to the parent's user the way approvals are.
54
80
 
55
81
  ## Approvals
56
82
 
@@ -75,6 +101,10 @@ variable), "Allow once" goes through; the wider scopes never do,
75
101
  and neither does any allow on chi's own config, hooks or guardrail rules. See
76
102
  [Guardrails](guardrails.md#approvals-from-a-parent-agent).
77
103
 
104
+ Start a child in the folder it should change (`chi send --new --dir PATH`):
105
+ writes and mutating git outside its own repo ask (`write-outside-repo`,
106
+ `git-outside-repo`), which a parent can only deny.
107
+
78
108
  An answer typed into `chi --attach` or the REPL counts as a parent's too when
79
109
  stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
80
110
  `CODEX_THREAD_ID`, `SAMAGOTCHI_PARENT_SESSION`) is set.
@@ -96,7 +126,11 @@ answers from any local process.
96
126
  `chi answer` then exits 1 and says to send the task again
97
127
  (`chi send --wait -m "…" ID`); a wait ends with `worker_gone` (exit 1).
98
128
  - A follow-up (`chi send --wait -m "…" ID`) to a session waiting for an answer
99
- isn't sent: it exits 1 with the `chi answer` command for the question.
129
+ isn't sent: it exits 1 with the `chi answer` command for the question. The
130
+ step-limit question is the exception: it waits between turns, so a message
131
+ goes in, drops it and starts a new turn.
132
+ - `chi send --wait ID` with no message waits past a question already reported
133
+ (the step-limit question too), until someone answers it.
100
134
  - An answer to a question that is no longer open (you answered it in the web
101
135
  first) isn't sent; `chi answer` says so and waits for what comes next.
102
136
  - `chi send --wait` and `chi answer` don't read stdin unless it's a pipe or a
@@ -130,6 +164,10 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
130
164
  `chi answer`, a piped `chi --attach` or chi's web API.
131
165
  - `status: running` (exit 4): still working. Wait again:
132
166
  `chi send --wait --format json --timeout 500 <session_id>`.
167
+ - `question.kind: continue`: chi ran out of steps before it answered. If it is
168
+ getting somewhere, continue it (`answer_with`, `--option Continue`); if not,
169
+ stop it (`--option Stop --text "why"`, exit 0, status `not_continued`) or send
170
+ a narrower follow-up instead. Ask your user when unsure.
133
171
  - Anything else (exit 1): report `detail` to your user. `chi --attach <session_id>`
134
172
  shows the session.
135
173
 
@@ -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.1
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