samagotchi 0.122.0 → 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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/docs/architecture.md +17 -6
  4. data/docs/cli.md +32 -2
  5. data/docs/configuration.md +31 -6
  6. data/docs/guardrails.md +112 -15
  7. data/docs/internals/engine.md +11 -4
  8. data/docs/internals/participants.md +67 -17
  9. data/docs/internals/prompt-caching.md +19 -7
  10. data/docs/internals/web-push.md +7 -3
  11. data/docs/sessions.md +7 -3
  12. data/lib/samagotchi/bridge.rb +4 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/config.rb +7 -1
  15. data/lib/samagotchi/delivery.rb +7 -0
  16. data/lib/samagotchi/engine.rb +200 -29
  17. data/lib/samagotchi/gem_update.rb +41 -4
  18. data/lib/samagotchi/guardrail_wiring.rb +45 -12
  19. data/lib/samagotchi/guardrails/approval.rb +42 -4
  20. data/lib/samagotchi/guardrails/gate.rb +1 -1
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
  22. data/lib/samagotchi/guardrails/pair_command.rb +365 -0
  23. data/lib/samagotchi/guardrails/review_paths.rb +41 -7
  24. data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
  25. data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
  26. data/lib/samagotchi/guardrails/verdict.rb +7 -0
  27. data/lib/samagotchi/guardrails.rb +1 -0
  28. data/lib/samagotchi/guardrails_command.rb +6 -1
  29. data/lib/samagotchi/kernel_loop.rb +20 -0
  30. data/lib/samagotchi/llm/chat_loop.rb +4 -0
  31. data/lib/samagotchi/participant_mode.rb +77 -3
  32. data/lib/samagotchi/participants.rb +7 -5
  33. data/lib/samagotchi/review_apply.rb +10 -2
  34. data/lib/samagotchi/review_start.rb +38 -10
  35. data/lib/samagotchi/session_access/local.rb +5 -2
  36. data/lib/samagotchi/session_participants.rb +18 -3
  37. data/lib/samagotchi/share_command.rb +2 -2
  38. data/lib/samagotchi/steer.rb +7 -1
  39. data/lib/samagotchi/system_one/client.rb +10 -2
  40. data/lib/samagotchi/system_prompt.rb +20 -4
  41. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  42. data/lib/samagotchi/tool_runner.rb +10 -1
  43. data/lib/samagotchi/toolkit_binding.rb +34 -0
  44. data/lib/samagotchi/toolkit_report_command.rb +6 -4
  45. data/lib/samagotchi/toolkit_suggest.rb +181 -15
  46. data/lib/samagotchi/tools/builtins.rb +14 -1
  47. data/lib/samagotchi/update_command.rb +7 -6
  48. data/lib/samagotchi/version.rb +1 -1
  49. data/lib/samagotchi/web/app.rb +7 -8
  50. data/lib/samagotchi/web/grants.rb +17 -5
  51. data/lib/samagotchi/web/message_display.rb +6 -1
  52. data/lib/samagotchi/web/participant_review.rb +3 -1
  53. data/lib/samagotchi/web/participant_view.rb +12 -4
  54. data/lib/samagotchi/web/participants_admin.rb +10 -6
  55. data/lib/samagotchi/web/public/app.js +34 -10
  56. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  57. data/lib/samagotchi/web/public/participant.html +1 -1
  58. data/lib/samagotchi/web/public/participant.js +10 -12
  59. data/lib/samagotchi/web/public/participant_model.js +20 -8
  60. data/lib/samagotchi/web/public/participant_names.js +29 -8
  61. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  62. data/lib/samagotchi/web/public/question_card.js +12 -2
  63. data/lib/samagotchi/web/public/question_cards.js +7 -0
  64. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  65. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  66. data/lib/samagotchi/web/public/turn_events.js +5 -4
  67. data/lib/samagotchi/web/public/turn_html.js +2 -2
  68. data/lib/samagotchi/web/public/turn_view.js +6 -4
  69. data/lib/samagotchi/web/public/update_notice.js +31 -2
  70. data/lib/samagotchi/web/push/notifier.rb +28 -1
  71. data/lib/samagotchi/web/server.rb +5 -1
  72. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  73. data/lib/samagotchi/web/session_hub.rb +24 -3
  74. data/lib/samagotchi/web/update_check.rb +179 -0
  75. data/lib/samagotchi/worker.rb +44 -6
  76. metadata +5 -1
@@ -73,6 +73,16 @@ right-hand column happens.
73
73
  beside `apply_staged_tools!`, then `reset!`): the chat loop builds `tools:` per request, so a mid-turn change
74
74
  would differ from the turn's prompt. Under `full` nothing is filtered and no line is added: the bytes are what
75
75
  they were before toolkits.
76
+ - A toolkit suggestion (`toolkit.suggest.mode: on`) is set before `apply_toolkit!` of the turn it was asked on
77
+ (`Engine#settle_toolkit_suggestion` → `ToolkitBinding#suggest`), and no request goes out before that, so on a
78
+ session's first turn it costs nothing. Its **deferred switch** (a later prompt after `undecided`, within
79
+ `toolkit.suggest.defer_turns` / `defer_tokens`) is engine-side, so `/toolkit`'s CacheConfirm doesn't apply: it
80
+ changes the declared tools at the front of the request, and the server re-reads **system prompt + tools +
81
+ conversation** on that turn. The defer bound keeps that conversation small; its notice says "from this turn".
82
+ When System 1 runs on the model's own single-slot local server (`Engine#toolkit_judge_shares_local_host?`), any
83
+ ask takes the slot and evicts its cached prefix: a first-turn ask costs that cache even when nothing switches
84
+ (there is little cached on a session's first turn), so on mode asks only there, skips the deferred path, and
85
+ sends no worker warm-up.
76
86
  - A kit's prompt text (`toolkit.kits.<name>.prompt:`, `chi.toolkit … prompt:`) sits after the appended files and
77
87
  before the memory indexes (`SystemPrompt#kit_prompt_section`): one prefix per kit, never per-session text, so
78
88
  sessions on the kit share it; a memory write (the indexes after it) doesn't move it. It is part of the
@@ -119,13 +129,15 @@ right-hand column happens.
119
129
  dropped or brought back) is a tail system message after the history, one per turn with ops, escaped as context
120
130
  notes are (`Prompt::ESCAPED_KINDS`). The turn-end warm-up never warms a drop sent after the turn: it misses once.
121
131
  - **Participants** ([participants.md](participants.md#attribution)). A participant's message carries a wire-only
122
- header line (`Steer.review_header`), a pure function of the saved message (its `author_name` included), so a
123
- replay sends the same bytes; the owner's messages are sent as before (`spec/review_contribution_spec.rb`, per loop
124
- and on a Messages host). The participants line opens the system prompt's volatile tail, after breakpoint #1:
125
- a new session's cache is untouched. The Engine checks the sidecar at each turn's start and rebuilds (`reset!`)
126
- only when the first participant joins and when the last leaves or expires: one history re-read each, then stable.
127
- Under D3(a) the declared tools stay the session's, so an author switch costs nothing: the review boundary is a
128
- gate on the calls, not a toolkit switch. The forged-header mark (`(quoted) `) is a pure function of the text too.
132
+ header line (`Steer.review_header`), a pure function of the saved message (its `author_name` and its
133
+ `participant_mode` stamp included: a review or a pair header), so a replay sends the same bytes; the owner's
134
+ messages are sent as before (`spec/review_contribution_spec.rb`, per loop and on a Messages host). The
135
+ participants line opens the system prompt's volatile tail, after breakpoint #1: a new session's cache is
136
+ untouched. Its words follow the session's `ParticipantMode` (a review or a working session's line), which never
137
+ changes for a session. The Engine checks the sidecar at each turn's start and rebuilds (`reset!`) only when the
138
+ first participant joins and when the last leaves or expires: one history re-read each, then stable. Under D3(a)
139
+ the declared tools stay the session's, so an author switch costs nothing: the review and pair boundaries are
140
+ gates on the calls, not a toolkit switch. The forged-header mark (`(quoted) `) is a pure function of the text too.
129
141
  - Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt (the recap's also
130
142
  asks for its auto title line, `RecapPrompt::TITLE`: static text, still that one request). On a current
131
143
  llama.cpp they don't evict the session (see the server settings below); chi never pins them to a slot.
@@ -101,10 +101,12 @@ The service worker is the only reader of the payload (JSON, at most `Notifier::O
101
101
 
102
102
  - `{"v":1,"kind":"show","tag":KEY,"title":…,"body":…,"url":URL,"badge":N,"open":[KEY…]}`: a waiting
103
103
  reason's (approval, question, continue, card) TTL 12 h (`SHOW_TTL`), `Urgency: high`; a failed or done turn's
104
- TTL 10 min (`EVENT_TTL`), `Urgency: normal`. `open` only while the whole list fits the limit; the test push
104
+ TTL 10 min (`EVENT_TTL`), `Urgency: normal`; an available chi update uses a 24 h TTL (`UPDATE_TTL`) and
105
+ `Urgency: normal`. `open` only while the whole list fits the limit; the test push
105
106
  (`chi:test`) has neither `open` nor `badge`. URL (`Notifier.session_url`) is the session in its own project's
106
107
  view, `/?dir=<project root>#/s/<id>` (the folder URL-encoded, its slashes kept), or `/?scope=all#/s/<id>` for a
107
- session in no repo: never the bare `/`, which is chi web's start project.
108
+ session in no repo: never the bare `/`, which is chi web's start project (except the update show, which names no
109
+ session: `/`).
108
110
 
109
111
  `badge` and `open` are the waiting keys still open in the Notifier's summaries (`Attention.open_keys`: a pending
110
112
  question of any reason, a hook's and a continue's too, not relayed, and a card with actions; never a failed or done
@@ -160,7 +162,9 @@ the top and in `pending_question` / `pending_card`, strings in `last_turn`), `no
160
162
  from chi web's request threads (`SessionHub#touch`, from the stop/archive/delete routes), so it only enqueues: a push
161
163
  is a 0.5 s https call. `emit` already rescues a raising sink. The sink keeps the last summary per session and turns
162
164
  each `session` / `session_gone` event into a `Show` (with its text) or a `Close` (it was answered anywhere) on a
163
- Queue.
165
+ Queue. A `chi` event marked `update_notice` becomes a `chi:update:<version>` show for the bare page; the checker
166
+ sets that marker only for a newly selected version not already in its persistent `notified` list. It uses the same
167
+ Presence skip and hourly CAP as session shows, and the 24-hour TTL lets an offline phone receive it later.
164
168
 
165
169
  **Seeding**: `Notifier#start(hub)` subscribes with `snapshot: true` and takes the summaries as the seed, so what is
166
170
  open already never pushes. The hub's projection is empty before its first scan, so `Server.start` runs `hub.scan`
data/docs/sessions.md CHANGED
@@ -264,7 +264,9 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
264
264
  ## Review sessions
265
265
 
266
266
  A **review session** is one made to be shared with participants (the shared-sessions plan's D2: a review is its own
267
- session, so a participant's review turn can't quote a working session's tool outputs). `chi review FILE…` starts one
267
+ session, so a participant's review turn can't quote a working session's tool outputs, and only reads). Sharing a
268
+ working session itself is pairing: a participant's turns there act with its tools under the owner's guardrails
269
+ ([CLI: Participants](cli.md#participants), [guardrails](guardrails.md#a-participants-pair-boundary)). `chi review FILE…` starts one
268
270
  in a project, `chi review --from ID` as a review fork of a working session (see [CLI: Review
269
271
  sessions](cli.md#review-sessions)); both start idle and print the `chi share` line to invite someone.
270
272
 
@@ -292,7 +294,8 @@ sessions](cli.md#review-sessions)); both start idle and print the `chi share` li
292
294
  space-separated, relative to the page's folder; a path with a space in double quotes, `"docs/my plan.md"`): its
293
295
  chip then reads `review: plan.md +1`, and Start makes a review session (the message typed, if any, is its first
294
296
  turn; with none it starts idle). A refused file shows the server's one message in the chip's form, opened again. On a session that isn't a review session the participants panel (the session bar's
295
- participants chip) says so before its Invite, with the share warning (how many tool outputs its history holds)
297
+ participants chip) says so before its Invite, with the share warning (that a participant's turns act there, writes
298
+ included and asks to the owner, or only read while an older worker holds it; how many tool outputs its history holds)
296
299
  and **Start a review of it**: a review fork of it, opened, with a toast (`Review 4b7e9a01 started from 3f9a1c2e's
297
300
  recap; 3f9a1c2e keeps running.`) whose **Invite** opens the new session's panel. Every summary carries `review`
298
301
  (null for other sessions).
@@ -311,7 +314,8 @@ declares every tool (full). The file stores only the name: a kit's tools and pro
311
314
 
312
315
  `toolkit_origin` records why the session is on that kit: `default` for the configured kit, `chosen` for a
313
316
  user-selected kit (including `/toolkit` and `ctx.use_toolkit`), `inherited` for a continue or fork, or `suggested`
314
- for a future toolkit suggestion. A session file from before the field reads as `chosen`.
317
+ when `toolkit.suggest.mode: on` switched it ([Toolkits](configuration.md#toolkits)). A session file from before the
318
+ field reads as `chosen`. Only a `default` session is ever suggested a kit.
315
319
 
316
320
  A `find_tools` result on a chat host saves the tools its hit loads as `loaded: ["list_sessions", "mcp_fs_read"]` on
317
321
  its `tool_response` entry (wire names: a built-in's, an MCP tool's `mcp_<server>_<tool>`), whatever the host: a
@@ -629,8 +629,11 @@ module Samagotchi
629
629
  # review_turns: a participant's turn runs as its own, inside the review
630
630
  # boundary (Guardrails::ReviewBoundary); a worker without it has none,
631
631
  # so chi web sends it no participant's turn (Delivery::REVIEW_FEATURE).
632
+ # pair_turns: a participant's turn in a working session runs as a pair
633
+ # turn (Guardrails::PairBoundary); a worker without it runs it as a
634
+ # review turn, and chi web says so (Delivery::PAIR_FEATURE).
632
635
  FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
633
- SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE].freeze
636
+ SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE].freeze
634
637
 
635
638
  # The routes, by method and what follows /session/:id/: a handler takes
636
639
  # the session id and the request body and returns [headers, status, body].
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.122.0
3
+ version: 0.123.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:
@@ -249,7 +249,8 @@ module Samagotchi
249
249
  # delegate child's. toolkit.kits (the user's own) is read by Toolkit.kits.
250
250
  Entry.new(key: "toolkit.default", yaml_path: %w[toolkit default], type: :string, default: nil, expose: %i[env config], lifecycle: :session_creation),
251
251
  Entry.new(key: "toolkit.child", yaml_path: %w[toolkit child], type: :string, default: nil, expose: %i[env config], lifecycle: :session_creation),
252
- # Toolkit suggestion (ToolkitSuggest): off, audit, or on; on currently behaves as audit and never changes a session.
252
+ # Toolkit suggestion (ToolkitSuggest): off; audit (logs, never blocks or changes a session); on (the first eligible
253
+ # turn waits up to guardrails.system_one.timeout_ms and may start on the suggested kit).
253
254
  Entry.new(key: "toolkit.suggest.mode", yaml_path: %w[toolkit suggest mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :worker_start),
254
255
  Entry.new(key: "toolkit.suggest.threshold", yaml_path: %w[toolkit suggest threshold], type: :float, default: 0.5, expose: %i[config], lifecycle: :worker_start),
255
256
  Entry.new(key: "toolkit.suggest.defer_turns", yaml_path: %w[toolkit suggest defer_turns], type: :integer, default: 3, expose: %i[config], lifecycle: :worker_start),
@@ -289,6 +290,11 @@ module Samagotchi
289
290
  Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config], lifecycle: :next_update),
290
291
  Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config], lifecycle: :next_update),
291
292
  Entry.new(key: "update.desktop", yaml_path: %w[update desktop], type: :bool, default: true, expose: %i[env config], lifecycle: :next_update),
293
+ Entry.new(key: "update.notify", yaml_path: %w[update notify], type: :enum, default: "on", expose: %i[env config], enum_values: %w[on off], lifecycle: :web_start),
294
+ Entry.new(key: "update.check_hours", yaml_path: %w[update check_hours], type: :integer, default: 24, expose: %i[env config], lifecycle: :web_start,
295
+ check: ->(value) { "at least 1" unless value >= 1 }),
296
+ Entry.new(key: "update.min_age_hours", yaml_path: %w[update min_age_hours], type: :integer, default: 24, expose: %i[env config], lifecycle: :web_start,
297
+ check: ->(value) { "at least 0" unless value >= 0 }),
292
298
  # The desktop helper's kitty targets (Desktop::MacOS.kitty_settings, one of its terminal_settings): listen_on copied from kitty.conf (unset = none),
293
299
  # the kitty binary, and the foreground programs listed, "|"-separated (a YAML list works too; "*" = every window).
294
300
  Entry.new(key: "kitty.listen_on", yaml_path: %w[kitty listen_on], type: :string, default: nil, expose: %i[env config], lifecycle: :desktop_update),
@@ -58,6 +58,13 @@ module Samagotchi
58
58
  "the owner must restart it (chi sessions restart ID, or the web's update badge)"
59
59
  }.freeze
60
60
 
61
+ # A worker that runs a participant's turn in a working session as a pair
62
+ # turn (ParticipantMode :pair, Guardrails::PairBoundary). One without it
63
+ # still runs it as a review turn (ReviewBoundary), so chi web and chi
64
+ # share treat the session as :review (ParticipantMode.live) until it
65
+ # restarts.
66
+ PAIR_FEATURE = "pair_turns"
67
+
61
68
  # Whether it is the one every sender defaulting to a step boundary uses.
62
69
  def self.next_step?(value) = parse(value) == NEXT_STEP
63
70
 
@@ -159,6 +159,7 @@ module Samagotchi
159
159
  @chat_backend = nil
160
160
  @chat_backend_mutex = Mutex.new
161
161
  @toolkit_audit_thread = nil
162
+ @toolkit_warmup_thread = nil
162
163
  @host_registry = host_registry || HostRegistry.new
163
164
  # A model given here (a worker's session model) is the one it runs,
164
165
  # so its host is checked; the config default is only checked once it
@@ -240,7 +241,7 @@ module Samagotchi
240
241
  # start: their names for the messages they wrote.
241
242
  @participants = SessionParticipants.new(state_dir: -> { session_state_dir })
242
243
  # Whether the system prompt carries the participants line.
243
- @participants_line = false
244
+ @participants_line = nil
244
245
  # Who is in the review now, and who came or went (#announce_participants).
245
246
  @participants_watch = ParticipantsWatch.new(session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
246
247
  @session = nil
@@ -381,6 +382,9 @@ module Samagotchi
381
382
  @kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
382
383
  @kernel.guardrail_gate = @guardrail_wiring.gate
383
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 }
384
388
  # The turn-end warm-up; the kernel asks it for a slot pin per request.
385
389
  @prompt_warmup = PromptWarmup.new
386
390
  @kernel.warmup = @prompt_warmup
@@ -410,7 +414,10 @@ module Samagotchi
410
414
  # from config on every ask, and the running turn's cancel controller
411
415
  # closes a judgment that is no longer wanted.
412
416
  system_one: -> { SystemOne::Client.from_config(host_registry: @host_registry) },
413
- 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) }
414
421
  )
415
422
  end
416
423
  private :build_guardrail_wiring
@@ -1115,6 +1122,8 @@ module Samagotchi
1115
1122
  thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
1116
1123
  end
1117
1124
  @toolkit_audit_thread&.join(TOOLKIT_AUDIT_JOIN_SECONDS)
1125
+ # The warm-up's answer is dropped anyway.
1126
+ stop_toolkit_warmup
1118
1127
  left = threads.count(&:alive?)
1119
1128
  Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
1120
1129
  @plugin_tasks.kill_leftovers
@@ -1395,6 +1404,34 @@ module Samagotchi
1395
1404
 
1396
1405
  def session_state_dir = @session_state_dir || Session.default_state_dir
1397
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
+
1398
1435
  # The running turn's metrics so far, saved as a turn a worker died in
1399
1436
  # would leave them (SessionMetrics#persist's running_turn): a worker's
1400
1437
  # turn checkpoint (TurnCheckpoint) calls it when it writes.
@@ -1500,14 +1537,15 @@ module Samagotchi
1500
1537
  change
1501
1538
  end
1502
1539
 
1503
- # Reads the session's participants at a turn's start; the first to join
1504
- # and the last to leave rebuild the system prompt (its participants
1505
- # 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.
1506
1544
  def refresh_participants!(session)
1507
- active = @participants.refresh!(session).active?
1508
- return if active == @participants_line
1545
+ line = @participants.refresh!(session).prompt_mode(session)
1546
+ return if line == @participants_line
1509
1547
 
1510
- @participants_line = active
1548
+ @participants_line = line
1511
1549
  @prompt_builder.reset!
1512
1550
  end
1513
1551
  private :refresh_participants!
@@ -1527,8 +1565,9 @@ module Samagotchi
1527
1565
  # A participant's opening message keeps the rules it was delivered under
1528
1566
  # (ParticipantMode), stamped before any request carries it, so its header
1529
1567
  # never changes when the session's mode does.
1530
- def participant_mode_fields(author, session: @session)
1531
- Steer.participant_author?(author) ? { participant_mode: ParticipantMode.of(session).to_s } : {}
1568
+ def participant_mode_fields(author)
1569
+ mode = Steer.participant_author?(author) && @guardrail_wiring.turn_mode
1570
+ mode ? { participant_mode: mode.to_s } : {}
1532
1571
  end
1533
1572
 
1534
1573
  # Why a turn stopped (TurnEndings#stop_facts).
@@ -2139,11 +2178,15 @@ module Samagotchi
2139
2178
  turn_started[:cut_after] = steer_cut_after
2140
2179
  # A participant's turn names them (author, author_name) for the UIs'
2141
2180
  # badge; the owner's turn_started stays as it was.
2142
- 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))
2143
2183
  emit_event(turn.on_event, turn.tag(turn_started))
2144
2184
  raise image_error if image_error
2145
2185
 
2146
- start_toolkit_suggestion_audit(turn)
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)
2147
2190
 
2148
2191
  # The window and the profile probes run after run_turn's probe-cancel
2149
2192
  # swap, so a Stop cuts them; a failure ends the turn as turn_failed.
@@ -2159,7 +2202,10 @@ module Samagotchi
2159
2202
  refuse_images!(turn.settings.vision) unless image_refs.empty?
2160
2203
  # A Stop during the probes: the turn ends before the load-failure
2161
2204
  # warnings, which show once, so the next turn still shows them.
2162
- return false if turn.controller.cancelled?
2205
+ if turn.controller.cancelled?
2206
+ toolkit_suggestion&.abandon(:canceled)
2207
+ return false
2208
+ end
2163
2209
 
2164
2210
  announce_guardrail_failures(turn.on_event)
2165
2211
  # Plugins' slow setup that brings tools (an MCP server's first
@@ -2172,9 +2218,12 @@ module Samagotchi
2172
2218
  # session's toolkit (a /toolkit change since the last turn): what the
2173
2219
  # prompt built next declares, unchanged until the next turn's start.
2174
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)
2175
2224
  apply_toolkit!(session, turn.on_event)
2176
2225
  refresh_participants!(session)
2177
- # A Stop during the wait above: the turn ends here,
2226
+ # A Stop during the waits above: the turn ends here,
2178
2227
  # before the hooks run or a reminder is used up.
2179
2228
  return false if turn.controller.cancelled?
2180
2229
 
@@ -2191,6 +2240,10 @@ module Samagotchi
2191
2240
 
2192
2241
  turn_messages(turn, image_refs)
2193
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)
2194
2247
  end
2195
2248
  private :prepare_turn
2196
2249
 
@@ -2306,7 +2359,7 @@ module Samagotchi
2306
2359
 
2307
2360
  unless turn.continue
2308
2361
  user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
2309
- **participant_mode_fields(turn.origin.author, session: session), MessageTime::KEY => turn.at }
2362
+ **participant_mode_fields(turn.origin.author), MessageTime::KEY => turn.at }
2310
2363
  user_message[:images] = image_refs unless image_refs.empty?
2311
2364
  turn.messages << user_message
2312
2365
  session.last_prompt = turn.prompt
@@ -2729,20 +2782,22 @@ module Samagotchi
2729
2782
  def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
2730
2783
  private :apply_toolkit!
2731
2784
 
2732
- # Start an audit-only classifier after :turn_started. All session and
2733
- # tool-registry reads happen here; the thread receives only this snapshot.
2734
- def start_toolkit_suggestion_audit(turn)
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)
2735
2794
  mode = Config.get("toolkit.suggest.mode").to_s
2736
2795
  return if mode == "off"
2737
2796
 
2797
+ joined = mode == "on"
2738
2798
  session = turn.session
2739
2799
  default = Toolkit.of(session.toolkit)
2740
- path = ToolkitSuggest.log_path(session_state_dir)
2741
- log = SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
2742
- warn: lambda { |reason|
2743
- Log.warn(:toolkit, "toolkit_suggest_log_failed",
2744
- echo: "[samagotchi:toolkit] #{reason}")
2745
- })
2800
+ log = toolkit_suggestion_log
2746
2801
  # The predicate checks the session first; the kits (memory kits are
2747
2802
  # read from disk) and the log (read whole) only when it gets there.
2748
2803
  kits = nil
@@ -2769,11 +2824,19 @@ module Samagotchi
2769
2824
  client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
2770
2825
  return unless client
2771
2826
 
2772
- if toolkit_judge_shares_local_host?(client.host)
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)
2773
2832
  Log.info(:toolkit, "toolkit_suggest_shared_host_skipped", session_id: session.id, host: client.host)
2774
2833
  return
2775
2834
  end
2776
2835
 
2836
+ # The warm-up's only value is the cache: the real ask never queues
2837
+ # behind it.
2838
+ stop_toolkit_warmup
2839
+
2777
2840
  # Rules, not tool lists: this runs before await_init_tasks and
2778
2841
  # apply_staged_tools!, so a first turn's plugin/MCP tools aren't
2779
2842
  # registered yet; the report expands the rules (ToolkitSuggest.rules).
@@ -2794,16 +2857,124 @@ module Samagotchi
2794
2857
  state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
2795
2858
  )
2796
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
2797
2865
  @toolkit_audit_thread = Thread.new do
2798
- ToolkitSuggest.ask(client: client, log: log, snapshot: snapshot, threshold: threshold)
2799
- rescue StandardError => e
2800
- ToolkitSuggest.warn(e)
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)
2801
2955
  end
2802
2956
  rescue StandardError => e
2803
2957
  ToolkitSuggest.warn(e)
2804
2958
  nil
2805
2959
  end
2806
- private :start_toolkit_suggestion_audit
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
2807
2978
 
2808
2979
  # D7: System 1's host is the session's model server, and a local one
2809
2980
  # (a single-slot Splash or llama.cpp): the same name, or two hosts:
@@ -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"