samagotchi 0.11.0 → 0.13.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -1
  3. data/README.md +20 -7
  4. data/bin/chi +10 -4
  5. data/docs/architecture.md +8 -6
  6. data/docs/cli.md +59 -11
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +34 -8
  9. data/docs/guardrails.md +73 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +4 -4
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge.rb +15 -5
  17. data/lib/samagotchi/bridge_client.rb +8 -3
  18. data/lib/samagotchi/bundle_command.rb +18 -10
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/cli/command.rb +3 -2
  24. data/lib/samagotchi/cli/exit.rb +23 -0
  25. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  26. data/lib/samagotchi/config.rb +50 -18
  27. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  28. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  29. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  30. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  31. data/lib/samagotchi/desktop_command.rb +5 -3
  32. data/lib/samagotchi/engine.rb +480 -491
  33. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  34. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  35. data/lib/samagotchi/host_registry.rb +26 -3
  36. data/lib/samagotchi/idle_reminders.rb +13 -36
  37. data/lib/samagotchi/kernel_loop.rb +82 -120
  38. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  39. data/lib/samagotchi/llm/model_result.rb +14 -20
  40. data/lib/samagotchi/llm/native_backend.rb +3 -21
  41. data/lib/samagotchi/llm/openai_chat.rb +10 -2
  42. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  43. data/lib/samagotchi/llm/usage.rb +2 -57
  44. data/lib/samagotchi/memory_bundle/installer.rb +13 -1
  45. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  46. data/lib/samagotchi/memory_paths.rb +8 -0
  47. data/lib/samagotchi/model_catalog.rb +101 -0
  48. data/lib/samagotchi/model_profile.rb +31 -17
  49. data/lib/samagotchi/models_command.rb +112 -0
  50. data/lib/samagotchi/parent_report.rb +213 -0
  51. data/lib/samagotchi/plugin/api.rb +15 -2
  52. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  53. data/lib/samagotchi/question_desk.rb +29 -2
  54. data/lib/samagotchi/recap_setup.rb +15 -5
  55. data/lib/samagotchi/reminder_queue.rb +73 -0
  56. data/lib/samagotchi/reminder_store.rb +3 -3
  57. data/lib/samagotchi/reply_wait.rb +5 -1
  58. data/lib/samagotchi/self_report.rb +8 -1
  59. data/lib/samagotchi/send_command.rb +51 -76
  60. data/lib/samagotchi/session.rb +15 -0
  61. data/lib/samagotchi/session_commands.rb +5 -5
  62. data/lib/samagotchi/session_manager.rb +36 -13
  63. data/lib/samagotchi/sessions_command.rb +13 -3
  64. data/lib/samagotchi/system_prompt.rb +10 -2
  65. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  66. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  67. data/lib/samagotchi/terminal_ui.rb +23 -29
  68. data/lib/samagotchi/thinking.rb +1 -1
  69. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  70. data/lib/samagotchi/tool_call_parser.rb +5 -6
  71. data/lib/samagotchi/tool_runner.rb +4 -6
  72. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  73. data/lib/samagotchi/tools/builtins.rb +15 -3
  74. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  75. data/lib/samagotchi/tools/execute.rb +8 -8
  76. data/lib/samagotchi/tools/task_create.rb +3 -2
  77. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  78. data/lib/samagotchi/turn_flow.rb +4 -4
  79. data/lib/samagotchi/turn_state.rb +126 -0
  80. data/lib/samagotchi/used_memories.rb +88 -0
  81. data/lib/samagotchi/version.rb +1 -1
  82. data/lib/samagotchi/vision_support.rb +4 -4
  83. data/lib/samagotchi/web/app.rb +17 -35
  84. data/lib/samagotchi/web/message_parts.rb +2 -1
  85. data/lib/samagotchi/web/server.rb +2 -1
  86. data/lib/samagotchi/web/session_hub.rb +0 -4
  87. data/lib/samagotchi/web/session_summary.rb +8 -16
  88. data/lib/samagotchi/worker.rb +1 -1
  89. metadata +14 -1
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "session_manager"
5
+ require_relative "bridge_client"
6
+ require_relative "reply_wait"
7
+ require_relative "parent_report"
8
+ require_relative "guardrails/parent_approvals"
9
+ require_relative "cli/command"
10
+ require_relative "cli/flags"
11
+ require_relative "cli/parent_wait"
12
+
13
+ module Samagotchi
14
+ # `chi answer`: answer the question a session's worker waits on (exit 3
15
+ # from chi send --wait), then wait for what comes next and print it the
16
+ # same way: the reply (0), the next question (3), still running (4). It is
17
+ # how a parent agent running chi as a sub-agent answers; the web and
18
+ # chi --attach answer the same question. See docs/sub-agent.md.
19
+ #
20
+ # An approval is the user's to allow: a parent may deny it, and allow it
21
+ # only "once" when guardrails.parent_approvals (config.yml) says so. It is
22
+ # checked here and again by the worker, which sees the answer marked as
23
+ # chi answer's. That guards an honest but eager parent, not a boundary:
24
+ # the Bridge and the localhost web take answers from any local process.
25
+ class AnswerCommand
26
+ include CLI::Command
27
+ include CLI::ParentWait
28
+
29
+ USAGE = <<~TEXT
30
+ Usage: chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]
31
+ chi answer ID --question QID --dismiss [--timeout S] [--format json]
32
+ Answers the question session ID waits on (chi send --wait exits 3
33
+ with it), then waits for what comes next and prints it as chi send
34
+ --wait does.
35
+ --question QID the question's id (chi send --wait prints it); a
36
+ question no longer open is not answered
37
+ --option N|LABEL
38
+ an option, by number (1-based) or label; repeat
39
+ on a multi-select question
40
+ --text T free text, where the question allows it; with an
41
+ approval, the reason for a Deny (text alone denies)
42
+ --dismiss leave it unanswered: chi does nothing it asked
43
+ about and finishes its reply
44
+ --timeout S give up waiting after S seconds (exit 4)
45
+ --format json one JSON object on stdout (see chi send --help)
46
+ An approval (a guardrail's ask) can be denied here: deny it, and
47
+ tell your user; allowing it is the user's. With
48
+ guardrails.parent_approvals: once, "Allow once" is let through.
49
+ Exit: 0 answered, 1 failed, refused or the worker is gone, 2
50
+ usage or an option the question doesn't offer, 3 a question waits,
51
+ 4 still running (--timeout), 130 Ctrl-C.
52
+ TEXT
53
+
54
+ FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
55
+ f.value "--question"
56
+ f.value "--option", key: :options, repeat: true
57
+ f.value "--text"
58
+ f.switch "--dismiss"
59
+ f.value "--timeout"
60
+ f.value "--format"
61
+ end
62
+
63
+ def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
64
+ @argv = argv.dup
65
+ @stdout = stdout
66
+ @stderr = stderr
67
+ @state_dir = state_dir || Session.default_state_dir
68
+ end
69
+
70
+ private
71
+
72
+ def command_name = "chi answer"
73
+
74
+ def parse
75
+ parsed = parse_flags(FLAGS, @argv, options: [])
76
+ return parsed if parsed.is_a?(Integer)
77
+
78
+ options = parsed.options
79
+ return usage_error("give one session id") unless parsed.args.size == 1
80
+ return usage_error("--question QID is required (chi send --wait prints it)") if options[:question].to_s.strip.empty?
81
+ if options[:dismiss]
82
+ return usage_error("--dismiss takes no --option or --text") if !options[:options].empty? || options[:text]
83
+ elsif options[:options].empty? && options[:text].nil?
84
+ return usage_error("nothing to answer with: --option, --text or --dismiss")
85
+ end
86
+ return usage_error("--format takes text or json") if options[:format] && !FORMATS.include?(options[:format])
87
+ if options[:timeout]
88
+ options[:timeout] = Float(options[:timeout], exception: false)
89
+ return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
90
+ end
91
+ options.merge(id: parsed.args.first)
92
+ end
93
+
94
+ # Answer, then wait for what comes next.
95
+ # @return [Integer] the exit status
96
+ def run_parsed(options)
97
+ id = resolve(options[:id]) or return 1
98
+ qid = options[:question].strip
99
+ session = Session.load(id, state_dir: @state_dir)
100
+ pending = session.pending_question
101
+ # Taken before the answer goes in, with the question answered as the
102
+ # one already reported: the file keeps it until the turn thread
103
+ # wakes, and it must not come back as exit 3.
104
+ cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
105
+ baseline = ReplyWait.baseline_of(session, question_id: qid)
106
+
107
+ if pending && pending[:id].to_s == qid
108
+ selected = selection(pending, options) or return @selection_status
109
+ refusal = approval_refusal(pending, selected, id)
110
+ return failure(refusal) if refusal
111
+
112
+ posted = post(id, qid, selected, options)
113
+ return posted if posted.is_a?(Integer)
114
+ elsif session.status != Session::STATUS_RUNNING
115
+ return failure("no question #{qid} waits in #{id[0, 8]}; nothing to answer")
116
+ else
117
+ not_open(qid)
118
+ end
119
+ # Answered here, or no longer open (answered elsewhere, or a newer
120
+ # question): what comes next either way.
121
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
122
+ end
123
+
124
+ # The labels to send. A number is an option's place (1-based); an
125
+ # exact label wins over a number.
126
+ # @return [Array<String>, nil] nil after a usage error
127
+ def selection(pending, options)
128
+ offered = Array(pending[:options]).map(&:to_s)
129
+ labels = options[:options].map do |given|
130
+ next given if offered.include?(given)
131
+
132
+ index = Integer(given, exception: false)
133
+ next offered[index - 1] if index&.between?(1, offered.size)
134
+
135
+ listed = offered.each_with_index.map { |label, i| "#{i + 1}. #{label}" }.join(", ")
136
+ @selection_status = usage_failure("no option #{given}; the options: #{listed}")
137
+ return nil
138
+ end
139
+ if labels.uniq.size > 1 && !pending[:multi_select]
140
+ @selection_status = usage_failure("one option only: the question is single-select")
141
+ return nil
142
+ end
143
+ labels.uniq
144
+ end
145
+
146
+ # Why a parent may not give this answer to an approval, or nil
147
+ # (Guardrails::ParentApprovals, with this process's config.yml). A
148
+ # Deny, text alone or a dismissal always passes: each one denies. The
149
+ # worker checks it again with its own config.
150
+ def approval_refusal(pending, selected, id)
151
+ offered = Array(pending[:options]).map(&:to_s)
152
+ indices = selected.map { |label| offered.index(label) }
153
+ reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: Guardrails::ParentApprovals.setting)
154
+ reason && Guardrails::ParentApprovals.message(reason)
155
+ end
156
+
157
+ # Post the answer (or the dismissal) to the live worker's Bridge.
158
+ # @return [Integer, nil] an exit status when it didn't go in; nil to
159
+ # wait (it went in, or the question was no longer open: 409)
160
+ def post(id, qid, selected, options)
161
+ client = BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir))
162
+ return failure(worker_gone(id)) unless client
163
+
164
+ reply = nil
165
+ 2.times do
166
+ reply = if options[:dismiss]
167
+ client.dismiss_question(id: qid)
168
+ else
169
+ client.answer(id: qid, selected: selected, freeform: options[:text],
170
+ client_id: Guardrails::ParentApprovals::CLIENT_ID)
171
+ end
172
+ break unless reply.status == 408
173
+ end
174
+
175
+ case reply.status
176
+ when 200 then nil
177
+ when 409 then not_open(qid)
178
+ when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
179
+ # The worker's own guardrails.parent_approvals refused the allow.
180
+ when 403 then failure(reply.json&.dig("detail") || "the worker refused the allow: deny it, and tell your user")
181
+ when 408 then failure("the worker did not take the answer in time; the question is still open: try again")
182
+ when 404
183
+ return failure(worker_gone(id)) if reply.json&.dig("error") == "unknown_session"
184
+
185
+ cant = options[:dismiss] ? "dismiss a question" : "take an answer"
186
+ failure(BridgeClient.stale_worker_message(id, cant: cant))
187
+ else failure("the worker answered #{reply.status}: #{reply.json&.dig("detail") || reply.body}")
188
+ end
189
+ rescue Errno::ETIMEDOUT
190
+ failure("the worker did not take the answer in time; the question is still open: try again")
191
+ rescue SystemCallError, IOError, SocketError
192
+ failure(worker_gone(id))
193
+ end
194
+
195
+ # The question was answered elsewhere (the web first), or another one
196
+ # waits now: said, then what comes next is waited for.
197
+ # @return [nil]
198
+ def not_open(qid)
199
+ error_line("#{command_name}: question #{qid} is no longer open; not answered here, waiting for what comes next")
200
+ nil
201
+ end
202
+
203
+ def worker_gone(id)
204
+ "the worker is gone and the question with it; send the task again: chi send --wait -m \"…\" #{id}"
205
+ end
206
+
207
+ # @return [Integer] 1, after the line
208
+ def failure(line)
209
+ error_line("#{command_name}: #{line}")
210
+ 1
211
+ end
212
+
213
+ # The Bridge refused the answer itself (an option not offered, two on a
214
+ # single-select question): a usage error, the question still open.
215
+ def usage_failure(line)
216
+ error_line("#{command_name}: #{line}")
217
+ CLI::Exit::USAGE
218
+ end
219
+ end
220
+ end
@@ -18,6 +18,7 @@ require_relative "bridge/sse_writer"
18
18
  require_relative "bridge/turn_accumulator"
19
19
  require_relative "session"
20
20
  require_relative "engine"
21
+ require_relative "guardrails/parent_approvals"
21
22
  require_relative "session_commands"
22
23
  require_relative "image_store"
23
24
  require_relative "log"
@@ -153,7 +154,7 @@ module Samagotchi
153
154
  @capture_handle = @engine.subscribe(observer: capture_observer)
154
155
  @accumulator_handle = @engine.subscribe(observer: @accumulator)
155
156
  # A plugin's ctx.messages mid-turn holds the turn so far (plan O1).
156
- @engine.running_turn_messages = -> { @accumulator.current_messages } if @engine.respond_to?(:running_turn_messages=)
157
+ @engine.running_turn_messages = -> { @accumulator.current_messages }
157
158
  @cards_handle = @engine.subscribe(observer: @cards)
158
159
  # One a worker that died left is not open.
159
160
  @pending_card.clear
@@ -180,7 +181,7 @@ module Samagotchi
180
181
  end
181
182
  @capture_handle&.unsubscribe
182
183
  @accumulator_handle&.unsubscribe
183
- @engine.running_turn_messages = nil if @engine.respond_to?(:running_turn_messages=)
184
+ @engine.running_turn_messages = nil
184
185
  @cards_handle&.unsubscribe
185
186
  @pending_card_handle&.unsubscribe
186
187
  @pending_card&.clear
@@ -217,7 +218,7 @@ module Samagotchi
217
218
  continue_offer: @accumulator.continue_offer,
218
219
  guardrail_warning: @engine.guardrail_warning,
219
220
  plugin_warning: @engine.plugin_warning,
220
- init_tasks: @engine.respond_to?(:init_tasks) ? @engine.init_tasks : [],
221
+ init_tasks: @engine.init_tasks,
221
222
  cards: @cards.list,
222
223
  commands: command_registry.listing,
223
224
  event_seq: seq,
@@ -242,7 +243,7 @@ module Samagotchi
242
243
 
243
244
  # The Engine's commands: the built-ins and its plugins'.
244
245
  def command_registry
245
- @engine.respond_to?(:command_registry) ? @engine.command_registry : SessionCommands.builtin_registry
246
+ @engine.command_registry
246
247
  end
247
248
 
248
249
  # The single shared capture observer: appends every event to the ring.
@@ -496,6 +497,10 @@ module Samagotchi
496
497
  qid = fetched(parsed, "id")
497
498
  selected = fetched(parsed, "selected")
498
499
  freeform = fetched(parsed, "freeform")
500
+ # chi answer's marker: a parent agent, held to this worker's
501
+ # guardrails.parent_approvals (QuestionDesk::Refused). Not a boundary:
502
+ # any local process may post here, with or without it.
503
+ client_id = fetched(parsed, "client_id")
499
504
  if qid.to_s.strip.empty?
500
505
  return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }]
501
506
  end
@@ -504,11 +509,15 @@ module Samagotchi
504
509
  return deadline_passed("answer") if expired?("answer_expired", deadline, sid: session_id, id: qid)
505
510
 
506
511
  begin
507
- result = @engine.answer_question(id: qid, selected: selected, freeform: freeform)
512
+ result = @engine.answer_question(id: qid, selected: selected, freeform: freeform, client_id: client_id)
508
513
  [{}, 200, { status: "answered", session_id: session_id, answer: result }]
509
514
  rescue Engine::QuestionNotPending => e
510
515
  # Another client answered first, or the question was cancelled.
511
516
  [{}, 409, { error: "question_not_pending", detail: e.message }]
517
+ rescue QuestionDesk::Refused => e
518
+ Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
519
+ [{}, 403, { error: "parent_approval_refused",
520
+ detail: Guardrails::ParentApprovals.message(e.reason) }]
512
521
  rescue ArgumentError => e
513
522
  [{}, 400, { error: "invalid_answer", detail: e.message }]
514
523
  end
@@ -888,6 +897,7 @@ module Samagotchi
888
897
  202 => "Accepted",
889
898
  204 => "No Content",
890
899
  400 => "Bad Request",
900
+ 403 => "Forbidden",
891
901
  404 => "Not Found",
892
902
  409 => "Conflict",
893
903
  500 => "Internal Server Error",
@@ -108,10 +108,15 @@ module Samagotchi
108
108
 
109
109
  # POST /session/:id/answer. 200 = recorded, 409 = another client answered
110
110
  # first (or the question is gone), 400 = invalid selection, 408
111
- # deadline_passed = read too late and dropped (see DEADLINE_SHARE).
111
+ # deadline_passed = read too late and dropped (see DEADLINE_SHARE), 403
112
+ # parent_approval_refused = a parent's allow (client_id cli:answer)
113
+ # beyond the worker's guardrails.parent_approvals.
114
+ # @param client_id [String, nil] who answers (chi answer marks its own)
112
115
  # @return [Response]
113
- def answer(id:, selected:, freeform: nil)
114
- post("answer", { id: id, selected: selected, freeform: freeform, deadline: deadline }, read_body: true)
116
+ def answer(id:, selected:, freeform: nil, client_id: nil)
117
+ body = { id: id, selected: selected, freeform: freeform, deadline: deadline }
118
+ body[:client_id] = client_id if client_id
119
+ post("answer", body, read_body: true)
115
120
  end
116
121
 
117
122
  # POST /session/:id/question/dismiss: leave the question unanswered.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "cli/flags"
4
+ require_relative "cli/exit"
4
5
  require_relative "memory_bundle"
5
6
 
6
7
  module Samagotchi
@@ -109,7 +110,7 @@ module Samagotchi
109
110
  when "build" then build(@argv[1..])
110
111
  else
111
112
  @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
112
- return 2
113
+ return CLI::Exit::USAGE
113
114
  end
114
115
  end
115
116
 
@@ -124,7 +125,7 @@ module Samagotchi
124
125
  force = opts.fetch(:force, false)
125
126
  if source.nil? || source.empty?
126
127
  @stderr.puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
127
- return 2
128
+ return CLI::Exit::USAGE
128
129
  end
129
130
  expanded_source = expand_source(source)
130
131
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
@@ -148,7 +149,7 @@ module Samagotchi
148
149
  agent = opts[:agent]
149
150
  if source.nil? || source.empty?
150
151
  @stderr.puts "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run]"
151
- return 2
152
+ return CLI::Exit::USAGE
152
153
  end
153
154
  expanded_source = expand_source(source)
154
155
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
@@ -217,7 +218,11 @@ module Samagotchi
217
218
 
218
219
  # An upgrade that kept edited files: launch an agent to merge them
219
220
  # (--agent, or yes on a terminal), or say how to take the bundle's.
220
- # @return [Integer] 0 resolved, 2 kept the edits
221
+ # An upgrade that kept edited files exits 2, as a usage error does
222
+ # (scripts already read it so): not CLI::Exit::USAGE by meaning.
223
+ CONFLICTS_KEPT = 2
224
+
225
+ # @return [Integer] 0 resolved, CONFLICTS_KEPT kept the edits
221
226
  def upgrade_conflicts(installer, bundle_name, expanded_source, agent)
222
227
  @stdout.puts "\n#{installer.conflicts.size} conflict(s) need resolution."
223
228
  installer.conflicts.each { |k, _| @stdout.puts " conflict: #{k}" }
@@ -227,13 +232,13 @@ module Samagotchi
227
232
  ans && ans.strip.downcase.start_with?("y")
228
233
  elsif agent.nil?
229
234
  @stdout.puts "Non-interactive terminal: kept your edits in the file(s) above; the rest is upgraded. Re-run with --force to take the bundle's version, or --agent in a TTY to merge."
230
- return 2
235
+ return CONFLICTS_KEPT
231
236
  else
232
237
  agent
233
238
  end
234
239
  unless launch
235
240
  @stdout.puts "Kept your edits in the file(s) above; the rest is upgraded. chi bundle diff #{bundle_name} FILE shows the base; re-run with --force to take the bundle's version."
236
- return 2
241
+ return CONFLICTS_KEPT
237
242
  end
238
243
 
239
244
  prompt = build_conflict_prompt(bundle_name, installer.conflicts, expanded_source)
@@ -258,7 +263,7 @@ module Samagotchi
258
263
  force = opts.fetch(:force, false)
259
264
  if bundle_name.nil? || bundle_name.empty?
260
265
  @stderr.puts "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
261
- return 2
266
+ return CLI::Exit::USAGE
262
267
  end
263
268
  data = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).read
264
269
  return uninstall_profile(bundle_name, force: force) if Samagotchi::MemoryBundle::Profile.installed_meta?(bundle_name, data)
@@ -355,6 +360,8 @@ module Samagotchi
355
360
  exists = File.exist?(hook_path) ? "ok" : "missing"
356
361
  @stdout.puts " #{hk}: event=#{event} on_error=#{on_error} priority=#{prio} [#{exists}]"
357
362
  end
363
+ @stdout.puts " requires_chi: #{st[:provenance][:requires_chi]}" if st[:hooks_requires_failure]
364
+ @stdout.puts " not loaded: #{st[:hooks_requires_failure]}" if st[:hooks_requires_failure]
358
365
  end
359
366
  if (plugin = st[:plugin])
360
367
  note = plugin[:state] == "modified" ? " (edited after install: not loaded; reinstall the bundle)" : ""
@@ -379,6 +386,7 @@ module Samagotchi
379
386
  hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
380
387
  plugin = st[:plugin]
381
388
  mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
389
+ mods += 1 if st[:hooks_requires_failure]
382
390
  plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
383
391
  includes = st[:provenance][:includes]
384
392
  includes_info = includes ? " includes=#{includes.join(",")}" : ""
@@ -393,7 +401,7 @@ module Samagotchi
393
401
  bname = args[0]
394
402
  file_arg = args[1]
395
403
  if bname.nil? || bname.empty?
396
- @stderr.puts "Usage: chi bundle diff <bundle> [file]"; return 2
404
+ @stderr.puts "Usage: chi bundle diff <bundle> [file]"; return CLI::Exit::USAGE
397
405
  end
398
406
  prov = Samagotchi::MemoryBundle::Provenance.new(name: bname)
399
407
  data = prov.read
@@ -534,7 +542,7 @@ module Samagotchi
534
542
  # Validate scope if given
535
543
  if scope && !%w[system project].include?(scope.to_s.strip.downcase)
536
544
  @stderr.puts "Invalid scope '#{scope}', expected system or project"
537
- return 2
545
+ return CLI::Exit::USAGE
538
546
  end
539
547
  begin
540
548
  builder = Samagotchi::MemoryBundle::Builder.new(
@@ -573,7 +581,7 @@ module Samagotchi
573
581
  end
574
582
  if parsed.error
575
583
  @stderr.puts "Unknown bundle #{sub} flag: #{parsed.error.arg}"
576
- return 2
584
+ return CLI::Exit::USAGE
577
585
  end
578
586
  parsed
579
587
  end
@@ -80,3 +80,22 @@ rules:
80
80
  command: '(\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles|\.git/hooks)'
81
81
  verdict: ask
82
82
  reason: the command names chi's config, hooks, guardrails or git hooks
83
+
84
+ # ── answering chi's own questions around chi answer ───────────────────
85
+ # A parent agent answers a child's approval with chi answer, which holds it
86
+ # to guardrails.parent_approvals. These shapes go around that: chi --attach
87
+ # or chi -p with stdin from a pipe, a here-string or a file (a chi after 0.12 treats
88
+ # those as a parent too, but an older child doesn't), and a POST to the
89
+ # Bridge's or the web's answer route.
90
+ - id: chi-answer-piped
91
+ tool: shell
92
+ command: '(\|[^;&|\n]*\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b|\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b[^;&|\n]*<)'
93
+ verdict: ask
94
+ scopes: [once, session]
95
+ reason: answers chi's questions from a pipe or a file, around chi answer
96
+ - id: chi-answer-http
97
+ tool: shell
98
+ command: '\b(curl|wget|http|xh)\b[^;&|\n]*/answer\b'
99
+ verdict: ask
100
+ scopes: [once, session]
101
+ reason: posts an answer to a chi session's question directly, around chi answer
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.2.0
3
+ version: 0.3.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), 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 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
6
6
  trust_level: reviewed
7
7
  # models: in small-models.yml (guardrails.small_models)
8
8
  requires_chi: ">= 0.8.1"
@@ -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; env `SAMAGOTCHI_GUARDRAILS_ENABLED`), `rules:` (list), `disable:` (list of rule ids, `id` or `bundle:id`, switching off a bundle's or config rule without editing it). A rule takes only `id`, `tool`, `command`, `path`, `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`); `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 load when a session starts: restart the worker/REPL after an edit. 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`, `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.
64
64
 
65
65
  ```yaml
66
66
  guardrails:
@@ -122,7 +122,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
122
122
  3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
123
123
  4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agent_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
124
124
  5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
125
- 6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, guardrail rules, bundle settings) waits for its restart. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
125
+ 6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, bundle settings) waits for its restart; guardrail rules and `model_aliases:` are read again live. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
126
126
 
127
127
  ## Validations
128
128
 
@@ -136,8 +136,8 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
136
136
  - must not point to itself (case-insensitive)
137
137
  - keys are normalized to downcase on write — `Qwen` and `qwen` collide
138
138
  - **Alias target**: non-empty string (model id).
139
- - **Hosts**: each entry needs `host`, `port` 1-65535, `transport` and `api` optional (a raw `api` must match `transport`), name must match `/\A[a-z0-9][a-z0-9._-]*\z/i`.
140
- - **Hooks**: each entry must have `path` (relative to `hooks_dir`), `on_error` is `skip` (default) or `log`. Class name must match file basename snake→Pascal.
139
+ - **Hosts**: each entry needs `host` (or `url:`, an http(s) URL with an optional path, replacing `host`/`port`; never both), `port` 1-65535 (default 8080), `enabled: false` skips the entry, `transport` and `api` optional (a raw `api` must match `transport`), name must match `/\A[a-z0-9][a-z0-9._-]*\z/i`.
140
+ - **Hooks**: each entry must have `path` (relative to `hooks_dir`), `on_error` is `skip` (default) or `log`; `required: true` on a `before_tool_call` hook denies the call when the hook raises. Class name must match file basename snake→Pascal.
141
141
  - **Scalars via registry**: `Config.coerce` validates `String/Numeric/true/false` per `type: :string/:integer/:float/:bool/:enum`; invalid values warn and fall back to entry `default`.
142
142
  - **Keys**: `validate_yaml_sections` flags unknown keys (with a suggestion), a registry section that isn't a mapping, and env/CLI-only keys (`model.profile`).
143
143
 
@@ -151,5 +151,5 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
151
151
 
152
152
  - Precedence is `CLI > ENV > file > default` (`Config.resolve`; `Config.get_with_origin` names the layer). `ENV` holds only what the user (or a spawning chi's CLI flags) set, and CLI (`--recap-base-url`) wins over both via `Config.reload!(cli_overrides:)`.
153
153
  - `--recap_base_url` (underscore) is rejected as unknown — use `--recap-base-url` (kebab). Same for all registry flags.
154
- - `model_aliases` require restart or `/model` reload to take effect; document the change.
154
+ - `model_aliases` are read again at each model resolution: an edit applies to the next `--model`, `/model`, new session or delegate, no restart. A running session keeps the model it already resolved.
155
155
  - Keep edits minimal: touch only the key you intend to change; preserve `hosts:`/`hooks:`/`guardrails:`/`bundles:` maps. Adding a `bundles: <name>:` entry does not install the bundle (`chi bundle install <name>`).
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.11.0
3
+ version: 0.13.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:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
9
- config_modification_protocol.md: sha256:dddd083d6f419674e6e0cc39118ca9785583a557e4950af1a9b028e52e8a3e0c
9
+ config_modification_protocol.md: sha256:d0e9d4a84652bf54ec2fd664f61c536637af9decdd22fcd982f52195bf9f5fb4
10
10
  memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "exit"
4
+
3
5
  module Samagotchi
4
6
  module CLI
5
7
  # The usage side of a subcommand: its error lines, usage errors (exit 2)
@@ -7,7 +9,6 @@ module Samagotchi
7
9
  # #command_name ("chi send"); its usage text is USAGE unless it
8
10
  # overrides #usage_text.
9
11
  module Command
10
- USAGE_EXIT = 2
11
12
  HELP_WORDS = %w[-h --help help].freeze
12
13
 
13
14
  private
@@ -30,7 +31,7 @@ module Samagotchi
30
31
  usage = usage_on_error
31
32
  error_line("#{command_name}: #{message}#{usage ? "" : " (see #{command_name} --help)"}")
32
33
  @stderr.puts(usage) if usage
33
- USAGE_EXIT
34
+ Exit::USAGE
34
35
  end
35
36
 
36
37
  # +argv+ through +flags+ (a Flags), help and usage errors done.
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module CLI
5
+ # chi's exit statuses, one table for every subcommand (docs/cli.md,
6
+ # docs/sub-agent.md): a script or a parent agent branches on them.
7
+ module Exit
8
+ OK = 0
9
+ # Anything that went wrong at run time: a refusal, a failed turn, a
10
+ # worker gone, a file that isn't there.
11
+ FAILED = 1
12
+ # The command line itself: an unknown flag, a missing argument, an
13
+ # option the question doesn't offer.
14
+ USAGE = 2
15
+ # A question waits for an answer (chi send --wait, chi answer, chi -p).
16
+ QUESTION = 3
17
+ # Still running after --timeout.
18
+ RUNNING = 4
19
+ # Ctrl-C: what a shell gives a command it interrupted.
20
+ INTERRUPTED = 130
21
+ end
22
+ end
23
+ end