samagotchi 0.115.0 → 0.117.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/bin/chi +2 -0
  4. data/docs/cli.md +33 -2
  5. data/docs/configuration.md +13 -0
  6. data/docs/guardrails.md +188 -1
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
  10. data/lib/samagotchi/bridge.rb +114 -14
  11. data/lib/samagotchi/bridge_client.rb +20 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +34 -3
  14. data/lib/samagotchi/engine.rb +6 -1
  15. data/lib/samagotchi/guardrail_wiring.rb +166 -2
  16. data/lib/samagotchi/guardrails/approval.rb +20 -0
  17. data/lib/samagotchi/guardrails/targets.rb +23 -2
  18. data/lib/samagotchi/guardrails/verdict.rb +23 -3
  19. data/lib/samagotchi/guardrails_command.rb +329 -0
  20. data/lib/samagotchi/hold_stop.rb +89 -0
  21. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
  22. data/lib/samagotchi/prompt_warmup.rb +8 -3
  23. data/lib/samagotchi/session_access/contract.rb +1 -1
  24. data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
  25. data/lib/samagotchi/session_commands.rb +79 -2
  26. data/lib/samagotchi/session_manager.rb +3 -2
  27. data/lib/samagotchi/session_metrics.rb +7 -1
  28. data/lib/samagotchi/system_one/answer.rb +78 -0
  29. data/lib/samagotchi/system_one/approval_question.rb +113 -0
  30. data/lib/samagotchi/system_one/client.rb +89 -0
  31. data/lib/samagotchi/system_one/errors.rb +14 -0
  32. data/lib/samagotchi/system_one/fake_backend.rb +45 -0
  33. data/lib/samagotchi/system_one/http_backend.rb +129 -0
  34. data/lib/samagotchi/system_one/log.rb +133 -0
  35. data/lib/samagotchi/system_one/question.rb +86 -0
  36. data/lib/samagotchi/system_one/record.rb +83 -0
  37. data/lib/samagotchi/system_one/self_protect.rb +90 -0
  38. data/lib/samagotchi/system_one/similar.rb +69 -0
  39. data/lib/samagotchi/system_one.rb +25 -0
  40. data/lib/samagotchi/task_watch.rb +23 -5
  41. data/lib/samagotchi/terminal_ui/attached_loop.rb +145 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -0
  43. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  44. data/lib/samagotchi/version.rb +1 -1
  45. data/lib/samagotchi/web/app.rb +3 -0
  46. data/lib/samagotchi/web/public/activity.js +10 -0
  47. data/lib/samagotchi/web/public/app.js +20 -1
  48. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  49. data/lib/samagotchi/web/public/composer_fold.js +8 -8
  50. data/lib/samagotchi/web/public/composer_grip.js +65 -22
  51. data/lib/samagotchi/web/public/composer_size.js +40 -5
  52. data/lib/samagotchi/web/public/data.js +24 -0
  53. data/lib/samagotchi/web/public/favicon.svg +1 -1
  54. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  55. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  56. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  57. data/lib/samagotchi/web/public/index.html +21 -0
  58. data/lib/samagotchi/web/public/session_actions.js +39 -6
  59. data/lib/samagotchi/web/public/stop_sheet.js +126 -0
  60. data/lib/samagotchi/web/public/system_one_wrong.js +32 -0
  61. data/lib/samagotchi/web/public/timing.js +9 -5
  62. data/lib/samagotchi/web/public/turn_events.js +1 -0
  63. data/lib/samagotchi/web/public/turn_html.js +13 -2
  64. data/lib/samagotchi/web/session_access_routes.rb +9 -6
  65. data/lib/samagotchi/web/session_control.rb +62 -3
  66. data/lib/samagotchi/web/session_show.rb +35 -5
  67. data/lib/samagotchi/worker.rb +57 -5
  68. data/lib/samagotchi/worker_idle_exit.rb +111 -9
  69. metadata +17 -1
@@ -19,6 +19,7 @@ require_relative "bridge/sse_writer"
19
19
  require_relative "bridge/turn_accumulator"
20
20
  require_relative "session"
21
21
  require_relative "engine"
22
+ require_relative "system_one/log"
22
23
  require_relative "session_commands"
23
24
  require_relative "queued_command"
24
25
  require_relative "steer"
@@ -86,21 +87,36 @@ module Samagotchi
86
87
  # one, POST /command answers 501
87
88
  # @param on_exit_request [#call, nil] a client asks the worker to exit
88
89
  # now: takes the client_id and delete: (the exit is to delete the
89
- # session, /exit --delete), returns nil when the worker will leave or
90
- # what keeps it up (a WorkerIdleExit::Hold: #reason, #tasks, #hint);
91
- # called with the event log held. Without one, POST /exit answers 501
90
+ # session, /exit --delete) or stop: ("all": stop everything the user
91
+ # can stop first), returns nil when the worker will leave or
92
+ # what keeps it up (a WorkerIdleExit::Hold: #reason, #tasks, #hint,
93
+ # #holds); called with the event log held. Without one, POST /exit
94
+ # answers 501
92
95
  # @param exit_discards [#call, nil] after an exit the worker agreed to:
93
96
  # whether it will delete the session as empty (the 200 says
94
97
  # +discard+); without one the reply leaves the field out
98
+ # @param exit_stopped [#call, nil] after a stop-all exit: what it stopped
99
+ # (a HoldStop::Stopped) for the 200's +stopped+; nil leaves it out
100
+ # @param on_holds [#call, nil] what the user could stop in this worker
101
+ # right now (WorkerIdleExit#stoppable_holds) for GET /holds; without
102
+ # one that route answers 501
103
+ # @param on_holds_stop [#call, nil] stop every one of them (HoldStop#stop_all)
104
+ # without the worker exiting, for POST /holds/stop; returns the
105
+ # HoldStop::Stopped the 200's +stopped+ carries; without one that route
106
+ # answers 501
95
107
  def initialize(engine:, state_dir:, session_id:, bind: DEFAULT_BIND,
96
108
  port: 0, ring_capacity: DEFAULT_RING_CAPACITY,
97
109
  heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL, input_format: nil, on_input: nil,
98
- on_command: nil, on_exit_request: nil, exit_discards: nil)
110
+ on_command: nil, on_exit_request: nil, exit_discards: nil, exit_stopped: nil,
111
+ on_holds: nil, on_holds_stop: nil)
99
112
  @engine = engine
100
113
  @on_input = on_input
101
114
  @on_command = on_command
102
115
  @on_exit_request = on_exit_request
103
116
  @exit_discards = exit_discards
117
+ @exit_stopped = exit_stopped
118
+ @on_holds = on_holds
119
+ @on_holds_stop = on_holds_stop
104
120
  @input_format = input_format
105
121
  @state_dir = state_dir
106
122
  @session_id = session_id
@@ -584,10 +600,13 @@ module Samagotchi
584
600
  %w[POST cancel] => :handle_cancel,
585
601
  %w[POST answer] => :handle_answer,
586
602
  %w[POST question/dismiss] => :handle_dismiss_question,
603
+ %w[POST guardrails/wrong] => :handle_system_one_wrong,
587
604
  %w[POST turn] => :handle_post_turn,
588
605
  %w[POST turn/dequeue] => :handle_dequeue_turn,
589
606
  %w[POST command] => :handle_command,
590
607
  %w[POST exit] => :handle_exit_request,
608
+ %w[GET holds] => :handle_holds,
609
+ %w[POST holds/stop] => :handle_holds_stop,
591
610
  %w[POST recap] => :handle_recap,
592
611
  %w[POST relay] => :handle_relay,
593
612
  %w[POST relay/status] => :handle_relay_status,
@@ -730,6 +749,30 @@ module Samagotchi
730
749
  end
731
750
  end
732
751
 
752
+ # POST /session/:id/guardrails/wrong {id}: append a false-allow label
753
+ # only for this session's System 1 auto-allow. Unknown ids are not
754
+ # distinguishable from judgments belonging to another session.
755
+ def handle_system_one_wrong(session_id, body)
756
+ parsed = parse_json(body)
757
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
758
+
759
+ judgment_id = fetched(parsed, "id").to_s.strip
760
+ return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }] if judgment_id.empty?
761
+
762
+ log = SystemOne::Log.new(dir: Guardrails::Approvals.dir_for(@state_dir))
763
+ entry = log.entries.find do |judgment|
764
+ judgment["id"] == judgment_id &&
765
+ judgment["session_id"] == session_id &&
766
+ judgment["decision"] == "auto"
767
+ end
768
+ return [{}, 404, { error: "unknown_judgment" }] unless entry
769
+ return [{}, 200, { status: "labelled", id: judgment_id }] if entry["answer"] == "should_have_asked"
770
+ return [{}, 409, { error: "already_labelled" }] unless entry["answer"].nil?
771
+ return [{}, 500, { error: "label_failed", detail: "could not write the System 1 label" }] unless log.label(judgment_id, "should_have_asked")
772
+
773
+ [{}, 200, { status: "labelled", id: judgment_id }]
774
+ end
775
+
733
776
  # What a parent's relay may say about this session's approval
734
777
  # (BridgeClient#relay): opened and closed only change how the question
735
778
  # shows (QuestionDesk#annotate); answered makes this worker ask the
@@ -857,14 +900,21 @@ module Samagotchi
857
900
 
858
901
  # A client asks the worker to exit now (`/exit` in the attached TUI), or
859
902
  # with "restart": true to hand the session to a new worker on the newest
860
- # chi installed (the "restart" feature). The worker decides with the
861
- # event log held, so no POST /turn lands between its check and its
862
- # answer; it leaves from its loop after this reply.
863
- # 200 {status: "exiting", discard?: the session is empty and goes} ({status: "restarting"} for a restart), or 409
864
- # {status: "held", reason:} naming what keeps it up (with tasks: [{id:,
865
- # command:}] and a hint: how to stop them, for background_tasks). A past +deadline+
866
- # (see #handle_post_turn), checked with the event log held: 408
867
- # deadline_passed, the worker not asked (its client said it didn't stop).
903
+ # chi installed (the "restart" feature). "stop": "all" first stops every
904
+ # hold the user can stop; "ask": false leaves the delegate children out of
905
+ # the list (a UI that can't ask about them, as /archive). The worker
906
+ # decides with the event log held, so no POST /turn lands between its
907
+ # check and its answer; it leaves from its loop after this reply.
908
+ # 200 {status: "exiting", discard?: the session is empty and goes,
909
+ # stopped?: what a "stop": "all" exit stopped} ({status: "restarting"} for
910
+ # a restart), or 409 {status: "held", reason:} naming what keeps it up
911
+ # (with tasks: [{id:, command:, age_seconds:}] and a hint: how to stop
912
+ # them, for background_tasks). When every hold is one the user can stop,
913
+ # the 409 also carries holds: [{kind:, items: [{id:, label:, detail:}]}
914
+ # — reminders, running tasks, delegate children — for the terminal's ask.
915
+ # A past +deadline+ (see #handle_post_turn), checked with the event log
916
+ # held: 408 deadline_passed, the worker not asked (its client said it
917
+ # didn't stop).
868
918
  # Returns [headers, status, body].
869
919
  def handle_exit_request(session_id, body)
870
920
  return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
@@ -875,7 +925,13 @@ module Samagotchi
875
925
  client_id = fetched(parsed, "client_id")
876
926
  delete = fetched(parsed, "delete") == true
877
927
  restart = fetched(parsed, "restart") == true
928
+ stop_all = fetched(parsed, "stop") == "all"
929
+ # A client that can ask about what still runs (an attached terminal's
930
+ # plain /exit) gets the delegates in the hold list; one that can't
931
+ # (/archive, and every worker's own last look) does not.
878
932
  options = restart ? { delete: delete, restart: true } : { delete: delete }
933
+ options[:stop] = true if stop_all && !restart
934
+ options[:ask] = false if fetched(parsed, "ask") == false
879
935
  deadline = fetched(parsed, "deadline")
880
936
  return BAD_DEADLINE unless deadline_valid?(deadline)
881
937
 
@@ -892,13 +948,57 @@ module Samagotchi
892
948
 
893
949
  body = { status: "exiting", session_id: @session_id }
894
950
  body[:discard] = @exit_discards.call == true if @exit_discards
951
+ stopped = @exit_stopped&.call
952
+ body[:stopped] = stopped.to_h if stopped
895
953
  return [{}, 200, body]
896
954
  end
897
955
 
898
956
  # A hold may name what it waits for (tasks: the background tasks a
899
- # finish wake watches).
957
+ # finish wake watches). When every hold is one the user can stop, +holds+
958
+ # lists them for the terminal's ask: [{kind:, items: [{id:, label:,
959
+ # detail:}]} (docs/cli.md, /exit).
900
960
  [{}, 409, { status: "held", reason: hold.reason.to_s, tasks: hold.tasks, hint: hold.hint,
901
- session_id: @session_id }.compact]
961
+ holds: holds_json(hold.holds), session_id: @session_id }.compact]
962
+ end
963
+
964
+ # The exit ask's stoppable holds as JSON; nil when there are none, so an
965
+ # older client sees today's plain 409.
966
+ def holds_json(holds)
967
+ return nil if holds.nil? || holds.empty?
968
+
969
+ holds.map { |hold| { kind: hold.kind.to_s, items: hold.items.map { |item| item.to_h } } }
970
+ end
971
+
972
+ # GET /session/:id/holds: what the user could stop in this worker right
973
+ # now — the same holds the exit ask's 409 carries (reminders, the watched
974
+ # background tasks still running, the delegate children with a live
975
+ # worker), whatever else keeps the worker up: chi web's Stop asks about
976
+ # these before it TERMs the worker, and a Stop cancels a running turn
977
+ # anyway, so a turn is not a reason to hide them.
978
+ # 200 {session_id, holds: [{kind:, items: [...]}]} (no +holds+ when there
979
+ # are none); 501 holds_unavailable when this worker has no such step.
980
+ # Returns [headers, status, body].
981
+ def handle_holds(_session_id, _body = nil)
982
+ return [{}, 501, { error: "holds_unavailable" }] unless @on_holds
983
+
984
+ [{}, 200, { session_id: @session_id, holds: holds_json(@on_holds.call) }.compact]
985
+ end
986
+
987
+ # POST /session/:id/holds/stop: stop everything the user can stop (HoldStop#stop_all)
988
+ # without the worker leaving — the reminders cleared, the watched tasks
989
+ # killed as task_stop does, the delegate children stopped as
990
+ # `chi sessions stop` does. What the user can't stop (a turn running,
991
+ # another UI attached) is untouched. The web's Stop's "Stop all" runs
992
+ # this, then stops the worker.
993
+ # 200 {status: "stopped", session_id, stopped: {reminders:, tasks:,
994
+ # delegates:}}; 501 holds_unavailable when this worker has no such step.
995
+ # Returns [headers, status, body].
996
+ def handle_holds_stop(_session_id, _body = nil)
997
+ return [{}, 501, { error: "holds_unavailable" }] unless @on_holds_stop
998
+
999
+ Log.info(:bridge, "holds_stop")
1000
+ stopped = @on_holds_stop.call
1001
+ [{}, 200, { status: "stopped", session_id: @session_id, stopped: stopped.to_h }]
902
1002
  end
903
1003
 
904
1004
  # Queue a turn for this session through its file IPC (the worker polls
@@ -145,6 +145,13 @@ module Samagotchi
145
145
  post("question/dismiss", { id: id, deadline: deadline }, read_body: true)
146
146
  end
147
147
 
148
+ # POST /session/:id/guardrails/wrong: mark this session's System 1
149
+ # auto-allow as one it should have asked about; 404 when unknown.
150
+ # @return [Response]
151
+ def mark_system_one_wrong(judgment_id:)
152
+ post("guardrails/wrong", { id: judgment_id }, read_body: true)
153
+ end
154
+
148
155
  # POST /session/:id/turn/dequeue: take back a queued message (one that
149
156
  # waits for the running turn's end) before it runs. 200 {status:
150
157
  # "removed", enqueued_id, prompt}; 409 already_started (a turn took
@@ -199,10 +206,12 @@ module Samagotchi
199
206
  # error "not_found" = a worker older than the route, 408 = too late.
200
207
  # @param client_id [String] the asking UI, whose own streams don't hold
201
208
  # @return [Response]
202
- def request_exit(client_id:, delete: false, restart: false)
209
+ def request_exit(client_id:, delete: false, restart: false, stop: false, ask: true)
203
210
  body = { client_id: client_id }
204
211
  body[:delete] = true if delete
205
212
  body[:restart] = true if restart
213
+ body[:stop] = "all" if stop
214
+ body[:ask] = false unless ask
206
215
  body[:deadline] = deadline
207
216
  post("exit", body, read_body: true)
208
217
  end
@@ -229,6 +238,16 @@ module Samagotchi
229
238
  post("tasks/stop", { task_id: task_id }, read_body: true)
230
239
  end
231
240
 
241
+ # POST /session/:id/holds/stop: stop everything this worker's user can
242
+ # stop (its reminders, the background tasks a finish wake watches that
243
+ # still run, its delegate children) without the worker exiting
244
+ # (Bridge#handle_holds_stop). 200 {status: "stopped", stopped:
245
+ # {reminders:, tasks:, delegates:}}; 501 for a worker without the route.
246
+ # @return [Response]
247
+ def stop_holds
248
+ post("holds/stop", {}, read_body: true)
249
+ end
250
+
232
251
  # How long the approval relay's calls wait for a reply: a parent's Stop
233
252
  # isn't held for READ_TIMEOUT by a child that doesn't answer.
234
253
  RELAY_TIMEOUT = 5
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.115.0
3
+ version: 0.117.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:
@@ -173,6 +173,21 @@ module Samagotchi
173
173
  # (Guardrails::ParentPolicy): hard = deny and plain answers; standard = + spend (Continue); soft = + permit (Allow
174
174
  # once). An invalid value is hard, the strictest.
175
175
  Entry.new(key: "guardrails.parent_answers", yaml_path: %w[guardrails parent_answers], type: :enum, default: "soft", expose: %i[config], enum_values: %w[hard standard soft], invalid: "hard", lifecycle: :next_tool_or_approval),
176
+ # System 1 (the cheap judgment model that answers the asks a user always allows). Client and settings; in `on`
177
+ # mode GuardrailWiring also reads rules, threshold and min_record for the auto-allow (docs/guardrails.md).
178
+ # off (the default) | audit (judge every ask, log it, never decide) | on. An unknown value warns and is off.
179
+ Entry.new(key: "guardrails.system_one.mode", yaml_path: %w[guardrails system_one mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :next_tool_or_approval),
180
+ # The hosts: entry serving /v1/systemone (Splash, or the hosted Jev API); unset: no judgment.
181
+ Entry.new(key: "guardrails.system_one.host", yaml_path: %w[guardrails system_one host], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
182
+ # The judgment model; unset: the host's first model, and no judgment if it names none.
183
+ Entry.new(key: "guardrails.system_one.model", yaml_path: %w[guardrails system_one model], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
184
+ # The rule ids System 1 may answer for ("a|b", or a YAML list, parsed like guardrails.small_models).
185
+ Entry.new(key: "guardrails.system_one.rules", yaml_path: %w[guardrails system_one rules], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
186
+ # The probability an auto-allow needs, and how many clean records a rule needs before it is trusted.
187
+ Entry.new(key: "guardrails.system_one.threshold", yaml_path: %w[guardrails system_one threshold], type: :float, default: 0.8, expose: %i[config], lifecycle: :next_tool_or_approval),
188
+ Entry.new(key: "guardrails.system_one.min_record", yaml_path: %w[guardrails system_one min_record], type: :integer, default: 5, expose: %i[config], lifecycle: :next_tool_or_approval),
189
+ # How long one judgment may take, in milliseconds; a judgment that doesn't arrive asks the user.
190
+ Entry.new(key: "guardrails.system_one.timeout_ms", yaml_path: %w[guardrails system_one timeout_ms], type: :integer, default: 2000, expose: %i[config], lifecycle: :next_tool_or_approval),
176
191
 
177
192
  Entry.new(key: "log.file", yaml_path: %w[log file], type: :string, default: nil, expose: %i[env config cli], lifecycle: :process_start),
178
193
  Entry.new(key: "log.disable", yaml_path: %w[log disable], type: :bool, default: false, expose: %i[env config cli], lifecycle: :process_start),
@@ -355,6 +370,8 @@ module Samagotchi
355
370
  # What a :bool value reads as (Config.coerce!), YAML 1.1's words and 1/0.
356
371
  BOOL_WORDS = { "1" => true, "true" => true, "yes" => true, "on" => true,
357
372
  "0" => false, "false" => false, "no" => false, "off" => false }.freeze
373
+ # The word an :enum takes for a boolean (an unquoted on/off is one in YAML 1.1).
374
+ BOOL_ENUM_WORDS = { true => "on", false => "off" }.freeze
358
375
 
359
376
  # Fast lookup maps
360
377
  BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
@@ -382,8 +399,10 @@ module Samagotchi
382
399
  # +raw+ as +entry+'s type, strictly: a value of another shape raises.
383
400
  # :string as given (a YAML list joined with |), :integer, :float,
384
401
  # :bool (true/yes/on/1, false/no/off/0/""), :enum in its canonical
385
- # case; nil for nil, and for a blank value of any other type than
386
- # :string and :bool. #coerce is its lenient twin (config.yml, env).
402
+ # case (an unquoted on/off, which YAML 1.1 reads as a boolean, is the
403
+ # word when the entry has it); nil for nil, and for a blank value of any
404
+ # other type than :string and :bool. #coerce is its lenient twin
405
+ # (config.yml, env).
387
406
  # @raise [SessionConfig::Error] "KEY takes WHAT, not RAW"
388
407
  def coerce!(entry, raw)
389
408
  return nil if raw.nil?
@@ -399,7 +418,7 @@ module Samagotchi
399
418
  when :integer then Integer(str, exception: false)
400
419
  when :float then Float(str, exception: false)
401
420
  when :bool then BOOL_WORDS[str.downcase]
402
- when :enum then entry.enum_values.find { |v| v.downcase == str.downcase }
421
+ when :enum then enum_word(entry, raw, str)
403
422
  else str
404
423
  end
405
424
  raise SessionConfig::Error, "#{entry.key} takes #{takes(entry)}, not #{raw.to_s.inspect}" if value.nil?
@@ -407,6 +426,18 @@ module Samagotchi
407
426
  value
408
427
  end
409
428
 
429
+ # A value of +entry+'s enum, as written. YAML 1.1 reads an unquoted
430
+ # on/off/yes/no as a boolean, so `mode: on` reaches the enum branch as
431
+ # true: it is the entry's "on"/"off" when the entry has that word, and
432
+ # still invalid when it doesn't.
433
+ def enum_word(entry, raw, str)
434
+ found = entry.enum_values.find { |v| v.downcase == str.downcase }
435
+ return found if found
436
+
437
+ word = BOOL_ENUM_WORDS[raw]
438
+ word && entry.enum_values.include?(word) ? word : nil
439
+ end
440
+
410
441
  # What +entry+'s type takes, as a refusal names it.
411
442
  def takes(entry)
412
443
  case entry.type
@@ -401,7 +401,12 @@ module Samagotchi
401
401
  cancelled: -> { !!active_cancel_controller&.cancelled? },
402
402
  # An approval of the model's call: :question_asked first (QuestionAsked).
403
403
  ask: ->(fields) { open_question(question_asked(QuestionAsked::KIND_APPROVAL, fields)) },
404
- notify: ->(message) { guardrail_notify(message) }
404
+ notify: ->(message) { guardrail_notify(message) },
405
+ # System 1 (experimental, audit phase): the judgment client is read
406
+ # from config on every ask, and the running turn's cancel controller
407
+ # closes a judgment that is no longer wanted.
408
+ system_one: -> { SystemOne::Client.from_config(host_registry: @host_registry) },
409
+ cancel_controller: -> { active_cancel_controller }
405
410
  )
406
411
  end
407
412
  private :build_guardrail_wiring
@@ -6,10 +6,13 @@ require_relative "config"
6
6
  require_relative "log"
7
7
  require_relative "guardrails"
8
8
  require_relative "guardrails/parent_approvals"
9
+ require_relative "guardrails/parent_policy"
10
+ require_relative "model_match"
9
11
  require_relative "memory_bundle/manifest"
10
12
  require_relative "version"
11
13
  require_relative "hooks"
12
14
  require_relative "edit_preview"
15
+ require_relative "system_one"
13
16
  require_relative "tool_activity"
14
17
  require_relative "turn_origin"
15
18
 
@@ -38,8 +41,11 @@ module Samagotchi
38
41
  # @param ask [#call] (fields) → the answer; the Engine's question flow
39
42
  # @param notify [#call] (message) → shows a guardrails line in every UI
40
43
  # (a bundle's rules kept because it needs a newer chi)
44
+ # @param system_one [#call] → SystemOne::Client or nil: the judgment client
45
+ # (nil while `guardrails.system_one.mode` is off or no host is set)
46
+ # @param cancel_controller [#call] → the running turn's CancellationController, nil
41
47
  def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {},
42
- notify: ->(_message) {})
48
+ notify: ->(_message) {}, system_one: -> {}, cancel_controller: -> {})
43
49
  @scratch = scratch
44
50
  @hooks_lookup = hooks
45
51
  @tools_lookup = tools
@@ -49,6 +55,8 @@ module Samagotchi
49
55
  @cancelled_lookup = cancelled
50
56
  @ask = ask
51
57
  @notify = notify
58
+ @system_one = system_one
59
+ @cancel_controller = cancel_controller
52
60
  # Bundle name → the rules last loaded from it whole, kept while the
53
61
  # installed version needs a newer chi; [name, version] said so once.
54
62
  @loaded_bundle_rules = {}
@@ -82,6 +90,7 @@ module Samagotchi
82
90
  def state_dir=(state_dir)
83
91
  @approvals = Guardrails::Approvals.new(dir: Guardrails::Approvals.dir_for(state_dir))
84
92
  @approvals = Guardrails::Approvals::InMemory.new(@approvals) if @scratch
93
+ @system_one_log = nil
85
94
  @protected = nil
86
95
  end
87
96
 
@@ -259,11 +268,166 @@ module Samagotchi
259
268
  # A call on chi's own config, hooks or guardrails is the user's to
260
269
  # allow, never a parent agent's (Guardrails::ParentPolicy).
261
270
  payload[:user_only] = Guardrails::ParentApprovals.protected_facts?(payload[:approval])
262
- Guardrails::Approval.settle(verdict, @ask.call(payload), payload[:approval][:scopes])
271
+ # System 1: the ask is judged before the user sees it, and in `on` mode
272
+ # one that passes every gate is allowed once without reaching them
273
+ # (docs/guardrails.md, "System 1").
274
+ judgment = system_one_judge(payload)
275
+ if judgment && judgment[:auto]
276
+ verdict.judgment = { id: judgment[:id], p: judgment[:p] }
277
+ verdict.settle!(:allow, decided_by: "system one")
278
+ verdict.scope = "once"
279
+ return verdict
280
+ end
281
+
282
+ answer = @ask.call(payload)
283
+ system_one_label(judgment, answer, payload)
284
+ Guardrails::Approval.settle(verdict, answer, payload[:approval][:scopes])
263
285
  end
264
286
 
265
287
  private
266
288
 
289
+ # The judgments log (SystemOne::Log), beside the approvals store; nil
290
+ # while there is no store dir.
291
+ def system_one_log
292
+ return nil if @approvals.nil?
293
+
294
+ @system_one_log ||= SystemOne::Log.new(dir: File.dirname(@approvals.path))
295
+ end
296
+
297
+ # System 1's judgment of an ask, written to the log before the user is
298
+ # asked, with the decision it led to: "auto" (it answered for the user),
299
+ # "asked" (on mode, the user decides) or "audit" (audit mode). A
300
+ # participant's review turn is never judged, and no client (mode off, no
301
+ # host) means no line at all. A judgment that fails is logged with p nil,
302
+ # so the failures are in the log too.
303
+ # @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
304
+ def system_one_judge(payload)
305
+ return nil if Steer.participant_author?(turn_author)
306
+
307
+ client = @system_one.call
308
+ log = system_one_log
309
+ return nil if client.nil? || log.nil?
310
+
311
+ approval = payload[:approval]
312
+ # The judgments log is read once for this ask: the state's examples and
313
+ # the record gate both use these entries. The judgment of the ask being
314
+ # judged is written after it, so the examples are always earlier asks.
315
+ entries = log.entries
316
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
317
+ answers = begin
318
+ client.ask(state: SystemOne::ApprovalQuestion.state(approval, session_title: @session_lookup.call&.title,
319
+ entries: entries),
320
+ questions: { SystemOne::ApprovalQuestion::ID => SystemOne::ApprovalQuestion.question },
321
+ cancel_controller: @cancel_controller.call)
322
+ rescue StandardError => e
323
+ # The client already turns every failure it knows into a nil; this is
324
+ # the unexpected one. Either way the ask goes to the user.
325
+ Samagotchi::Log.warn(:guardrails, "system_one_ask_failed", error: "#{e.class}: #{e.message}")
326
+ nil
327
+ end
328
+ latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
329
+ p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
330
+ auto = system_one_auto_allow?(client, approval, p, entries)
331
+ decision = system_one_decision(client, auto)
332
+
333
+ id = log.judgment(session_id: @session_lookup.call&.id,
334
+ rule: approval[:rule], source: approval[:source], tool: approval[:tool],
335
+ **SystemOne::ApprovalQuestion.call_fields(approval),
336
+ repo_name: approval[:repo_name],
337
+ p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
338
+ latency_ms: latency_ms, decision: decision, origin: "live")
339
+ # A judgment the log cannot hold is no judgment: the user is asked.
340
+ { id: id, p: p, auto: auto && !id.nil? }
341
+ rescue StandardError => e
342
+ # The approval never fails because of System 1.
343
+ Samagotchi::Log.warn(:guardrails, "system_one_judgment_failed", error: "#{e.class}: #{e.message}")
344
+ nil
345
+ end
346
+
347
+ # What the judgment line says it did: "auto" (System 1 answered for the
348
+ # user), "asked" (on mode, the user decides), "audit" (nothing decided).
349
+ def system_one_decision(client, auto)
350
+ return "auto" if auto
351
+
352
+ client.mode == "on" ? "asked" : "audit"
353
+ end
354
+
355
+ # Every gate an auto-allow must pass, in order: the mode, the rule the
356
+ # user opted in, the never-list and chi's own files, the probability, and
357
+ # the rule's record. Any one that fails leaves the ask to the user. The
358
+ # entries are the judgment log read once for this ask.
359
+ # @param entries [Array<Hash>] SystemOne::Log#entries
360
+ def system_one_auto_allow?(client, approval, p, entries)
361
+ return false unless client.mode == "on"
362
+
363
+ rule = approval[:rule].to_s
364
+ return false if rule.empty?
365
+ return false unless system_one_rules.include?(rule)
366
+ return false if SystemOne::SelfProtect.refuse?(approval)
367
+
368
+ threshold = Config.get("guardrails.system_one.threshold").to_f
369
+ return false if p.nil? || p.to_f < threshold
370
+
371
+ min_record = Config.get("guardrails.system_one.min_record").to_i
372
+ SystemOne::Record.new(entries, rule: rule).qualifies?(threshold: threshold, min_record: min_record)
373
+ rescue StandardError => e
374
+ Samagotchi::Log.warn(:guardrails, "system_one_gate_failed", error: "#{e.class}: #{e.message}")
375
+ false
376
+ end
377
+
378
+ # guardrails.system_one.rules: a "|"-separated string or a YAML list,
379
+ # read with the same grammar as guardrails.small_models.
380
+ def system_one_rules
381
+ ModelMatch.parse(Config.get("guardrails.system_one.rules"))
382
+ rescue StandardError
383
+ []
384
+ end
385
+
386
+ # The user's answer to that ask, as a label on the judgment: the scope
387
+ # they took, a denial, or none for an ask that was cancelled. A parent
388
+ # agent's answer is not the user's: its allow labels nothing, and its
389
+ # deny is a label only where the parent could have allowed — a parent
390
+ # that was not allowed to permit says nothing about what the user would
391
+ # have done (docs/guardrails.md, "System 1").
392
+ def system_one_label(judgment, answer, payload)
393
+ id = judgment && judgment[:id]
394
+ return nil if id.nil?
395
+
396
+ scopes = Array(payload[:approval][:scopes])
397
+ if !answer.is_a?(Samagotchi::QuestionAnswer)
398
+ label = "none"
399
+ elsif answer.parent_agent?
400
+ return nil if parent_allowed?(answer, scopes) || !parent_may_permit?(payload)
401
+
402
+ label = "deny"
403
+ else
404
+ index = answer.selected_indices.first
405
+ label = if index && index < scopes.size
406
+ scopes[index] == "once" ? "allow_once" : "allow_wide"
407
+ else
408
+ "deny"
409
+ end
410
+ end
411
+ system_one_log.label(id, label)
412
+ rescue StandardError => e
413
+ Samagotchi::Log.warn(:guardrails, "system_one_label_failed", error: "#{e.class}: #{e.message}")
414
+ nil
415
+ end
416
+
417
+ # Whether a parent's answer picked one of the offered scopes.
418
+ def parent_allowed?(answer, scopes)
419
+ index = answer.selected_indices.first
420
+ !index.nil? && index < scopes.size
421
+ end
422
+
423
+ # Whether this session's parent level lets a parent allow a call at all,
424
+ # and the ask is not one only the user may answer.
425
+ def parent_may_permit?(payload)
426
+ return false if payload[:user_only]
427
+
428
+ Guardrails::ParentPolicy.for_session(@session_lookup.call).allow?("permit")
429
+ end
430
+
267
431
  # The work tree top of the session's working_directory (the folder
268
432
  # itself outside a repo, or when it's gone); never Dir.pwd, which is
269
433
  # the waker's cwd for a worker whose folder is gone.
@@ -51,6 +51,9 @@ module Samagotchi
51
51
  paths: targets && !targets.paths.empty? ? targets.paths : nil,
52
52
  # No command or path (an MCP tool): its arguments, as the question.
53
53
  args: args_for_card(targets),
54
+ # A memory_write ask, in words: the entry, its scope, and for a
55
+ # removal the description its index line carries.
56
+ memory: memory_for_card(targets),
54
57
  cwd: targets&.cwd,
55
58
  repo_root: targets&.repo_root,
56
59
  # The repository's name (a worktree's main checkout's folder).
@@ -78,6 +81,23 @@ module Samagotchi
78
81
  text.empty? ? nil : text
79
82
  end
80
83
 
84
+ # A memory_write ask names an entry, not a file: which one, and in which
85
+ # scope. For a removal the ask also carries that entry's description from
86
+ # its scoped index line, which is what System 1 otherwise cannot see
87
+ # (docs/guardrails.md, "System 1"). nil for every other tool.
88
+ def memory_for_card(targets)
89
+ entry = targets&.memory
90
+ return nil if entry.nil?
91
+
92
+ facts = { name: entry[:name], scope: entry[:scope] }
93
+ if targets.memory_remove?
94
+ require_relative "../memory_bundle/index_updater"
95
+ description = MemoryBundle::IndexUpdater.description_for(entry[:scope], entry[:name])
96
+ facts[:description] = description unless description.to_s.strip.empty?
97
+ end
98
+ facts
99
+ end
100
+
81
101
  # Settle +verdict+ from open_question's result.
82
102
  # @param answer [QuestionAnswer, Hash, String] the answer, or {error:} (anything else is a cancel)
83
103
  # @param scopes [Array<String>] the offered scopes, in option order
@@ -74,6 +74,12 @@ module Samagotchi
74
74
  attr_reader :memory_overlay
75
75
  alias memory_overlay? memory_overlay
76
76
 
77
+ # @return [Hash, nil] a memory_write call's entry and its scope,
78
+ # {name:, scope:}: which memory the ask is about, in words rather than
79
+ # as the file it names (#paths); nil for another tool or a call the
80
+ # tool would refuse
81
+ attr_reader :memory
82
+
77
83
  # @param call [Hash] the parsed tool call
78
84
  # @param context [Context]
79
85
  # @param model_key [String, nil] for a memory_write model overlay, and
@@ -114,9 +120,10 @@ module Samagotchi
114
120
  end
115
121
  memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
116
122
  memory_overlay = tool == "memory_write" && !memory_remove && call[:current_model_only].to_s.strip.downcase == "true"
123
+ memory = tool == "memory_write" ? memory_facts(call) : nil
117
124
  new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
118
125
  repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
119
- memory_overlay: memory_overlay,
126
+ memory_overlay: memory_overlay, memory: memory,
120
127
  model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
121
128
  chi_dirs: -> { chi_dirs(context, base) })
122
129
  end
@@ -224,14 +231,28 @@ module Samagotchi
224
231
  nil
225
232
  end
226
233
 
234
+ # The memory a memory_write call is about, in the words the ask needs:
235
+ # the entry and its scope, not its file; nil for a call the tool would
236
+ # refuse (no entry, no scope, a scope chi has no folder for).
237
+ def self.memory_facts(call)
238
+ entry = call[:path].to_s.strip
239
+ scope = Tools::MemoryRead.normalize_scope(call[:scope])
240
+ return nil if entry.empty? || scope.nil?
241
+
242
+ { name: entry, scope: scope }
243
+ rescue StandardError
244
+ nil
245
+ end
246
+
227
247
  # @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
228
248
  def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, acts_as: nil, label: nil,
229
249
  model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil, memory_remove: false,
230
- memory_overlay: false)
250
+ memory_overlay: false, memory: nil)
231
251
  @acts_as = acts_as
232
252
  @label = label
233
253
  @memory_remove = memory_remove
234
254
  @memory_overlay = memory_overlay
255
+ @memory = memory
235
256
  @repo = repo
236
257
  @chi_dirs = chi_dirs
237
258
  @session_root = session_root || repo_root || cwd