samagotchi 0.121.1 → 0.123.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/bin/chi +4 -1
  4. data/docs/architecture.md +19 -7
  5. data/docs/cli.md +32 -1
  6. data/docs/configuration.md +55 -5
  7. data/docs/guardrails.md +119 -7
  8. data/docs/internals/engine.md +11 -2
  9. data/docs/internals/participants.md +76 -20
  10. data/docs/internals/prompt-caching.md +19 -7
  11. data/docs/internals/web-push.md +7 -3
  12. data/docs/memory.md +33 -0
  13. data/docs/plugins.md +3 -2
  14. data/docs/sessions.md +14 -4
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  16. data/lib/samagotchi/bridge.rb +8 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +5 -1
  18. data/lib/samagotchi/bridge_client/stream_open.rb +8 -2
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  21. data/lib/samagotchi/config.rb +11 -0
  22. data/lib/samagotchi/delivery.rb +7 -0
  23. data/lib/samagotchi/engine.rb +289 -12
  24. data/lib/samagotchi/event_fanout.rb +0 -1
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +46 -12
  27. data/lib/samagotchi/guardrails/approval.rb +42 -4
  28. data/lib/samagotchi/guardrails/gate.rb +1 -1
  29. data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
  30. data/lib/samagotchi/guardrails/pair_command.rb +365 -0
  31. data/lib/samagotchi/guardrails/review_paths.rb +41 -7
  32. data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
  33. data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
  34. data/lib/samagotchi/guardrails/verdict.rb +7 -0
  35. data/lib/samagotchi/guardrails.rb +1 -0
  36. data/lib/samagotchi/guardrails_command.rb +6 -1
  37. data/lib/samagotchi/kernel_loop.rb +20 -0
  38. data/lib/samagotchi/llm/chat_loop.rb +4 -0
  39. data/lib/samagotchi/log_line.rb +1 -1
  40. data/lib/samagotchi/memory_orphans.rb +11 -0
  41. data/lib/samagotchi/participant_mode.rb +85 -0
  42. data/lib/samagotchi/participants.rb +7 -5
  43. data/lib/samagotchi/plugin/api.rb +7 -4
  44. data/lib/samagotchi/process_group.rb +13 -0
  45. data/lib/samagotchi/review_apply.rb +10 -2
  46. data/lib/samagotchi/review_start.rb +42 -10
  47. data/lib/samagotchi/run_options.rb +1 -0
  48. data/lib/samagotchi/session.rb +14 -1
  49. data/lib/samagotchi/session_access/contract.rb +1 -1
  50. data/lib/samagotchi/session_access/local.rb +5 -2
  51. data/lib/samagotchi/session_access/remote.rb +32 -12
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +9 -2
  53. data/lib/samagotchi/session_manager.rb +21 -7
  54. data/lib/samagotchi/session_participants.rb +18 -3
  55. data/lib/samagotchi/session_setup.rb +8 -4
  56. data/lib/samagotchi/sessions_command.rb +1 -1
  57. data/lib/samagotchi/share_command.rb +2 -2
  58. data/lib/samagotchi/steer.rb +13 -6
  59. data/lib/samagotchi/system_one/client.rb +16 -7
  60. data/lib/samagotchi/system_one/log.rb +25 -5
  61. data/lib/samagotchi/system_prompt.rb +20 -4
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  63. data/lib/samagotchi/terminal_ui.rb +2 -1
  64. data/lib/samagotchi/tool_runner.rb +11 -1
  65. data/lib/samagotchi/toolkit.rb +27 -8
  66. data/lib/samagotchi/toolkit_binding.rb +149 -7
  67. data/lib/samagotchi/toolkit_command.rb +15 -3
  68. data/lib/samagotchi/toolkit_report_command.rb +320 -0
  69. data/lib/samagotchi/toolkit_suggest.rb +377 -0
  70. data/lib/samagotchi/tools/builtins.rb +14 -1
  71. data/lib/samagotchi/tools/memory.rb +1 -1
  72. data/lib/samagotchi/update_command.rb +7 -6
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/web/app.rb +12 -13
  75. data/lib/samagotchi/web/grants.rb +17 -5
  76. data/lib/samagotchi/web/message_display.rb +6 -1
  77. data/lib/samagotchi/web/participant_access.rb +4 -1
  78. data/lib/samagotchi/web/participant_review.rb +8 -4
  79. data/lib/samagotchi/web/participant_view.rb +12 -4
  80. data/lib/samagotchi/web/participants_admin.rb +10 -6
  81. data/lib/samagotchi/web/principal.rb +3 -4
  82. data/lib/samagotchi/web/public/app.js +34 -10
  83. data/lib/samagotchi/web/public/bubbles.js +3 -2
  84. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  85. data/lib/samagotchi/web/public/participant.html +1 -1
  86. data/lib/samagotchi/web/public/participant.js +12 -13
  87. data/lib/samagotchi/web/public/participant_model.js +20 -8
  88. data/lib/samagotchi/web/public/participant_names.js +29 -8
  89. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  90. data/lib/samagotchi/web/public/question_card.js +12 -2
  91. data/lib/samagotchi/web/public/question_cards.js +7 -0
  92. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  93. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  94. data/lib/samagotchi/web/public/turn_events.js +7 -5
  95. data/lib/samagotchi/web/public/turn_html.js +2 -2
  96. data/lib/samagotchi/web/public/turn_view.js +6 -4
  97. data/lib/samagotchi/web/public/update_notice.js +31 -2
  98. data/lib/samagotchi/web/push/notifier.rb +28 -1
  99. data/lib/samagotchi/web/server.rb +7 -3
  100. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  101. data/lib/samagotchi/web/session_control.rb +4 -2
  102. data/lib/samagotchi/web/session_hub.rb +24 -3
  103. data/lib/samagotchi/web/start_dir.rb +10 -5
  104. data/lib/samagotchi/web/update_check.rb +179 -0
  105. data/lib/samagotchi/worker.rb +60 -8
  106. data/lib/samagotchi/worker_link.rb +1 -0
  107. metadata +8 -1
@@ -41,6 +41,8 @@ require_relative "llm/openai_chat"
41
41
  require_relative "llm/turn_settings"
42
42
  require_relative "session"
43
43
  require_relative "toolkit_binding"
44
+ require_relative "participant_mode"
45
+ require_relative "toolkit_suggest"
44
46
  require_relative "tool_loads"
45
47
  require_relative "answer_tail"
46
48
  require_relative "archive_store"
@@ -156,6 +158,8 @@ module Samagotchi
156
158
  @no_interrupt = no_interrupt
157
159
  @chat_backend = nil
158
160
  @chat_backend_mutex = Mutex.new
161
+ @toolkit_audit_thread = nil
162
+ @toolkit_warmup_thread = nil
159
163
  @host_registry = host_registry || HostRegistry.new
160
164
  # A model given here (a worker's session model) is the one it runs,
161
165
  # so its host is checked; the config default is only checked once it
@@ -237,7 +241,7 @@ module Samagotchi
237
241
  # start: their names for the messages they wrote.
238
242
  @participants = SessionParticipants.new(state_dir: -> { session_state_dir })
239
243
  # Whether the system prompt carries the participants line.
240
- @participants_line = false
244
+ @participants_line = nil
241
245
  # Who is in the review now, and who came or went (#announce_participants).
242
246
  @participants_watch = ParticipantsWatch.new(session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
243
247
  @session = nil
@@ -378,6 +382,9 @@ module Samagotchi
378
382
  @kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
379
383
  @kernel.guardrail_gate = @guardrail_wiring.gate
380
384
  @kernel.participant_turn = -> { Steer.participant_author?(turn_author) }
385
+ @kernel.participant_mode = -> { @guardrail_wiring.turn_mode }
386
+ # A pair turn stops once its participant passed their cap.
387
+ @kernel.generation_guard = -> { guard_participant_spend }
381
388
  # The turn-end warm-up; the kernel asks it for a slot pin per request.
382
389
  @prompt_warmup = PromptWarmup.new
383
390
  @kernel.warmup = @prompt_warmup
@@ -407,7 +414,10 @@ module Samagotchi
407
414
  # from config on every ask, and the running turn's cancel controller
408
415
  # closes a judgment that is no longer wanted.
409
416
  system_one: -> { SystemOne::Client.from_config(host_registry: @host_registry) },
410
- cancel_controller: -> { active_cancel_controller }
417
+ cancel_controller: -> { active_cancel_controller },
418
+ # A participant's turn: their name on the owner's card, their
419
+ # grant's mode for the turn's (GuardrailWiring#turn_mode).
420
+ participant: ->(author) { @participants.entry_for(author, session: @session) }
411
421
  )
412
422
  end
413
423
  private :build_guardrail_wiring
@@ -1082,6 +1092,10 @@ module Samagotchi
1082
1092
 
1083
1093
  # How long #shutdown waits for the running anytime commands, in all.
1084
1094
  SHUTDOWN_JOIN_SECONDS = 3.0
1095
+ # How long #shutdown waits for a running toolkit audit ask (a one-shot
1096
+ # -p --non-interactive exits right after its turn); a slower ask goes
1097
+ # unlogged.
1098
+ TOOLKIT_AUDIT_JOIN_SECONDS = 1.0
1085
1099
 
1086
1100
  # The REPL or the session's worker is leaving (/exit, an idle exit, a
1087
1101
  # crash, TERM): stop the idle jobs, give the running anytime commands
@@ -1107,6 +1121,9 @@ module Samagotchi
1107
1121
 
1108
1122
  thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
1109
1123
  end
1124
+ @toolkit_audit_thread&.join(TOOLKIT_AUDIT_JOIN_SECONDS)
1125
+ # The warm-up's answer is dropped anyway.
1126
+ stop_toolkit_warmup
1110
1127
  left = threads.count(&:alive?)
1111
1128
  Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
1112
1129
  @plugin_tasks.kill_leftovers
@@ -1387,6 +1404,34 @@ module Samagotchi
1387
1404
 
1388
1405
  def session_state_dir = @session_state_dir || Session.default_state_dir
1389
1406
 
1407
+ # Whether a participant's pair turn must stop, read before each of its
1408
+ # generations (#guard_participant_spend): author ("p:<id>") → why (their
1409
+ # link ended, or they passed their cap), nil while it may go on. The
1410
+ # Worker sets it (Worker#participant_turn_stop); without one (the REPL)
1411
+ # nothing is checked mid-turn.
1412
+ attr_writer :participant_stop
1413
+
1414
+ # What a participant turn's stop is attributed to (HookRoutes#stop_turn).
1415
+ PARTICIPANTS_HOOK = "participants"
1416
+
1417
+ # Before each generation (KernelLoop#generation_guard): a pair turn whose
1418
+ # participant's link ended, or whose spend (Engine#metrics: their
1419
+ # finished turns, this one's finished generations, their side requests)
1420
+ # passed their cap, stops on the plugins' stop path (cost-cap's,
1421
+ # work-cap's), with its notice. A generation's own cost is known only
1422
+ # once it ends, so a turn can pass the cap by its last generation. The
1423
+ # turn-start check is the Worker's (Worker#participant_refusal); a
1424
+ # review turn only reads, and keeps to it.
1425
+ def guard_participant_spend
1426
+ author = turn_author
1427
+ return if author.nil? || @participant_stop.nil?
1428
+ return unless @guardrail_wiring.turn_mode == ParticipantMode::PAIR
1429
+
1430
+ reason = @participant_stop.call(author) or return
1431
+ hook_stop_turn(reason, PARTICIPANTS_HOOK)
1432
+ end
1433
+ private :guard_participant_spend
1434
+
1390
1435
  # The running turn's metrics so far, saved as a turn a worker died in
1391
1436
  # would leave them (SessionMetrics#persist's running_turn): a worker's
1392
1437
  # turn checkpoint (TurnCheckpoint) calls it when it writes.
@@ -1492,14 +1537,15 @@ module Samagotchi
1492
1537
  change
1493
1538
  end
1494
1539
 
1495
- # Reads the session's participants at a turn's start; the first to join
1496
- # and the last to leave rebuild the system prompt (its participants
1497
- # line), once each: a turn in between sends the same prefix.
1540
+ # Reads the session's participants at a turn's start; the first to join,
1541
+ # the first invited to act (a pair participant: the line's words) and
1542
+ # the last to leave rebuild the system prompt (its participants line),
1543
+ # once each: a turn in between sends the same prefix.
1498
1544
  def refresh_participants!(session)
1499
- active = @participants.refresh!(session).active?
1500
- return if active == @participants_line
1545
+ line = @participants.refresh!(session).prompt_mode(session)
1546
+ return if line == @participants_line
1501
1547
 
1502
- @participants_line = active
1548
+ @participants_line = line
1503
1549
  @prompt_builder.reset!
1504
1550
  end
1505
1551
  private :refresh_participants!
@@ -1516,6 +1562,14 @@ module Samagotchi
1516
1562
  Steer.author_fields(author, @participants.name_for(author, session: session))
1517
1563
  end
1518
1564
 
1565
+ # A participant's opening message keeps the rules it was delivered under
1566
+ # (ParticipantMode), stamped before any request carries it, so its header
1567
+ # never changes when the session's mode does.
1568
+ def participant_mode_fields(author)
1569
+ mode = Steer.participant_author?(author) && @guardrail_wiring.turn_mode
1570
+ mode ? { participant_mode: mode.to_s } : {}
1571
+ end
1572
+
1519
1573
  # Why a turn stopped (TurnEndings#stop_facts).
1520
1574
  def stop_facts(event) = @turn_endings.stop_facts(event)
1521
1575
  private :stop_facts
@@ -2124,10 +2178,16 @@ module Samagotchi
2124
2178
  turn_started[:cut_after] = steer_cut_after
2125
2179
  # A participant's turn names them (author, author_name) for the UIs'
2126
2180
  # badge; the owner's turn_started stays as it was.
2127
- turn_started.merge!(author_fields(turn.origin.author, session: session))
2181
+ turn_started.merge!(author_fields(turn.origin.author, session: session),
2182
+ participant_mode_fields(turn.origin.author))
2128
2183
  emit_event(turn.on_event, turn.tag(turn_started))
2129
2184
  raise image_error if image_error
2130
2185
 
2186
+ # On mode's ask starts here too; the turn joins it before
2187
+ # apply_toolkit! below, so its latency hides behind the probes and
2188
+ # the init wait.
2189
+ toolkit_suggestion = start_toolkit_suggestion(turn)
2190
+
2131
2191
  # The window and the profile probes run after run_turn's probe-cancel
2132
2192
  # swap, so a Stop cuts them; a failure ends the turn as turn_failed.
2133
2193
  # The cache itself was dropped at the previous turn's end, so this
@@ -2142,7 +2202,10 @@ module Samagotchi
2142
2202
  refuse_images!(turn.settings.vision) unless image_refs.empty?
2143
2203
  # A Stop during the probes: the turn ends before the load-failure
2144
2204
  # warnings, which show once, so the next turn still shows them.
2145
- return false if turn.controller.cancelled?
2205
+ if turn.controller.cancelled?
2206
+ toolkit_suggestion&.abandon(:canceled)
2207
+ return false
2208
+ end
2146
2209
 
2147
2210
  announce_guardrail_failures(turn.on_event)
2148
2211
  # Plugins' slow setup that brings tools (an MCP server's first
@@ -2155,9 +2218,12 @@ module Samagotchi
2155
2218
  # session's toolkit (a /toolkit change since the last turn): what the
2156
2219
  # prompt built next declares, unchanged until the next turn's start.
2157
2220
  apply_staged_tools!
2221
+ # On mode: the suggested kit, before anything is declared (a Stop
2222
+ # during the wait is caught by the check below).
2223
+ settle_toolkit_suggestion(turn, toolkit_suggestion)
2158
2224
  apply_toolkit!(session, turn.on_event)
2159
2225
  refresh_participants!(session)
2160
- # A Stop during the wait above: the turn ends here,
2226
+ # A Stop during the waits above: the turn ends here,
2161
2227
  # before the hooks run or a reminder is used up.
2162
2228
  return false if turn.controller.cancelled?
2163
2229
 
@@ -2174,6 +2240,10 @@ module Samagotchi
2174
2240
 
2175
2241
  turn_messages(turn, image_refs)
2176
2242
  true
2243
+ ensure
2244
+ # A turn that ended (a failure, an Interrupt) before it settled the
2245
+ # ask: its line says so. Nothing once it was settled.
2246
+ toolkit_suggestion&.abandon(:failed)
2177
2247
  end
2178
2248
  private :prepare_turn
2179
2249
 
@@ -2289,7 +2359,7 @@ module Samagotchi
2289
2359
 
2290
2360
  unless turn.continue
2291
2361
  user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
2292
- MessageTime::KEY => turn.at }
2362
+ **participant_mode_fields(turn.origin.author), MessageTime::KEY => turn.at }
2293
2363
  user_message[:images] = image_refs unless image_refs.empty?
2294
2364
  turn.messages << user_message
2295
2365
  session.last_prompt = turn.prompt
@@ -2712,6 +2782,213 @@ module Samagotchi
2712
2782
  def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
2713
2783
  private :apply_toolkit!
2714
2784
 
2785
+ # Start the toolkit classifier after :turn_started, for audit and on
2786
+ # mode alike (one eligibility, one snapshot, one thread). All session
2787
+ # and tool-registry reads happen here; the thread receives only this
2788
+ # snapshot. Audit never blocks: the thread logs its line. On mode
2789
+ # returns the ask's Pending, which the turn joins before apply_toolkit!
2790
+ # (#settle_toolkit_suggestion), with the turn's controller as the
2791
+ # request's cancel controller.
2792
+ # @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
2793
+ def start_toolkit_suggestion(turn)
2794
+ mode = Config.get("toolkit.suggest.mode").to_s
2795
+ return if mode == "off"
2796
+
2797
+ joined = mode == "on"
2798
+ session = turn.session
2799
+ default = Toolkit.of(session.toolkit)
2800
+ log = toolkit_suggestion_log
2801
+ # The predicate checks the session first; the kits (memory kits are
2802
+ # read from disk) and the log (read whole) only when it gets there.
2803
+ kits = nil
2804
+ candidates = nil
2805
+ return unless ToolkitSuggest.eligible?(
2806
+ session: session, prompt: turn.prompt, author: turn.origin.author,
2807
+ candidate_count: lambda {
2808
+ kits = @toolkit_binding.kits
2809
+ candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
2810
+ candidates.size
2811
+ },
2812
+ last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
2813
+ defer_turns: Config.get("toolkit.suggest.defer_turns"),
2814
+ defer_tokens: Config.get("toolkit.suggest.defer_tokens")
2815
+ )
2816
+
2817
+ # One ask at a time: an eligible turn while the last one still runs
2818
+ # goes unasked, and says so.
2819
+ if @toolkit_audit_thread&.alive?
2820
+ Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
2821
+ return
2822
+ end
2823
+
2824
+ client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
2825
+ return unless client
2826
+
2827
+ # D7: on a shared single-slot server audit's free ask would compete
2828
+ # with the turn's prefill, and any ask evicts the session's cached
2829
+ # prefix: on mode asks only on a first turn there (nothing is cached
2830
+ # yet worth keeping), never on the deferred path.
2831
+ if (!joined || ToolkitSuggest.model_reply?(session.messages)) && toolkit_judge_shares_local_host?(client.host)
2832
+ Log.info(:toolkit, "toolkit_suggest_shared_host_skipped", session_id: session.id, host: client.host)
2833
+ return
2834
+ end
2835
+
2836
+ # The warm-up's only value is the cache: the real ask never queues
2837
+ # behind it.
2838
+ stop_toolkit_warmup
2839
+
2840
+ # Rules, not tool lists: this runs before await_init_tasks and
2841
+ # apply_staged_tools!, so a first turn's plugin/MCP tools aren't
2842
+ # registered yet; the report expands the rules (ToolkitSuggest.rules).
2843
+ snapshots = candidates.map do |kit|
2844
+ ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
2845
+ rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
2846
+ end.freeze
2847
+ default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
2848
+ rules: ToolkitSuggest.rules(default.resolve(kits)))
2849
+ core_tools = @tools.entries(toolkit: nil).select(&:core?).map { |entry| entry.name.dup.freeze }.freeze
2850
+ state = ToolkitSuggest.question(
2851
+ default: default, kits: kits, project: File.basename(session.project_root.to_s),
2852
+ prompt: turn.prompt
2853
+ ).last
2854
+ snapshot = ToolkitSuggest::Snapshot.new(
2855
+ session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
2856
+ default: default_snapshot, candidates: snapshots, core_tools: core_tools,
2857
+ state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
2858
+ )
2859
+ threshold = Config.get("toolkit.suggest.threshold")
2860
+ pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
2861
+ timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
2862
+ # On mode: cut by a Stop while the turn waits (Pending#wait), not
2863
+ # by one after it went on; audit's ask is never cut.
2864
+ controller = joined ? pending.cancel_controller : nil
2865
+ @toolkit_audit_thread = Thread.new do
2866
+ judged = nil
2867
+ begin
2868
+ judged = ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
2869
+ cancel_controller: controller)
2870
+ rescue LLM::RequestCancelled
2871
+ nil
2872
+ rescue StandardError => e
2873
+ ToolkitSuggest.warn(e)
2874
+ ensure
2875
+ pending.finish(judged)
2876
+ end
2877
+ end
2878
+ joined ? pending : nil
2879
+ rescue StandardError => e
2880
+ ToolkitSuggest.warn(e)
2881
+ nil
2882
+ end
2883
+ private :start_toolkit_suggestion
2884
+
2885
+ # On mode, before apply_toolkit!: wait for the ask (#start_toolkit_
2886
+ # suggestion) up to System 1's timeout from its start, or until a Stop,
2887
+ # and put the session on a candidate picked with confidence ≥ threshold
2888
+ # (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
2889
+ # other outcome (the default, below threshold, undecided, no judgment,
2890
+ # a timeout, a Stop) leaves the session on its default. Nothing here
2891
+ # sends a request; prepare_turn's cancel check ends a stopped turn. A
2892
+ # Stop wins over an answer already in (Pending#wait checks it first).
2893
+ def settle_toolkit_suggestion(turn, pending)
2894
+ return if pending.nil?
2895
+
2896
+ outcome, judged = pending.wait(turn.controller)
2897
+ unless outcome == :answered
2898
+ Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
2899
+ return
2900
+ end
2901
+ return if judged.nil?
2902
+
2903
+ pending.record(toolkit_suggestion_outcome(turn.session, judged))
2904
+ rescue StandardError => e
2905
+ ToolkitSuggest.warn(e)
2906
+ pending&.record("failed")
2907
+ end
2908
+ private :settle_toolkit_suggestion
2909
+
2910
+ # What the turn does with an answered ask, as the log line's did:
2911
+ # suggested (switched), chosen (the toolkit isn't the default any
2912
+ # more), unavailable (the kit is gone), defer or default.
2913
+ def toolkit_suggestion_outcome(session, judged)
2914
+ case judged.would
2915
+ when "suggested"
2916
+ return "chosen" unless session.toolkit_origin == "default"
2917
+ return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
2918
+
2919
+ switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
2920
+ deferred: ToolkitSuggest.model_reply?(session.messages))
2921
+ switched ? "suggested" : "chosen"
2922
+ when "defer" then "defer"
2923
+ else "default"
2924
+ end
2925
+ end
2926
+ private :toolkit_suggestion_outcome
2927
+
2928
+ # On mode, as an idle worker starts (Worker#start_background): one ask
2929
+ # with the real question and a stand-in prompt, on its own thread,
2930
+ # never logged and never waited for, so System 1's server has the
2931
+ # question's prefix cached for the first prompt. Only for a session a
2932
+ # first prompt could be asked for: origin default, no model reply, not
2933
+ # a delegate child or a review session, at least two candidates; and
2934
+ # not when the worker starts on a first prompt (that ask comes at once).
2935
+ def warm_toolkit_suggestion!
2936
+ return unless Config.get("toolkit.suggest.mode").to_s == "on"
2937
+
2938
+ session = @session
2939
+ return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
2940
+ return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
2941
+
2942
+ default = Toolkit.of(session.toolkit)
2943
+ kits = @toolkit_binding.kits
2944
+ return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
2945
+
2946
+ client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
2947
+ return unless client
2948
+ # D7: on the model's own single-slot server it would evict the
2949
+ # session's cached prefix for a question that may never come.
2950
+ return if toolkit_judge_shares_local_host?(client.host)
2951
+
2952
+ project = File.basename(session.project_root.to_s)
2953
+ @toolkit_warmup_thread = Thread.new do
2954
+ ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
2955
+ end
2956
+ rescue StandardError => e
2957
+ ToolkitSuggest.warn(e)
2958
+ nil
2959
+ end
2960
+ public :warm_toolkit_suggestion!
2961
+
2962
+ # A still-running warm-up, stopped: a real ask (or the worker leaving)
2963
+ # doesn't wait behind it.
2964
+ def stop_toolkit_warmup
2965
+ @toolkit_warmup_thread&.kill
2966
+ @toolkit_warmup_thread = nil
2967
+ end
2968
+ private :stop_toolkit_warmup
2969
+
2970
+ def toolkit_suggestion_log
2971
+ path = ToolkitSuggest.log_path(session_state_dir)
2972
+ SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
2973
+ warn: lambda { |reason|
2974
+ Log.warn(:toolkit, "toolkit_suggest_log_failed", echo: "[samagotchi:toolkit] #{reason}")
2975
+ })
2976
+ end
2977
+ private :toolkit_suggestion_log
2978
+
2979
+ # D7: System 1's host is the session's model server, and a local one
2980
+ # (a single-slot Splash or llama.cpp): the same name, or two hosts:
2981
+ # entries for one server root (scheme, host, port).
2982
+ def toolkit_judge_shares_local_host?(judge_name)
2983
+ model_host, = @host_registry.host_for_model(effective_model_name)
2984
+ return false if model_host.nil? || model_host.remote?
2985
+ return true if judge_name.to_s.strip.casecmp?(model_host.name.to_s)
2986
+
2987
+ judge = @host_registry.find_entry(judge_name)
2988
+ !judge.nil? && judge.root_url.downcase == model_host.root_url.downcase
2989
+ end
2990
+ private :toolkit_judge_shares_local_host?
2991
+
2715
2992
  # The session's toolkit (see #initialize for when).
2716
2993
  def build_toolkit_binding
2717
2994
  ToolkitBinding.new(
@@ -56,7 +56,6 @@ module Samagotchi
56
56
  @announce_or_hold = announce_or_hold
57
57
  @working_set_emitted = nil
58
58
  end
59
- # rubocop:enable Metrics/ParameterLists
60
59
 
61
60
  # Always return a handler so raw kernel loop events reach the persistent
62
61
  # SessionObserver (and thus the analytics collector) even when there is no
@@ -5,6 +5,7 @@ require "net/http"
5
5
  require "open3"
6
6
  require "rbconfig"
7
7
  require "rubygems/package"
8
+ require "time"
8
9
  require_relative "version"
9
10
  require_relative "redacted_inspect"
10
11
 
@@ -22,12 +23,14 @@ module Samagotchi
22
23
  end
23
24
 
24
25
  LATEST_URL = URI("https://rubygems.org/api/v1/versions/samagotchi/latest.json")
26
+ RELEASES_URL = URI("https://rubygems.org/api/v1/versions/samagotchi.json")
25
27
  # Not SAMAGOTCHI_UPDATE_GEM: that is the env form of config update.gem.
26
28
  LOCAL_ENV = "SAMAGOTCHI_UPDATE_GEM_FILE"
27
29
  TIMEOUT = 5
28
30
 
29
31
  # source is what `gem install` takes: the gem's name, or a .gem file.
30
32
  Latest = Struct.new(:version, :source, keyword_init: true)
33
+ Release = Data.define(:version, :created_at, :prerelease)
31
34
 
32
35
  # Runs a command without a shell; [output, success].
33
36
  class Runner
@@ -57,7 +60,7 @@ module Samagotchi
57
60
  local = @env[LOCAL_ENV].to_s
58
61
  return Latest.new(version: Gem::Package.new(local).spec.version.to_s, source: local) unless local.empty?
59
62
 
60
- version = JSON.parse(@fetcher.call.to_s)["version"].to_s
63
+ version = JSON.parse(fetch_body(LATEST_URL).to_s)["version"].to_s
61
64
  raise Error, "rubygems.org answered without a version" if version.empty? || !Gem::Version.correct?(version)
62
65
 
63
66
  Latest.new(version: version, source: "samagotchi")
@@ -67,6 +70,29 @@ module Samagotchi
67
70
  raise Error, "#{e.class}: #{e.message}".lines.first.strip
68
71
  end
69
72
 
73
+ # @return [Array<Release>] rubygems releases, or the one local smoke gem
74
+ def releases
75
+ local = @env[LOCAL_ENV].to_s
76
+ return [local_release(local)] unless local.empty?
77
+
78
+ records = JSON.parse(fetch_body(RELEASES_URL).to_s)
79
+ raise Error, "rubygems.org answered without a releases list" unless records.is_a?(Array)
80
+
81
+ records.map do |record|
82
+ version = record["number"].to_s
83
+ created_at = record["created_at"].to_s
84
+ unless Gem::Version.correct?(version) && !created_at.empty? && [true, false].include?(record["prerelease"])
85
+ raise Error, "rubygems.org answered with an invalid release"
86
+ end
87
+
88
+ Release.new(version: version, created_at: Time.iso8601(created_at), prerelease: record["prerelease"])
89
+ end
90
+ rescue Error
91
+ raise
92
+ rescue StandardError => e
93
+ raise Error, "#{e.class}: #{e.message}".lines.first.strip
94
+ end
95
+
70
96
  def newer?(latest, current = VERSION)
71
97
  Gem::Version.new(latest.version) > Gem::Version.new(current)
72
98
  end
@@ -83,9 +109,20 @@ module Samagotchi
83
109
 
84
110
  private
85
111
 
86
- def fetch
87
- Net::HTTP.start(LATEST_URL.host, LATEST_URL.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
88
- request = Net::HTTP::Get.new(LATEST_URL)
112
+ def local_release(path)
113
+ version = Gem::Package.new(path).spec.version
114
+ Release.new(version: version.to_s, created_at: File.mtime(path), prerelease: version.prerelease?)
115
+ end
116
+
117
+ def fetch_body(url)
118
+ return @fetcher.call if @fetcher.arity.zero?
119
+
120
+ @fetcher.call(url)
121
+ end
122
+
123
+ def fetch(url = LATEST_URL)
124
+ Net::HTTP.start(url.host, url.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
125
+ request = Net::HTTP::Get.new(url)
89
126
  request["User-Agent"] = USER_AGENT
90
127
  response = http.request(request)
91
128
  raise Error, "rubygems.org answered #{response.code}" unless response.code == "200"
@@ -15,6 +15,7 @@ require_relative "edit_preview"
15
15
  require_relative "system_one"
16
16
  require_relative "tool_activity"
17
17
  require_relative "turn_origin"
18
+ require_relative "participant_mode"
18
19
 
19
20
  module Samagotchi
20
21
  # An Engine's tool guardrails: who can approve (the interface), the YAML
@@ -44,8 +45,11 @@ module Samagotchi
44
45
  # @param system_one [#call] → SystemOne::Client or nil: the judgment client
45
46
  # (nil while `guardrails.system_one.mode` is off or no host is set)
46
47
  # @param cancel_controller [#call] → the running turn's CancellationController, nil
48
+ # @param participant [#call] (author) → the participant's
49
+ # Participants::Entry (participants.json: their name, their grant's
50
+ # mode), nil for none
47
51
  def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {},
48
- notify: ->(_message) {}, system_one: -> {}, cancel_controller: -> {})
52
+ notify: ->(_message) {}, system_one: -> {}, cancel_controller: -> {}, participant: ->(_author) {})
49
53
  @scratch = scratch
50
54
  @hooks_lookup = hooks
51
55
  @tools_lookup = tools
@@ -57,6 +61,8 @@ module Samagotchi
57
61
  @notify = notify
58
62
  @system_one = system_one
59
63
  @cancel_controller = cancel_controller
64
+ @participant = participant
65
+ @turn_mode = nil
60
66
  # Bundle name → the rules last loaded from it whole, kept while the
61
67
  # installed version needs a newer chi; [name, version] said so once.
62
68
  @loaded_bundle_rules = {}
@@ -95,12 +101,24 @@ module Samagotchi
95
101
  end
96
102
 
97
103
  # A turn starts: its origin (Engine#run_turn's TurnOrigin; nil or a
98
- # Hash is read as one, TurnOrigin.of), and the git facts read afresh.
104
+ # Hash is read as one, TurnOrigin.of), the git facts read afresh, and
105
+ # a participant's turn's mode decided once for the whole turn
106
+ # (#turn_mode).
99
107
  def begin_turn(origin)
100
108
  @origin = TurnOrigin.of(origin)
101
109
  @git = Guardrails::GitInfo.new
110
+ author = turn_author
111
+ @turn_entry = author && @participant.call(author)
112
+ @turn_mode = author && ParticipantMode.for_turn(@session_lookup.call, @turn_entry)
102
113
  end
103
114
 
115
+ # The running participant turn's mode (ParticipantMode.for_turn: :pair
116
+ # only in a working session for a participant invited to act), decided
117
+ # at its start; nil for the owner's turns. The boundary in #checks, the
118
+ # Engine's stamp and mid-turn stop, and ToolRunner read it.
119
+ # @return [Symbol, nil]
120
+ attr_reader :turn_mode
121
+
104
122
  # Who wrote the running turn when it isn't the session's owner (its
105
123
  # origin's participant author); nil for the owner's turns and every
106
124
  # other origin.
@@ -125,23 +143,34 @@ module Samagotchi
125
143
 
126
144
  # The gate's core checks, in order. A delegate child's boundary comes
127
145
  # before the rules, so its ask keeps the child-boundary rule id (and
128
- # its once/session scopes) when a strict rule asks too. The review
129
- # boundary comes before both: a participant's turn is denied what it
130
- # may not do, never asked about it (deny is sticky, so no later vote
131
- # can turn it into a question).
146
+ # its once/session scopes) when a strict rule asks too. The
147
+ # participant's boundary comes before both, by the session's
148
+ # ParticipantMode: a review turn's (ReviewBoundary) or a pair turn's
149
+ # (PairBoundary). Either denies what a participant's turn may not do,
150
+ # never asks about it; both are no-ops for the owner's turns.
132
151
  def checks
133
152
  rules = self.rules
134
- [@failures, review_boundary, rules.hook_asks, protected_paths,
153
+ [@failures, participant_boundary, rules.hook_asks, protected_paths,
135
154
  (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch), child_boundary, rules].compact
136
155
  end
137
156
 
138
- # A participant's turn's boundary (Guardrails::ReviewBoundary): in
139
- # every session, a no-op unless the running turn's author is a
140
- # participant's.
157
+ # The boundary for a participant's turn in this session (#checks).
158
+ def participant_boundary
159
+ @turn_mode == ParticipantMode::PAIR ? pair_boundary : review_boundary
160
+ end
161
+
162
+ # A participant's review turn's boundary (Guardrails::ReviewBoundary): a
163
+ # no-op unless the running turn's author is a participant's.
141
164
  def review_boundary
142
165
  @review_boundary ||= Guardrails::ReviewBoundary.new(author: -> { turn_author }, root: -> { boundary_root })
143
166
  end
144
167
 
168
+ # A participant's pair turn's boundary (Guardrails::PairBoundary): a
169
+ # no-op unless the running turn's author is a participant's.
170
+ def pair_boundary
171
+ @pair_boundary ||= Guardrails::PairBoundary.new(author: -> { turn_author }, root: -> { boundary_root })
172
+ end
173
+
145
174
  # The delegate child's boundary (Guardrails::ChildBoundary); nil for
146
175
  # any other session.
147
176
  def child_boundary
@@ -263,8 +292,12 @@ module Samagotchi
263
292
  label = verdict.targets&.label ||
264
293
  ToolActivity.plugin_label(acts_as || verdict.call[:name].to_s, registry: @tools_lookup.call)
265
294
  # verdict.call is the call that will run (a hook may have replaced it).
295
+ # A participant's turn's ask names them and offers once only
296
+ # (Approval.payload's author:), whichever session it is.
297
+ author = turn_author
266
298
  payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call),
267
- only: (SCRATCH_SCOPES if @scratch))
299
+ only: (SCRATCH_SCOPES if @scratch), author: author,
300
+ author_name: (@turn_entry&.name if author))
268
301
  # A call on chi's own config, hooks or guardrails is the user's to
269
302
  # allow, never a parent agent's (Guardrails::ParentPolicy).
270
303
  payload[:user_only] = Guardrails::ParentApprovals.protected_facts?(payload[:approval])
@@ -310,7 +343,8 @@ module Samagotchi
310
343
  # System 1's judgment of an ask, written to the log before the user is
311
344
  # asked, with the decision it led to: "auto" (it answered for the user),
312
345
  # "asked" (on mode, the user decides) or "audit" (audit mode). A
313
- # participant's review turn is never judged, and no client (mode off, no
346
+ # participant's turn (review or pair) is never judged, so never
347
+ # auto-allowed: its asks go to the owner. No client (mode off, no
314
348
  # host) means no line at all. A judgment that fails is logged with p nil,
315
349
  # so the failures are in the log too.
316
350
  # @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written