samagotchi 0.122.0 → 0.124.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/bin/chi +13 -2
  4. data/docs/architecture.md +36 -7
  5. data/docs/cli.md +48 -3
  6. data/docs/configuration.md +31 -6
  7. data/docs/desktop.md +51 -3
  8. data/docs/guardrails.md +112 -15
  9. data/docs/internals/engine.md +11 -4
  10. data/docs/internals/participants.md +67 -17
  11. data/docs/internals/prompt-caching.md +19 -7
  12. data/docs/internals/web-push.md +7 -3
  13. data/docs/sessions.md +7 -3
  14. data/docs/testing.md +2 -1
  15. data/lib/samagotchi/bridge.rb +4 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +7 -1
  18. data/lib/samagotchi/delivery.rb +7 -0
  19. data/lib/samagotchi/desktop/macos/App.swift +9 -1
  20. data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
  21. data/lib/samagotchi/desktop/macos/Images.swift +4 -3
  22. data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
  23. data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
  24. data/lib/samagotchi/engine.rb +200 -29
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +45 -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/participant_mode.rb +77 -3
  40. data/lib/samagotchi/participants.rb +7 -5
  41. data/lib/samagotchi/review_apply.rb +10 -2
  42. data/lib/samagotchi/review_start.rb +38 -10
  43. data/lib/samagotchi/run_options.rb +43 -2
  44. data/lib/samagotchi/session_access/local.rb +5 -2
  45. data/lib/samagotchi/session_manager.rb +11 -0
  46. data/lib/samagotchi/session_participants.rb +18 -3
  47. data/lib/samagotchi/share_command.rb +2 -2
  48. data/lib/samagotchi/steer.rb +7 -1
  49. data/lib/samagotchi/system_one/client.rb +10 -2
  50. data/lib/samagotchi/system_prompt.rb +20 -4
  51. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  52. data/lib/samagotchi/tool_runner.rb +10 -1
  53. data/lib/samagotchi/toolkit_binding.rb +34 -0
  54. data/lib/samagotchi/toolkit_report_command.rb +6 -4
  55. data/lib/samagotchi/toolkit_suggest.rb +181 -15
  56. data/lib/samagotchi/tools/builtins.rb +14 -1
  57. data/lib/samagotchi/update_command.rb +47 -19
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +204 -9
  60. data/lib/samagotchi/web/grants.rb +17 -5
  61. data/lib/samagotchi/web/message_display.rb +6 -1
  62. data/lib/samagotchi/web/participant_review.rb +3 -1
  63. data/lib/samagotchi/web/participant_view.rb +12 -4
  64. data/lib/samagotchi/web/participants_admin.rb +10 -6
  65. data/lib/samagotchi/web/public/app.js +73 -10
  66. data/lib/samagotchi/web/public/data.js +17 -0
  67. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  68. data/lib/samagotchi/web/public/index.html +21 -2
  69. data/lib/samagotchi/web/public/participant.html +1 -1
  70. data/lib/samagotchi/web/public/participant.js +10 -12
  71. data/lib/samagotchi/web/public/participant_model.js +20 -8
  72. data/lib/samagotchi/web/public/participant_names.js +29 -8
  73. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  74. data/lib/samagotchi/web/public/question_card.js +12 -2
  75. data/lib/samagotchi/web/public/question_cards.js +7 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  77. data/lib/samagotchi/web/public/toasts.js +13 -5
  78. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  79. data/lib/samagotchi/web/public/turn_events.js +5 -4
  80. data/lib/samagotchi/web/public/turn_html.js +2 -2
  81. data/lib/samagotchi/web/public/turn_view.js +6 -4
  82. data/lib/samagotchi/web/public/update_notice.js +33 -4
  83. data/lib/samagotchi/web/public/update_sheet.js +158 -0
  84. data/lib/samagotchi/web/push/notifier.rb +28 -1
  85. data/lib/samagotchi/web/server.rb +121 -8
  86. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  87. data/lib/samagotchi/web/session_hub.rb +52 -3
  88. data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
  89. data/lib/samagotchi/web/update_check.rb +186 -0
  90. data/lib/samagotchi/worker.rb +44 -6
  91. metadata +7 -1
@@ -0,0 +1,272 @@
1
+ // The panel's chi side without the panel: what a send to chi sessions runs
2
+ // (SendPlan, built from the panel's state, run by PanelController), and the
3
+ // build spec's seams: `ChiHelper --sessions` prints the session rows the
4
+ // panel lists, `ChiHelper --send-plan` the chi calls a send would make.
5
+ import Foundation
6
+
7
+ /// A send to chi sessions as the chi calls it makes: `chi send` (a message,
8
+ /// or `--new` on a new row, this Mac's or a remote's) or `chi note`, one per
9
+ /// machine. Pure: nothing runs here.
10
+ struct SendPlan {
11
+ /// One chi call: its args after the launch file's argv, its stdin, its
12
+ /// name in the panel's lines ("chi send"), the remote it goes to and its
13
+ /// time limit.
14
+ struct Call: Equatable {
15
+ let command: String
16
+ let args: [String]
17
+ let stdin: Data?
18
+ var remote: String?
19
+ var timeout: TimeInterval = 10
20
+ }
21
+
22
+ /// A session on a machine: nil is this Mac.
23
+ struct Target: Equatable {
24
+ let remote: String?
25
+ let id: String
26
+ }
27
+
28
+ let kind: SendKind
29
+ /// UUID-shaped session ids and their machines, in list order; not used
30
+ /// with +new+.
31
+ let targets: [Target]
32
+ let new: Bool
33
+ /// The remote a new session starts on; nil: this Mac.
34
+ let newRemote: String?
35
+ /// Where a new session starts (PanelModel.newSessionDir, or
36
+ /// newSessionDir(remote:) on a remote); nil: nowhere known, no call.
37
+ let newDir: String?
38
+ /// `--model` for `chi send --new` on this Mac, verbatim; nil for the
39
+ /// default. Never on a remote (D4: the list is this Mac's).
40
+ let model: String?
41
+ /// The message line, trimmed; `-m` of `chi send`.
42
+ let prompt: String
43
+ /// A message's stdin: the quoted context; nil when there is none.
44
+ let context: String?
45
+ /// `chi note`'s stdin: the message line, then the context.
46
+ let note: String
47
+ /// `chi note --source`, trimmed; "" for none.
48
+ let source: String
49
+ /// `--image` paths; a note's are dropped before (text only).
50
+ let images: [String]
51
+
52
+ /// The panel's state for this send.
53
+ static func from(_ model: PanelModel, kind: SendKind, sessions: [LiveSession], new: Bool, images: [PanelImage]) -> SendPlan {
54
+ let newRemote = new ? model.newRemote : nil
55
+ let newDir = newRemote.map(model.newSessionDir(remote:)) ?? model.newSessionDir
56
+ return SendPlan(kind: kind, targets: sessions.map { Target(remote: $0.remote, id: $0.id) }, new: new, newRemote: newRemote,
57
+ newDir: newDir, model: newRemote == nil ? model.modelForSend : nil,
58
+ prompt: model.trimmedPrompt, context: model.hasContext ? model.text : nil, note: model.noteText,
59
+ source: model.source.trimmingCharacters(in: .whitespacesAndNewlines), images: images.map(\.url.path))
60
+ }
61
+
62
+ /// This Mac's call first, then one per remote in list order. A session
63
+ /// goes once, to the first machine it is under; a remote whose name chi
64
+ /// wouldn't take is left out (the listing never makes one).
65
+ var calls: [Call] {
66
+ if new {
67
+ guard newDir != nil, newRemote.map(Remotes.valid) ?? true else { return [] }
68
+ return [call(ids: [], remote: newRemote)]
69
+ }
70
+ var machines: [String?] = []
71
+ var ids: [String?: [String]] = [:]
72
+ var seen = Set<String>()
73
+ let ordered = targets.filter { $0.remote == nil } + targets.filter { $0.remote != nil }
74
+ for target in ordered where target.remote.map(Remotes.valid) ?? true && seen.insert(target.id).inserted {
75
+ if ids[target.remote] == nil { machines.append(target.remote) }
76
+ ids[target.remote, default: []].append(target.id)
77
+ }
78
+ return machines.map { call(ids: ids[$0] ?? [], remote: $0) }
79
+ }
80
+
81
+ /// Reading, converting and a new worker's start take longer than text;
82
+ /// a remote's text, waking a stopped worker there, too.
83
+ private func call(ids: [String], remote: String?) -> Call {
84
+ var args: [String]
85
+ let command: String
86
+ let stdin: Data?
87
+ switch kind {
88
+ case .message, .paste:
89
+ // stdin is the quoted context; without -m, chi takes it as the message.
90
+ command = "chi send"
91
+ args = new ? ["send", "--new", "--dir", newDir ?? ""] : ["send"]
92
+ // The row's name verbatim; none for the default, nor on a remote.
93
+ if new, remote == nil, let model { args += ["--model", model] }
94
+ if !prompt.isEmpty { args += ["-m", prompt] }
95
+ args += images.flatMap { ["--image", $0] }
96
+ stdin = context.map { Data($0.utf8) }
97
+ case .note:
98
+ command = "chi note"
99
+ args = ["note"]
100
+ if !source.isEmpty { args += ["--source", source] }
101
+ stdin = Data(note.utf8)
102
+ }
103
+ if !new { args += ids }
104
+ if let remote { args = ["--remote", remote] + args }
105
+ let timeout: TimeInterval = !images.isEmpty && kind != .note ? 30 : remote == nil ? 10 : 15
106
+ return Call(command: command, args: args, stdin: stdin, remote: remote, timeout: timeout)
107
+ }
108
+ }
109
+
110
+ private func printJSON(_ object: Any) {
111
+ let data = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
112
+ print(String(decoding: data, as: UTF8.self))
113
+ }
114
+
115
+ /// `ChiHelper --sessions [--launch PATH] [--last KEY]... [--order
116
+ /// local-first|remotes-first] [--click KEY]...`: the panel's session rows in
117
+ /// its order (live, then recent), the new row's folder, a section per remote
118
+ /// (rows not listed before, or its line, and its new row's folder) and what
119
+ /// the panel preselects with KEYs as the remembered choice (none by default:
120
+ /// never the user's) when the answers come in that order (this Mac's first
121
+ /// by default), then after the clicks (a row's key, "new", "new:REMOTE" for
122
+ /// a remote's new row, "model" for ⌘M), as JSON ({"rows": [{"id", "desc",
123
+ /// "cwd", "busy", "recent"}], "new_dir", "remotes": [{"name", "rows" (each
124
+ /// with "remote"), "error", "new_dir" (null: no new row)}], "selected",
125
+ /// "new_selected", "new_remote", "picker_open", "message", "hint"}); a
126
+ /// failed listing of this Mac's is its message on stderr, exit 1.
127
+ func sessionsCommand(_ args: [String]) -> Int32 {
128
+ var args = args
129
+ var launchPath = ChiRunner.launchPath
130
+ var last: [String] = []
131
+ var remotesFirst = false
132
+ var clicks: [String] = []
133
+ let usage = "usage: ChiHelper --sessions [--launch PATH] [--last KEY]... [--order local-first|remotes-first] [--click KEY]...\n"
134
+ while !args.isEmpty {
135
+ let arg = args.removeFirst()
136
+ switch arg {
137
+ case "--launch" where !args.isEmpty: launchPath = args.removeFirst()
138
+ case "--last" where !args.isEmpty: last.append(args.removeFirst())
139
+ case "--click" where !args.isEmpty: clicks.append(args.removeFirst())
140
+ case "--order" where !args.isEmpty:
141
+ let order = args.removeFirst()
142
+ guard ["local-first", "remotes-first"].contains(order) else {
143
+ FileHandle.standardError.write(usage.data(using: .utf8)!)
144
+ return 2
145
+ }
146
+ remotesFirst = order == "remotes-first"
147
+ default:
148
+ FileHandle.standardError.write(usage.data(using: .utf8)!)
149
+ return 2
150
+ }
151
+ }
152
+ let runner = ChiRunner(launchPath: launchPath)
153
+ // As the panel: this Mac's lists beside `remote ls`, then each remote's
154
+ // at once. All are awaited, then fed to the panel's model in the order
155
+ // --order says (the panel takes them as they come).
156
+ let queue = DispatchQueue(label: "sessions-command")
157
+ let group = DispatchGroup()
158
+ var local: Result<[LiveSession], ChiError>?
159
+ var names: [String] = []
160
+ var answers: [String: Result<[LiveSession], ChiError>] = [:]
161
+ group.enter()
162
+ runner.sessions(queue: queue) { local = $0; group.leave() }
163
+ group.enter()
164
+ runner.remotes(queue: queue) { found in
165
+ names = found
166
+ for name in found {
167
+ group.enter()
168
+ runner.sessions(remote: name, queue: queue) { answers[name] = $0; group.leave() }
169
+ }
170
+ group.leave()
171
+ }
172
+ group.wait()
173
+ let model = PanelModel()
174
+ model.lastChoice = last
175
+ model.startRemotes(names)
176
+ let showRemotes = { for name in names { model.showRemote(name, answers[name]!) } }
177
+ if remotesFirst { showRemotes() }
178
+ model.showSessions(local!)
179
+ if !remotesFirst { showRemotes() }
180
+ for key in clicks {
181
+ switch key {
182
+ case "new": model.toggleNew()
183
+ case "model": _ = model.pressModel()
184
+ case let key where key.hasPrefix("new:"): model.toggleNew(remote: String(key.dropFirst(4)))
185
+ default: model.toggle(key)
186
+ }
187
+ }
188
+ switch local! {
189
+ case .failure(let error):
190
+ FileHandle.standardError.write("\(error.description)\n".data(using: .utf8)!)
191
+ return 1
192
+ case .success:
193
+ let row = { (s: LiveSession) -> [String: Any] in
194
+ var row: [String: Any] = ["id": s.id, "desc": s.desc ?? NSNull(), "cwd": s.cwd ?? NSNull(), "busy": s.busy ?? NSNull(),
195
+ "recent": s.recent]
196
+ if let remote = s.remote { row["remote"] = remote }
197
+ return row
198
+ }
199
+ let remotes: [[String: Any]] = model.remoteGroups.map { group in
200
+ ["name": group.section.name, "rows": group.rows.map(row), "error": group.section.error ?? NSNull(),
201
+ "new_dir": model.newSessionDir(remote: group.section.name) ?? NSNull()]
202
+ }
203
+ printJSON(["rows": model.sessions.map(row), "new_dir": model.newSessionDir, "remotes": remotes,
204
+ "selected": model.selected.sorted(), "new_selected": model.newSelected, "new_remote": model.newRemote ?? NSNull(),
205
+ "picker_open": model.pickerOpen, "message": model.message, "hint": model.machineHint])
206
+ return 0
207
+ }
208
+ }
209
+
210
+ /// `ChiHelper --send-plan message|note|paste [--id [REMOTE:]ID]... [--new [REMOTE:]DIR]
211
+ /// [--default MODEL] [--model MODEL] [--prompt P] [--source S] [--image PATH]...`:
212
+ /// the panel's text on stdin; prints the calls SendPlan makes as JSON
213
+ /// ([{"command", "args", "stdin", "timeout"}], stdin null when none). An id
214
+ /// as a row's key: "studio:<id>" on a remote. --new is the new row, DIR the
215
+ /// folder of its only (live) session; with REMOTE: (DIR absolute), that
216
+ /// remote's new row; --default is the configured model, --model the pick.
217
+ func sendPlanCommand(_ args: [String]) -> Int32 {
218
+ let usage = "usage: ChiHelper --send-plan message|note|paste [--id [REMOTE:]ID]... [--new [REMOTE:]DIR] [--default MODEL] "
219
+ + "[--model MODEL] "
220
+ + "[--prompt P] [--source S] [--image PATH]...\n"
221
+ var args = args
222
+ let kinds: [String: SendKind] = ["message": .message, "note": .note, "paste": .paste]
223
+ guard let first = args.first, let kind = kinds[first] else {
224
+ FileHandle.standardError.write(usage.data(using: .utf8)!)
225
+ return 2
226
+ }
227
+ args.removeFirst()
228
+ let model = PanelModel()
229
+ var sessions: [LiveSession] = [], newDir: String?, images: [PanelImage] = []
230
+ while !args.isEmpty {
231
+ let arg = args.removeFirst()
232
+ guard !args.isEmpty else { FileHandle.standardError.write(usage.data(using: .utf8)!); return 2 }
233
+ let value = args.removeFirst()
234
+ switch arg {
235
+ case "--id":
236
+ // A UUID has no ":", so the last one splits off the remote.
237
+ let colon = value.lastIndex(of: ":")
238
+ var session = LiveSession(id: colon.map { String(value[value.index(after: $0)...]) } ?? value, desc: nil, cwd: nil,
239
+ busy: nil, owner: nil, updatedAt: nil)
240
+ session.remote = colon.map { String(value[..<$0]) }
241
+ sessions.append(session)
242
+ case "--new" where value.hasPrefix("/"): newDir = value
243
+ case "--new":
244
+ guard let colon = value.firstIndex(of: ":") else { FileHandle.standardError.write(usage.data(using: .utf8)!); return 2 }
245
+ let name = String(value[..<colon])
246
+ var session = LiveSession(id: UUID().uuidString, desc: nil, cwd: String(value[value.index(after: colon)...]), busy: nil,
247
+ owner: nil, updatedAt: nil)
248
+ session.remote = name
249
+ model.startRemotes([name])
250
+ model.showRemote(name, .success([session]))
251
+ model.newRemote = name
252
+ case "--default": model.newModel = value
253
+ case "--model": model.pickedModel = value
254
+ case "--prompt": model.prompt = value
255
+ case "--source": model.source = value
256
+ case "--image": images.append(PanelImage(url: URL(fileURLWithPath: value), temp: false))
257
+ default:
258
+ FileHandle.standardError.write(usage.data(using: .utf8)!)
259
+ return 2
260
+ }
261
+ }
262
+ model.text = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self)
263
+ if let newDir {
264
+ model.sessions = [LiveSession(id: UUID().uuidString, desc: nil, cwd: newDir, busy: nil, owner: nil, updatedAt: nil)]
265
+ }
266
+ let plan = SendPlan.from(model, kind: kind, sessions: sessions, new: newDir != nil || model.newRemote != nil, images: images)
267
+ printJSON(plan.calls.map { call -> [String: Any] in
268
+ ["command": call.command, "args": call.args, "timeout": call.timeout,
269
+ "stdin": call.stdin.map { String(decoding: $0, as: UTF8.self) } ?? NSNull()]
270
+ })
271
+ return 0
272
+ }
@@ -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: