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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module SystemOne
5
+ # The question types the judgment contract has.
6
+ QUESTION_TYPES = %i[noul choice score].freeze
7
+
8
+ # The questions of one ask, as an Array of Question: a Hash of
9
+ # id => Question (or of wire fields), or an Array of Questions.
10
+ def self.normalize_questions(questions)
11
+ case questions
12
+ when Hash then questions.map { |id, value| Question.coerce(id, value) }
13
+ when Array then questions.map { |value| value.is_a?(Question) ? value : Question.coerce(value.to_s, value) }
14
+ else raise ArgumentError, "questions must be a hash of id => question, got #{questions.class}"
15
+ end
16
+ end
17
+
18
+ # One question of a System One request: id, type (:noul a yes/no
19
+ # judgment, :choice one of the named criteria, :score one of the listed
20
+ # levels), the instruction the model reads, and the criteria a :choice
21
+ # (name => description or nil) or :score (a list of level names) needs.
22
+ #
23
+ # Question.new(id: "git-push", type: "noul", instructions: "Would the user allow this git push?")
24
+ Question = Data.define(:id, :type, :instructions, :criteria) do
25
+ def initialize(id:, type:, instructions:, criteria: nil)
26
+ id = id.to_s.strip
27
+ raise ArgumentError, "SystemOne::Question needs an id" if id.empty?
28
+
29
+ type = type.to_s.strip.downcase.to_sym
30
+ unless QUESTION_TYPES.include?(type)
31
+ raise ArgumentError, "question #{id}: type must be one of #{QUESTION_TYPES.join(", ")}, got #{type.inspect}"
32
+ end
33
+
34
+ instructions = instructions.to_s
35
+ raise ArgumentError, "question #{id}: instructions are required" if instructions.strip.empty?
36
+
37
+ super(id: id, type: type, instructions: instructions, criteria: criteria_for(id, type, criteria))
38
+ end
39
+
40
+ # The entry of the request's questions map.
41
+ def to_wire
42
+ wire = { "type" => type.to_s, "instructions" => instructions }
43
+ wire["criteria"] = criteria unless criteria.nil?
44
+ wire
45
+ end
46
+
47
+ # A Question as given, or a {type:, instructions:, criteria:} hash
48
+ # (string or symbol keys) — what a caller has on hand.
49
+ def self.coerce(id, value)
50
+ return value if value.is_a?(Question)
51
+
52
+ unless value.is_a?(Hash)
53
+ raise ArgumentError, "question #{id}: expected a SystemOne::Question or a hash, got #{value.class}"
54
+ end
55
+
56
+ field = ->(name) { value.key?(name) ? value[name] : value[name.to_sym] }
57
+ new(id: id, type: field.call("type"), instructions: field.call("instructions"), criteria: field.call("criteria"))
58
+ end
59
+
60
+ private
61
+
62
+ def criteria_for(id, type, criteria)
63
+ case type
64
+ when :noul
65
+ raise ArgumentError, "question #{id}: an noul question takes no criteria" unless criteria.nil?
66
+
67
+ nil
68
+ when :choice
69
+ unless criteria.is_a?(Hash) && !criteria.empty? &&
70
+ criteria.all? { |name, description| !name.to_s.strip.empty? && (description.nil? || description.is_a?(String)) }
71
+ raise ArgumentError, "question #{id}: a choice question takes criteria as {name => description or nil}"
72
+ end
73
+
74
+ criteria.to_h { |name, description| [name.to_s, description] }
75
+ else
76
+ unless criteria.is_a?(Array) && !criteria.empty? &&
77
+ criteria.all? { |level| level.is_a?(String) && !level.strip.empty? }
78
+ raise ArgumentError, "question #{id}: a score question takes criteria as a list of level names"
79
+ end
80
+
81
+ criteria.map(&:to_s)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module SystemOne
5
+ # One rule's record: what System 1 has judged for it and what the user
6
+ # did about those asks. Built from `SystemOne::Log#entries` (judgments
7
+ # joined with their last label), filtered to one rule. The gate before
8
+ # System 1 answers for the user: enough clean allows at the threshold,
9
+ # and no false allow among the rule's last 20 labelled asks
10
+ # (docs/guardrails.md, "System 1").
11
+ #
12
+ # An ask with no label, no probability, or a `none` label counts for
13
+ # neither side: only a judgment the user actually answered teaches
14
+ # anything.
15
+ class Record
16
+ # The labels that mean the user let the call through.
17
+ ALLOW_LABELS = %w[allow_once allow_wide].freeze
18
+ # A judgment at the threshold that the user did not allow: System 1
19
+ # was wrong, and the rule's record is not clean.
20
+ FALSE_ALLOW_LABELS = %w[deny should_have_asked].freeze
21
+ # How many of the rule's labelled asks the false-allow check looks at.
22
+ WINDOW = 20
23
+
24
+ attr_reader :rule, :entries
25
+
26
+ # @param entries [Array<Hash>] SystemOne::Log#entries, oldest first
27
+ # @param rule [String] the rule the record is for
28
+ def initialize(entries, rule:)
29
+ @rule = rule.to_s
30
+ @entries = Array(entries).select { |entry| entry["rule"].to_s == @rule }
31
+ end
32
+
33
+ # @param threshold [Float] the probability an auto-allow needs
34
+ # @param min_record [Integer] how many clean allows the rule needs
35
+ # @return [Boolean] whether System 1 may answer this rule for the user
36
+ def qualifies?(threshold:, min_record:)
37
+ clean = high(threshold).count { |entry| ALLOW_LABELS.include?(entry["answer"].to_s) }
38
+ return false if clean < min_record.to_i
39
+
40
+ !false_allow?(labeled.last(WINDOW), threshold)
41
+ end
42
+
43
+ # The counts a `/guardrails` listing shows: how many asks the rule has,
44
+ # how many were answered, and how each was answered.
45
+ # @return [Hash]
46
+ def summary
47
+ labeled_entries = labeled
48
+ {
49
+ rule: rule,
50
+ asks: entries.size,
51
+ labelled: labeled_entries.size,
52
+ allow_once: count("allow_once"),
53
+ allow_wide: count("allow_wide"),
54
+ deny: count("deny"),
55
+ should_have_asked: count("should_have_asked"),
56
+ none: count("none"),
57
+ no_label: entries.size - labeled_entries.size,
58
+ no_p: entries.count { |entry| entry["p"].nil? }
59
+ }
60
+ end
61
+
62
+ private
63
+
64
+ # The asks of the rule the user answered.
65
+ def labeled
66
+ entries.reject { |entry| entry["answer"].nil? }
67
+ end
68
+
69
+ # The asks judged at or above +threshold+.
70
+ def high(threshold)
71
+ entries.select { |entry| entry["p"] && entry["p"].to_f >= threshold.to_f }
72
+ end
73
+
74
+ def false_allow?(list, threshold)
75
+ list.any? { |entry| entry["p"] && entry["p"].to_f >= threshold.to_f && FALSE_ALLOW_LABELS.include?(entry["answer"].to_s) }
76
+ end
77
+
78
+ def count(label)
79
+ entries.count { |entry| entry["answer"].to_s == label }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../paths"
4
+ require_relative "../config"
5
+ require_relative "../memory_paths"
6
+ require_relative "../hooks/loader"
7
+ require_relative "../guardrails/protected_paths"
8
+
9
+ module Samagotchi
10
+ module SystemOne
11
+ # The calls System 1 may never answer for, whatever the rule's record
12
+ # says: one that changes chi itself. An auto-allow on config.yml, the
13
+ # hooks, the installed bundles or the guardrails store would let the
14
+ # agent widen its own guardrails without the user (docs/guardrails.md,
15
+ # "System 1"). The memories dir is deliberately not one of them:
16
+ # reading and appending a memory file stays eligible.
17
+ #
18
+ # Pure: it only reads the paths as this process's environment names them,
19
+ # and answers on the approval's own facts (Guardrails::Approval.payload's
20
+ # approval:), the same shape ParentApprovals.protected_facts? takes.
21
+ module SelfProtect
22
+ # The rules that let one agent answer chi's questions: an auto-allow
23
+ # there is one agent approving another's, so the user decides them even
24
+ # when they are listed in `guardrails.system_one.rules`.
25
+ NEVER_AUTO = %w[chi-answer-piped chi-answer-http chi-broadcast].freeze
26
+ # chi's config file, however the user names it in config.yml.
27
+ CONFIG_FILE = /\Aconfig[\w-]*\.ya?ml\z/i
28
+ # The dirs as a shell command may spell them when the settings point
29
+ # elsewhere: chi's default config dir and default state dir. A path
30
+ # into the memories dir is not a control file.
31
+ DEFAULT_CONFIG_TEXT = %r{\B~/\.config/samagotchi(?!/memories)}
32
+ DEFAULT_STATE_TEXT = %r{\B~/\.local/state/samagotchi}
33
+
34
+ module_function
35
+
36
+ # @param approval [Hash, nil] the ask's approval facts (rule, tool,
37
+ # paths, command)
38
+ # @return [Boolean] whether System 1 must not answer this ask
39
+ def refuse?(approval)
40
+ return false unless approval.is_a?(Hash)
41
+ return true if NEVER_AUTO.include?(fetch(approval, :rule).to_s)
42
+
43
+ dirs = control_dirs
44
+ paths = Array(fetch(approval, :paths)).map(&:to_s).reject(&:empty?)
45
+ return true if paths.any? { |path| control_path?(path, dirs) }
46
+
47
+ command = fetch(approval, :command).to_s
48
+ !command.empty? && control_text?(command, dirs)
49
+ end
50
+
51
+ # The dirs whose files are chi's own: its hooks, its installed bundles,
52
+ # the guardrails store (approvals.json and judgments.jsonl).
53
+ # @return [Array<String>]
54
+ def control_dirs
55
+ [Hooks::Loader.hooks_dir, MemoryPaths.bundles_dir, File.join(Paths.state_dir, "guardrails")]
56
+ .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?)
57
+ end
58
+
59
+ def control_path?(path, dirs)
60
+ real = Guardrails::ProtectedPaths.real(path)
61
+ return true if dirs.any? { |dir| Guardrails::ProtectedPaths.within?(real, dir) }
62
+ return true if config_file?(real)
63
+
64
+ control_text?(path, dirs)
65
+ end
66
+
67
+ # config.yml in chi's config dir — not the memories beside it.
68
+ def config_file?(path)
69
+ Guardrails::ProtectedPaths.within?(path, ConfigFile.config_dir) && File.basename(path).match?(CONFIG_FILE)
70
+ end
71
+
72
+ # A command or path naming one of those dirs, in the spelling this
73
+ # process uses or the default `~/.config/samagotchi` one.
74
+ def control_text?(text, dirs)
75
+ dirs.any? { |dir| text.include?(dir) || text.include?(File.expand_path(dir)) } ||
76
+ text.match?(config_text) || text.match?(DEFAULT_CONFIG_TEXT) || text.match?(DEFAULT_STATE_TEXT)
77
+ end
78
+
79
+ # chi's config dir as text — everything under it except the memories,
80
+ # which the model works with.
81
+ def config_text
82
+ Regexp.new("#{Regexp.escape(ConfigFile.config_dir)}(?![/]memories)")
83
+ end
84
+
85
+ def fetch(hash, key)
86
+ hash.key?(key) ? hash[key] : hash[key.to_s]
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module SystemOne
5
+ # The few-shot examples an ask carries beside its own facts: the earlier
6
+ # asks the user actually answered, the most alike first — this rule in
7
+ # this repository, then this rule, then whatever shares the most words
8
+ # with this call. No embeddings: a word overlap, with the newer ask first
9
+ # when two tie, so the same log always gives the same five
10
+ # (docs/guardrails.md, "System 1").
11
+ module Similar
12
+ # How many examples the state can carry.
13
+ MAX = 5
14
+ # A "token" of a call, for the overlap: a word, lower-cased.
15
+ TOKEN = /[a-z0-9_]+/
16
+
17
+ module_function
18
+
19
+ # @param entries [Array<Hash>] SystemOne::Log#entries, oldest first —
20
+ # the same read the record gate uses
21
+ # @param rule [String, nil] the rule asking now
22
+ # @param repo_name [String, nil] the repository this ask is in
23
+ # @param call_text [String] the call to approve (ApprovalQuestion.call_text)
24
+ # @return [Array<Hash>] at most MAX examples, most alike first; [] when
25
+ # the log has nothing labelled to offer
26
+ def select(entries:, rule:, repo_name:, call_text:)
27
+ words = tokens(call_text)
28
+ ranked = Array(entries).each_with_index.select { |entry, _index| labelled?(entry) }
29
+ ranked = ranked.sort_by { |entry, index| [tier(entry, rule, repo_name), -overlap(entry["call"], words), -index] }
30
+ ranked.first(MAX).map { |entry, _index| example(entry) }
31
+ end
32
+
33
+ # A judgment teaches something only when the user answered it: no label
34
+ # or a `none` says nothing, and a line with no rule cannot be matched.
35
+ def labelled?(entry)
36
+ answer = entry["answer"].to_s
37
+ !answer.empty? && answer != "none" && !entry["rule"].to_s.strip.empty?
38
+ end
39
+
40
+ # 0: this rule in this repository. 1: this rule anywhere. 2: any other
41
+ # ask the user answered.
42
+ def tier(entry, rule, repo_name)
43
+ same_rule = entry["rule"].to_s == rule.to_s
44
+ return 0 if same_rule && !repo_name.to_s.strip.empty? && entry["repo_name"].to_s == repo_name.to_s
45
+ return 1 if same_rule
46
+
47
+ 2
48
+ end
49
+
50
+ # How many of this call's words the logged call shares.
51
+ def overlap(call, words)
52
+ return 0 if words.empty?
53
+
54
+ (tokens(call) & words).size
55
+ end
56
+
57
+ def tokens(text)
58
+ text.to_s.downcase.scan(TOKEN).uniq
59
+ end
60
+
61
+ # What the judge is shown about an ask: how it was judged and what the
62
+ # user did, as the log holds it.
63
+ def example(entry)
64
+ { "rule" => entry["rule"].to_s, "repo" => entry["repo_name"], "call" => entry["call"],
65
+ "answer" => entry["answer"].to_s }.compact
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "system_one/errors"
4
+ require_relative "system_one/question"
5
+ require_relative "system_one/answer"
6
+ require_relative "system_one/fake_backend"
7
+ require_relative "system_one/http_backend"
8
+ require_relative "system_one/client"
9
+ require_relative "system_one/log"
10
+ require_relative "system_one/record"
11
+ require_relative "system_one/similar"
12
+ require_relative "system_one/self_protect"
13
+ require_relative "system_one/approval_question"
14
+
15
+ module Samagotchi
16
+ # System 1: the cheap judgment model that will, one phase at a time,
17
+ # answer the guardrail asks a user always allows. P1 is the client and its
18
+ # settings; P2a judges every ask a user is about to see and logs the
19
+ # judgment and their answer (SystemOne::Log); P3a is `on` mode: a judgment
20
+ # that passes every gate allows the call once, without asking
21
+ # (SystemOne::Record, SystemOne::SelfProtect). The line a UI shows for an
22
+ # auto-allow is P3b (docs/guardrails.md, "System 1").
23
+ module SystemOne
24
+ end
25
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  require_relative "config"
4
6
  require_relative "log"
5
7
  require_relative "tools/task_runtime"
@@ -182,19 +184,35 @@ module Samagotchi
182
184
  # @return [Array<Entry>]
183
185
  def holding = entries.reject { |entry| entry.note_only || entry.failures >= MAX_FAILURES }
184
186
 
185
- # The #holding tasks still running ({id:, command:}), for /exit
186
- # (WorkerIdleExit's watched_running): one finished meanwhile is noted
187
- # as the worker leaves. One `ps` per task.
188
- # @return [Array<Hash>]
187
+ # A watched task still running, as /exit and the exit ask see it:
188
+ # +age_seconds+, the seconds since it started (nil when its record has
189
+ # no start time).
190
+ Running = Data.define(:id, :command, :age_seconds)
191
+
192
+ # The #holding tasks still running, for /exit (WorkerIdleExit's
193
+ # watched_running): one finished meanwhile is noted as the worker leaves.
194
+ # One `ps` per task.
195
+ # @return [Array<Running>]
189
196
  def running
190
197
  holding.filter_map do |entry|
191
198
  record, = Tools::TaskRuntime.get_record(entry.id)
192
- { id: entry.id, command: record.command.to_s } if record&.running?
199
+ next unless record&.running?
200
+
201
+ Running.new(id: entry.id, command: record.command.to_s, age_seconds: age_seconds(record))
193
202
  end
194
203
  end
195
204
 
196
205
  private
197
206
 
207
+ # Seconds since the task started, from its record's iso8601 +started_at+;
208
+ # nil when that can't be read.
209
+ def age_seconds(record)
210
+ started = Time.parse(record.started_at.to_s)
211
+ [(Time.now - started).round, 0].max
212
+ rescue ArgumentError, TypeError
213
+ nil
214
+ end
215
+
198
216
  # Removes +entry+; nil (a filter_map's skip), or with +kept+ whether it
199
217
  # was there. +log+: why a task leaves without a note, at +level+ (debug
200
218
  # by default), with +error+ when there is one.
@@ -57,17 +57,35 @@ module Samagotchi
57
57
  # An exit the worker agreed to in an empty session. Said before it has
58
58
  # left: it checks again once it lets go, and a note or prompt arriving
59
59
  # first keeps the session (SessionManager.discard_left_session).
60
- EMPTY_EXIT = "Detached; the session is empty: it will be discarded if nothing arrives before its worker stops."
60
+ EMPTY_WORDS = "the session is empty: it will be discarded if nothing arrives before its worker stops."
61
+ EMPTY_EXIT = "Detached; #{EMPTY_WORDS}"
61
62
  # Why the worker stayed up after /exit, by the reason it gave.
62
63
  HELD_REASONS = {
63
64
  "turn_running" => "a turn is running", "input_queued" => "prompts are queued",
64
65
  "continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
65
66
  "reminders" => "reminders are set", "starting" => "the worker is still starting",
66
- "background_tasks" => "background tasks it started are still running"
67
+ "background_tasks" => "background tasks it started are still running",
68
+ "delegates" => "delegates of this session are still running"
67
69
  }.freeze
68
70
  # How to end a background task that holds the worker up (an older
69
71
  # worker's 409 has no hint of its own).
70
72
  TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
73
+ # The /exit ask, when every hold is one the user can stop: what still
74
+ # runs, then the three answers. Each kind, singular and plural.
75
+ EXIT_HOLD_WORDS = {
76
+ "reminders" => %w[reminder reminders],
77
+ "background_tasks" => ["background task", "background tasks"],
78
+ "delegates" => %w[delegate delegates]
79
+ }.freeze
80
+ # The 200's +stopped+ keys, by the kind the ask lists them under.
81
+ STOPPED_KEYS = { "reminders" => "reminders", "tasks" => "background_tasks", "delegates" => "delegates" }.freeze
82
+ EXIT_ASK_OPTIONS = ["Keep running in the background (detach)", "Stop them and exit", "Back to the session"].freeze
83
+ # How the ask reads an answer.
84
+ EXIT_ANSWERS = {
85
+ "1" => :keep, "keep" => :keep, "detach" => :keep, "background" => :keep,
86
+ "2" => :stop, "stop" => :stop, "exit" => :stop,
87
+ "3" => :back, "back" => :back, "stay" => :back
88
+ }.freeze
71
89
  # How much of the last answer a join shows.
72
90
  JOIN_ANSWER_LINES = 12
73
91
  JOIN_ANSWER_CHARS = 1200
@@ -175,6 +193,8 @@ module Samagotchi
175
193
  @own_failed = false
176
194
  # A continue offer is pending: the prompt asks for the answer.
177
195
  @continue_offer = nil
196
+ # The /exit ask is open: the next line is its answer (#answer_exit_ask).
197
+ @exit_ask = nil
178
198
  # The status row: the worker's model, ctx, the session's memories.
179
199
  @status = StatusRow.new(screen)
180
200
  # Participants' names by author ("p:<id>"), from the join's state
@@ -470,6 +490,14 @@ module Samagotchi
470
490
 
471
491
  # @return [Symbol, nil] :detach to end the loop
472
492
  def submit(line)
493
+ if @exit_ask
494
+ # The exit ask is open: this line answers it. Ctrl-D and /detach
495
+ # detach without stopping anything, the way its 1 does.
496
+ return detach(kept_line(@exit_ask)) if line.nil? || command_registry.lookup_local(line.strip)&.id == :detach
497
+
498
+ return answer_exit_ask(line)
499
+ end
500
+
473
501
  return detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if line.nil?
474
502
 
475
503
  text = line.strip
@@ -599,14 +627,104 @@ module Samagotchi
599
627
 
600
628
  # Detach, and ask the worker to exit now. The worker decides: it stays
601
629
  # up while anything still needs it (a turn, queued input, a continue
602
- # offer, another UI, reminders), and the line says which.
603
- # @return [Symbol] :detach
630
+ # offer, another UI, reminders), and the line says which. When the only
631
+ # holds are ones the user can stop, it asks what to do with them instead
632
+ # of detaching (#answer_exit_ask).
633
+ # @return [Symbol, nil] :detach, or nil to stay at the prompt
604
634
  def exit_worker
605
- detach(exit_line(@client.request_exit(client_id: @client_id)))
635
+ reply = @client.request_exit(client_id: @client_id)
636
+ holds = reply.status == 409 ? reply.json&.fetch("holds", nil) : nil
637
+ return detach(exit_line(reply)) unless holds.is_a?(Array) && !holds.empty?
638
+
639
+ @exit_ask = holds
640
+ @screen.commit(ask_lines(holds))
641
+ nil
606
642
  rescue SystemCallError, IOError => e
607
643
  detach(exit_failed_line(e.message))
608
644
  end
609
645
 
646
+ # The ask: what still runs, then the three answers.
647
+ def ask_lines(holds)
648
+ lines = holds.map do |hold|
649
+ items = Array(hold["items"])
650
+ singular, plural = hold_words(hold["kind"])
651
+ " • #{items.size} #{items.size == 1 ? singular : plural}: #{items.map { |item| item_word(item, hold["kind"]) }.join(", ")}"
652
+ end
653
+ lines += EXIT_ASK_OPTIONS.each_with_index.map { |option, index| " #{index + 1}. #{option}" }
654
+ (["This session still has:"] + lines).join("\n")
655
+ end
656
+
657
+ # One entry of a hold: the command or name, with its detail in
658
+ # brackets; a delegate leads with its short id and its title in quotes.
659
+ def item_word(item, kind)
660
+ label = item["label"].to_s
661
+ label = "#{item["id"].to_s[0, 8]} \"#{label}\"" if kind == "delegates"
662
+ detail = item["detail"].to_s
663
+ detail.empty? ? label : "#{label} (#{detail})"
664
+ end
665
+
666
+ # The answer to the exit ask.
667
+ # @return [Symbol, nil] :detach for 1 and 2, nil for 3 or a line the
668
+ # ask doesn't understand
669
+ def answer_exit_ask(line)
670
+ choice = EXIT_ANSWERS[line.to_s.strip.downcase]
671
+ unless choice
672
+ @screen.commit("Say 1 (keep running), 2 (stop them and exit) or 3 (back to the session).")
673
+ return nil
674
+ end
675
+
676
+ holds = @exit_ask
677
+ @exit_ask = nil
678
+ case choice
679
+ when :keep then detach(kept_line(holds))
680
+ when :stop then stop_and_exit
681
+ else
682
+ @screen.commit("(nothing stopped; the worker keeps running)")
683
+ nil
684
+ end
685
+ end
686
+
687
+ # 1: detach, everything left running.
688
+ def kept_line(holds)
689
+ "Detached; the session keeps running (#{hold_words_list(holds)}). " \
690
+ "Re-attach with: #{via.attach(@client.session_id)}"
691
+ end
692
+
693
+ # 2: ask the worker to stop them all and exit.
694
+ # @return [Symbol] :detach
695
+ def stop_and_exit
696
+ reply = @client.request_exit(client_id: @client_id, stop: true)
697
+ detach(exit_line(reply))
698
+ rescue SystemCallError, IOError => e
699
+ detach(exit_failed_line(e.message))
700
+ end
701
+
702
+ # "2 reminders, 1 background task, 1 delegate" from the ask's holds.
703
+ def hold_words_list(holds)
704
+ holds.map do |hold|
705
+ items = Array(hold["items"])
706
+ singular, plural = hold_words(hold["kind"])
707
+ "#{items.size} #{items.size == 1 ? singular : plural}"
708
+ end.join(", ")
709
+ end
710
+
711
+ def hold_words(kind) = EXIT_HOLD_WORDS.fetch(kind.to_s, [kind.to_s, kind.to_s])
712
+
713
+ # What a stop-all exit stopped: "stopped 2 reminders (api_health, git
714
+ # push), 1 background task (rails s)"; nil when it stopped nothing.
715
+ def stopped_words(stopped)
716
+ return nil unless stopped.is_a?(Hash)
717
+
718
+ parts = STOPPED_KEYS.filter_map do |key, kind|
719
+ names = Array(stopped[key])
720
+ next if names.empty?
721
+
722
+ singular, plural = hold_words(kind)
723
+ "#{names.size} #{names.size == 1 ? singular : plural} (#{names.join(", ")})"
724
+ end
725
+ parts.empty? ? nil : "stopped #{parts.join(", ")}"
726
+ end
727
+
610
728
  # /exit --delete: the worker exits as for /exit, then the session is
611
729
  # deleted. When the worker stays up (another UI, queued input, ...),
612
730
  # nothing is deleted.
@@ -656,7 +774,9 @@ module Samagotchi
656
774
  # (SessionManager.archive_session). An empty session: the worker
657
775
  # deletes it as it leaves.
658
776
  def exit_and_archive
659
- reply = @client.request_exit(client_id: @client_id)
777
+ # /archive can't ask about what still runs: a child the archive will
778
+ # stop is not worth a question here.
779
+ reply = @client.request_exit(client_id: @client_id, ask: false)
660
780
  id = @client.session_id
661
781
  return detach(EMPTY_EXIT) if reply.status == 200 && discards?(reply)
662
782
  return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
@@ -676,9 +796,11 @@ module Samagotchi
676
796
  error = reply.json&.fetch("error", nil)
677
797
  case reply.status
678
798
  when 200
679
- return EMPTY_EXIT if discards?(reply)
799
+ stopped = stopped_words(reply.json&.fetch("stopped", nil))
800
+ return EMPTY_EXIT if discards?(reply) && stopped.nil?
801
+ return "Detached; #{stopped}. #{EMPTY_WORDS.capitalize}" if discards?(reply)
680
802
 
681
- "Detached; the session's worker is stopping. Resume with: #{via.resume(id)}"
803
+ "Detached; #{stopped || "the session's worker is stopping"}. Resume with: #{via.resume(id)}"
682
804
  when 409
683
805
  reason = reply.json&.fetch("reason", nil).to_s
684
806
  "Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
@@ -1550,7 +1672,8 @@ module Samagotchi
1550
1672
  record = join_record(records, part)
1551
1673
  duration = record&.dig("duration_ms")
1552
1674
  @screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
1553
- saved_status: record&.dig("status")))
1675
+ saved_status: record&.dig("status"),
1676
+ auto_allowed: record&.dig("guardrail")))
1554
1677
  end
1555
1678
  end
1556
1679
  end
@@ -1594,8 +1717,10 @@ module Samagotchi
1594
1717
  # A saved call's row; "no result" for a call the turn never answered
1595
1718
  # (it ended first). Its status is the saved record's, as live and the
1596
1719
  # web's reload (a guardrail's "blocked" is no error), else read from
1597
- # its result (a guardrail's deny text reads blocked too).
1598
- def join_tool_line(part, duration_ms: nil, saved_status: nil)
1720
+ # its result (a guardrail's deny text reads blocked too). A record that
1721
+ # System 1 allowed once (+auto_allowed+, its saved {rule, p}) says so,
1722
+ # as the live row does.
1723
+ def join_tool_line(part, duration_ms: nil, saved_status: nil, auto_allowed: nil)
1599
1724
  output = part[:output]
1600
1725
  command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
1601
1726
  text = ToolActivity.saved_result_text(output) unless output.nil?
@@ -1612,7 +1737,15 @@ module Samagotchi
1612
1737
  params: part[:params], status: status,
1613
1738
  description: ToolView.description_title(part[:view] || {}) }
1614
1739
  "#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
1615
- "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
1740
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}" \
1741
+ "#{auto_allowed_suffix(auto_allowed)}"
1742
+ end
1743
+
1744
+ # The live row's System 1 suffix for a saved record's {rule, p}.
1745
+ def auto_allowed_suffix(saved)
1746
+ return "" unless saved.is_a?(Hash)
1747
+
1748
+ format_tool_guardrail_suffix({ decided_by: "system one", p: saved["p"] || saved[:p] })
1616
1749
  end
1617
1750
 
1618
1751
  # The context notes that came since the last prompt (all of them in a
@@ -221,6 +221,7 @@ module Samagotchi
221
221
  line += @view.format_tool_image_suffix(images) if images&.any?
222
222
  line += @view.format_tool_diff_suffix(diff) if diff
223
223
  line += @view.format_tool_cut_suffix(output_truncated) if output_truncated
224
+ line += @view.format_tool_guardrail_suffix(activity[:guardrail]) if activity[:guardrail]
224
225
  @view.print_line(line)
225
226
  end
226
227
 
@@ -202,6 +202,20 @@ module Samagotchi
202
202
  output_truncated ? " #{paint("[cut]", 90)}" : ""
203
203
  end
204
204
 
205
+ # A call System 1 allowed once instead of asking: " · auto-allowed
206
+ # (System 1, p=0.97)", yellow, from the activity's guardrail (symbol or
207
+ # string keys: live, snapshot). Any other guardrail data adds nothing.
208
+ def format_tool_guardrail_suffix(guardrail)
209
+ return "" unless guardrail.is_a?(Hash)
210
+
211
+ get = ->(key) { guardrail[key] || guardrail[key.to_s] }
212
+ return "" unless get.call(:decided_by) == "system one"
213
+
214
+ p_value = get.call(:p)
215
+ detail = p_value.nil? ? "System 1" : format("System 1, p=%.2f", p_value.to_f)
216
+ " #{paint("\u00b7 auto-allowed (#{detail})", 33)}"
217
+ end
218
+
205
219
  # Prompt labels by the sender's client_id: a whole id, or a prefix
206
220
  # (ending in ":") it starts with (turn_events.js CLIENT_LABELS;
207
221
  # spec/shared/labels_matrix.json).