samagotchi 0.9.0 → 0.10.1

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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +5 -3
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +36 -13
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +13 -4
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +18 -9
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. metadata +12 -1
@@ -46,18 +46,18 @@ module Samagotchi
46
46
  # @param last_event_id [String, nil] reconnect cursor (or ?from_seq=):
47
47
  # `<seq>-<epoch>` or a plain seq
48
48
  # @param epoch [String, nil] this worker's epoch, in every frame id
49
- # @param snapshot_provider [#call] -> {status:, message_count:,
50
- # last_prompt:, event_seq:} (Engine#session_state_snapshot)
51
- # @param turn_snapshot_provider [#call, nil] -> {messages:, current_turn:,
52
- # queued:, event_seq:}; called with the event log held
53
- # (Bridge#snapshot). Defaults to just the event_seq.
49
+ # @param frame_provider [#call, nil] -> {snapshot: {messages:,
50
+ # current_turn:, queued:, event_seq:, ...}, session_state_snapshot:
51
+ # {status:, message_count:, ...}}; called with the event log held, so
52
+ # both describe the same event_seq (Bridge#snapshot_frame). Defaults
53
+ # to just the event_seq.
54
54
  # @param join_with_snapshot [Boolean] start with a snapshot frame rather
55
55
  # than a replay
56
56
  # @param bridge [Samagotchi::Bridge, nil] for stop signalling
57
57
  # @param max_queue [Integer] bounded-queue capacity
58
58
  # @param heartbeat_interval [Float] idle-seconds between `: ping` frames
59
59
  def initialize(engine:, ring:, session_id:, last_event_id: nil, epoch: nil,
60
- snapshot_provider:, turn_snapshot_provider: nil, join_with_snapshot: false, bridge: nil,
60
+ frame_provider: nil, join_with_snapshot: false, bridge: nil,
61
61
  max_queue: DEFAULT_MAX_QUEUE,
62
62
  heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL)
63
63
  @engine = engine
@@ -65,8 +65,7 @@ module Samagotchi
65
65
  @session_id = session_id
66
66
  @last_event_id = last_event_id
67
67
  @epoch = epoch
68
- @snapshot_provider = snapshot_provider
69
- @turn_snapshot_provider = turn_snapshot_provider || -> { { event_seq: @engine.event_count } }
68
+ @frame_provider = frame_provider || -> { { snapshot: { event_seq: @engine.event_count }, session_state_snapshot: {} } }
70
69
  @join_with_snapshot = join_with_snapshot
71
70
  @bridge = bridge
72
71
  @max_queue = max_queue
@@ -78,8 +77,6 @@ module Samagotchi
78
77
  @foreign_cursor = !cursor_epoch.nil? && cursor_epoch != @epoch
79
78
  @high_water = @foreign_cursor ? 0 : cursor_seq
80
79
  @handle = nil
81
- @serving = false
82
- @mutex = Monitor.new
83
80
  end
84
81
 
85
82
  # Non-blocking enqueue used as the per-connection live observer. Any
@@ -99,16 +96,15 @@ module Samagotchi
99
96
  # Run the serve loop on the caller's (connection) thread. Blocks until
100
97
  # the client disconnects or the bridge stops.
101
98
  def serve!(io)
102
- @serving = true
103
99
  write_sse_headers(io)
104
100
  begin
105
101
  if @join_with_snapshot
106
102
  # Subscribe and snapshot as one step of the event log, then go live.
107
- snapshot = @engine.synchronize_events do
103
+ frame = @engine.synchronize_events do
108
104
  @handle = @engine.subscribe(observer: self)
109
105
  take_snapshot
110
106
  end
111
- write_snapshot_frame(io, :snapshot, snapshot)
107
+ write_snapshot_frame(io, :snapshot, frame)
112
108
  else
113
109
  # (1) subscribe the live queue FIRST.
114
110
  @handle = @engine.subscribe(observer: self)
@@ -122,7 +118,6 @@ module Samagotchi
122
118
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
123
119
  # Client hung up; nothing to do.
124
120
  ensure
125
- @serving = false
126
121
  @engine.unsubscribe(handle: @handle) if @handle
127
122
  end
128
123
  end
@@ -222,23 +217,22 @@ module Samagotchi
222
217
  write_snapshot_frame(io, :reset, @engine.synchronize_events { take_snapshot })
223
218
  end
224
219
 
225
- # With the event log held: the snapshot, and the high-water mark moved
226
- # to its seq (down too, for a cursor from before a worker restart), so
227
- # events after it pass #call and events it covers are skipped.
220
+ # With the event log held: the snapshot frame's content (the snapshot
221
+ # and the session state, both as of one event_seq), and the high-water
222
+ # mark moved to that seq (down too, for a cursor from before a worker
223
+ # restart), so events after it pass #call and events it covers are
224
+ # skipped.
228
225
  def take_snapshot
229
- snapshot = @turn_snapshot_provider.call
230
- @high_water = snapshot[:event_seq].to_i
231
- snapshot.merge(event_id: EventId.format(@high_water, @epoch))
226
+ frame = @frame_provider.call
227
+ seq = frame[:snapshot][:event_seq].to_i
228
+ @high_water = seq
229
+ id = EventId.format(seq, @epoch)
230
+ { snapshot: frame[:snapshot].merge(event_id: id),
231
+ session_state_snapshot: frame[:session_state_snapshot].merge(event_seq: seq, event_id: id) }
232
232
  end
233
233
 
234
- def write_snapshot_frame(io, type, snapshot)
235
- seq = snapshot[:event_seq]
236
- state = @snapshot_provider.call.merge(event_seq: seq, event_id: snapshot[:event_id])
237
- write_frame(io, seq: seq, data: { type: type, snapshot: snapshot, session_state_snapshot: state })
238
- end
239
-
240
- def closed?
241
- @mutex.synchronize { !@serving }
234
+ def write_snapshot_frame(io, type, frame)
235
+ write_frame(io, seq: frame[:snapshot][:event_seq], data: { type: type, **frame })
242
236
  end
243
237
  end
244
238
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../events"
3
4
  require_relative "../kernel_loop"
4
5
  require_relative "../steer"
5
6
  require_relative "turn_notice"
@@ -15,12 +16,15 @@ module Samagotchi
15
16
  # its terminal event, the same event that brings its messages into the
16
17
  # session, so a snapshot shows a turn in exactly one of the two.
17
18
  class TurnAccumulator
18
- TERMINAL_EVENTS = %i[turn_completed turn_canceled turn_failed].freeze
19
-
20
- def initialize(max_output_chars: KernelLoop::DEFAULT_MAX_TOOL_OUTPUT_CHARS)
19
+ # @param clock [#call] monotonic seconds: a tool call's duration
20
+ def initialize(max_output_chars: KernelLoop::DEFAULT_MAX_TOOL_OUTPUT_CHARS,
21
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
21
22
  @max_output_chars = max_output_chars
23
+ @clock = clock
22
24
  @mutex = Mutex.new
23
25
  @turn = nil
26
+ # When each running tool call started, by [iteration, call_index].
27
+ @tool_started_at = {}
24
28
  @queued = []
25
29
  @merged_origins = []
26
30
  @recap = nil
@@ -36,7 +40,8 @@ module Samagotchi
36
40
  # @return [Hash, nil] a copy of the turn in progress: prompt, origin,
37
41
  # continue, ordered parts (thinking / text / tool / input / steer /
38
42
  # reminder / notice), pending_question and the last event_seq folded
39
- # in. A notice part holds the event of one of the turn's rows
43
+ # in. A finished tool part keeps the live row's action and duration_ms
44
+ # too. A notice part holds the event of one of the turn's rows
40
45
  # (TurnNotice: a hook's notice, an empty-answer retry, a question and
41
46
  # its answer), which a UI replays through its live handler.
42
47
  def current_turn
@@ -81,6 +86,80 @@ module Samagotchi
81
86
  messages
82
87
  end
83
88
 
89
+ # The turn in progress (#current_turn's shape, symbol keys) as the live
90
+ # events that would have drawn it, in order, as turn_events.js
91
+ # snapshotEvents replays it for the web (spec/shared/turn_snapshot.json
92
+ # pins both): a generation_chunk per thinking or text part (a
93
+ # generation_completed closes text before the next step's), a tool's
94
+ # tool_call_started and, once finished, its tool_call_completed (with
95
+ # the live activity: action, tool, params, status), a merged_input
96
+ # (the one synthetic type) per merged prompt, a steer-only
97
+ # pending_input_merged per steer, a reminder_injected, and a notice
98
+ # part's own event.
99
+ # @param started_at [String, nil] the turn's start, on turn_started
100
+ # @return [Array<Hash>] symbol keys and types; [] with no turn
101
+ def self.replay_events(turn, started_at: nil)
102
+ return [] unless turn
103
+
104
+ started = { type: :turn_started, prompt: turn[:prompt], origin: turn[:origin], continue: !!turn[:continue] }
105
+ started[:started_at] = started_at if started_at
106
+ events = [with_images(started, turn[:images])]
107
+ text_iteration = nil # a text part is open for this iteration
108
+ close_text = lambda do
109
+ events << { type: :generation_completed } unless text_iteration.nil?
110
+ text_iteration = nil
111
+ end
112
+ Array(turn[:parts]).each do |part|
113
+ case part[:kind]
114
+ when "thinking" then events << { type: :generation_chunk, text: "", thinking: part[:text], iteration: part[:iteration] }
115
+ when "text"
116
+ close_text.call if !text_iteration.nil? && text_iteration != part[:iteration]
117
+ text_iteration = part[:iteration]
118
+ events << { type: :generation_chunk, text: part[:text], thinking: "", iteration: part[:iteration] }
119
+ when "tool"
120
+ close_text.call
121
+ events.concat(tool_replay_events(part))
122
+ when "input"
123
+ close_text.call
124
+ events << { type: :merged_input, content: part[:text], origins: part[:origins] || [] }
125
+ when "reminder"
126
+ close_text.call
127
+ events << { type: :reminder_injected, reminders: part[:reminders] || [] }
128
+ when "notice"
129
+ close_text.call
130
+ notice = part[:event]
131
+ events << notice.merge(type: notice[:type].to_sym) if notice.is_a?(Hash) && notice[:type]
132
+ when "steer"
133
+ close_text.call
134
+ events << { type: :pending_input_merged, count: 0, content: nil, steers: [{ source: part[:source], text: part[:text] }] }
135
+ end
136
+ end
137
+ events
138
+ end
139
+
140
+ def self.tool_replay_events(part)
141
+ call = { iteration: part[:iteration], call_index: part[:call_index], tool: part[:tool] }
142
+ call[:label] = part[:label] if part[:label]
143
+ title = part[:title] ? { title: part[:title] } : {}
144
+ events = [{ type: :tool_call_started, **call, params: part[:params], **title }]
145
+ return events if part[:status] == "running"
146
+
147
+ activity = { tool: part[:tool], status: part[:status], params: part[:params], **title }
148
+ activity[:action] = part[:action] if part[:action]
149
+ completed = { type: :tool_call_completed, **call, output: part[:output], output_truncated: !!part[:output_truncated],
150
+ activity: activity }
151
+ completed[:duration_ms] = part[:duration_ms] unless part[:duration_ms].nil?
152
+ completed[:diff] = part[:diff] if part[:diff]
153
+ events << with_images(completed, part[:images])
154
+ end
155
+ private_class_method :tool_replay_events
156
+
157
+ # +event+ with images: when there are any.
158
+ def self.with_images(event, images)
159
+ Array(images).empty? ? event : event.merge(images: images)
160
+ end
161
+ private_class_method :with_images
162
+
84
163
  # @return [String, nil] the last idle recap, until a turn makes it stale
85
164
  def recap
86
165
  @mutex.synchronize { @recap }
@@ -119,6 +198,7 @@ module Samagotchi
119
198
  dequeue([event[:origin]])
120
199
  @turn = { prompt: event[:prompt], origin: event[:origin], continue: !!event[:continue],
121
200
  parts: [], pending_question: nil }
201
+ @tool_started_at = {}
122
202
  @turn[:images] = event[:images] if event[:images]
123
203
  when :input_merged
124
204
  @merged_origins = Array(event[:origins])
@@ -127,7 +207,7 @@ module Samagotchi
127
207
  return unless @turn
128
208
 
129
209
  fold_turn_event(event)
130
- if TERMINAL_EVENTS.include?(type)
210
+ if Events::TURN_END.include?(type)
131
211
  @turn = nil
132
212
  @merged_origins = []
133
213
  else
@@ -152,6 +232,7 @@ module Samagotchi
152
232
  part[:label] = event[:label] if event[:label]
153
233
  part[:title] = event[:title] if event[:title]
154
234
  parts << part
235
+ @tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
155
236
  when :tool_call_completed
156
237
  tool = parts.reverse_each.find do |part|
157
238
  part[:kind] == "tool" && part[:iteration] == event[:iteration] && part[:call_index] == event[:call_index]
@@ -165,6 +246,12 @@ module Samagotchi
165
246
  tool[:output_truncated] = capped || !!event[:output_truncated]
166
247
  tool[:images] = event[:images] if event[:images]
167
248
  tool[:diff] = event[:diff] if event[:diff]
249
+ action = event.dig(:activity, :action)
250
+ tool[:action] = action.to_s if action
251
+ # As the live row times it (EventRenderer): from tool_call_started,
252
+ # less an approval wait.
253
+ started_at = @tool_started_at.delete([event[:iteration], event[:call_index]])
254
+ tool[:duration_ms] = [((@clock.call - started_at) * 1000) - event[:waited_ms].to_f, 0].max.round if started_at
168
255
  when :pending_input_merged
169
256
  # A steer-only merge (count 0) has no user part: the origins stay
170
257
  # for the user lines' own merge.
@@ -51,7 +51,6 @@ module Samagotchi
51
51
  REQUEST_GRACE_SECONDS = 1.0
52
52
  # The largest request body read (images travel as refs, never bytes).
53
53
  MAX_BODY_BYTES = 1_000_000
54
- MAX_TURN_IMAGES = 20
55
54
  # A request's deadline (see #handle_post_turn) that isn't epoch seconds.
56
55
  BAD_DEADLINE = [{ "Allow" => "POST" }, 400, { error: "bad_deadline", detail: "deadline must be epoch seconds" }].freeze
57
56
 
@@ -227,6 +226,18 @@ module Samagotchi
227
226
  end
228
227
  end
229
228
 
229
+ # The snapshot and the session state (status, pending question, ...) as
230
+ # one step of the event log, both as of the same event_seq: what a
231
+ # snapshot or reset frame (SSEWriter) and GET snapshot carry.
232
+ # @return [Hash] {snapshot:, session_state_snapshot:}
233
+ def snapshot_frame
234
+ @engine.synchronize_events do
235
+ snap = snapshot
236
+ state = @engine.session_state_snapshot.merge(event_seq: snap[:event_seq], event_id: snap[:event_id])
237
+ { snapshot: snap, session_state_snapshot: state }
238
+ end
239
+ end
240
+
230
241
  private
231
242
 
232
243
  # The Engine's commands: the built-ins and its plugins'.
@@ -320,7 +331,7 @@ module Samagotchi
320
331
  client_id: stream_client_id(request[:query]))
321
332
  break # SSE owns the connection until the client disconnects.
322
333
  else
323
- payload, status, body = send(route.first, route.last, request[:body])
334
+ payload, status, body = dispatch(route.first, route.last, request[:body])
324
335
  write_json(io, status, payload, body)
325
336
  end
326
337
 
@@ -356,8 +367,7 @@ module Samagotchi
356
367
  session_id: @session_id,
357
368
  last_event_id: last_event_id,
358
369
  epoch: @epoch,
359
- snapshot_provider: -> { @engine.session_state_snapshot },
360
- turn_snapshot_provider: -> { self.snapshot },
370
+ frame_provider: -> { snapshot_frame },
361
371
  # A reconnect (with a cursor) replays; only a fresh join snapshots.
362
372
  join_with_snapshot: snapshot && last_event_id.nil?,
363
373
  bridge: self,
@@ -430,28 +440,33 @@ module Samagotchi
430
440
  [handler, m[1]]
431
441
  end
432
442
 
443
+ # Every route but the stream: only this bridge's own session (see
444
+ # #own_session?), and a handler that raises answers 500 bridge_error,
445
+ # logged (the connection thread doesn't report).
446
+ # @return [Array(Hash, Integer, Hash)] headers, status, body
447
+ def dispatch(handler, session_id, body)
448
+ return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
449
+
450
+ send(handler, session_id, body)
451
+ rescue StandardError => e
452
+ Log.exception(:bridge, "handler_failed", e)
453
+ [{}, 500, { error: "bridge_error", detail: e.message }]
454
+ end
455
+
433
456
  # /recap in an attached TUI: the saved recap, and a new one asked for at
434
457
  # once (it arrives as :recap_ready). Answers mid-turn too.
435
458
  # 200 {enabled:, saved:, request:, min_user_turns:}. Returns [headers, status, body].
436
459
  def handle_recap(session_id, _body = nil)
437
- return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
438
-
439
460
  recap = @engine.recap
440
461
  return [{}, 200, { enabled: false }] unless recap
441
462
 
442
463
  saved = @engine.saved_recap
443
464
  [{}, 200, { enabled: true, saved: saved, request: @engine.request_recap.to_s, min_user_turns: recap.min_user_turns }]
444
- rescue StandardError => e
445
- [{}, 500, { error: "bridge_error", detail: e.message }]
446
465
  end
447
466
 
448
467
  # Cancel the active turn on this session's engine, if any.
449
468
  # Returns [headers, status, body].
450
469
  def handle_cancel(session_id, body)
451
- unless own_session?(session_id)
452
- return [{}, 404, { error: "unknown_session" }]
453
- end
454
-
455
470
  # Optional reason from JSON body
456
471
  reason = :manual
457
472
  if body && !body.strip.empty?
@@ -468,31 +483,19 @@ module Samagotchi
468
483
  else
469
484
  [{}, 409, { error: "not_running", detail: "no active turn to cancel", session_id: @session_id }]
470
485
  end
471
- rescue StandardError => e
472
- [{}, 500, { error: "bridge_error", detail: e.message }]
473
486
  end
474
487
 
475
488
  # Answer the pending question. A past +deadline+ (see #handle_post_turn):
476
489
  # 408 deadline_passed, and the question stays open. An answer takes no
477
490
  # event hold, so it is checked right before it is recorded.
478
491
  def handle_answer(session_id, body)
479
- unless own_session?(session_id)
480
- return [{}, 404, { error: "unknown_session" }]
481
- end
482
492
  parsed = parse_json(body)
483
493
  unless parsed.is_a?(Hash)
484
494
  return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }]
485
495
  end
486
- qid = fetched(parsed, "id") || fetched(parsed, "question_id")
487
- selected = fetched(parsed, "selected") || fetched(parsed, "selection")
488
- freeform = fetched(parsed, "freeform") || fetched(parsed, "other")
489
- # Support nested answer
490
- if parsed["answer"].is_a?(Hash)
491
- ans = parsed["answer"]
492
- qid ||= fetched(ans, "id")
493
- selected ||= fetched(ans, "selected")
494
- freeform ||= fetched(ans, "freeform")
495
- end
496
+ qid = fetched(parsed, "id")
497
+ selected = fetched(parsed, "selected")
498
+ freeform = fetched(parsed, "freeform")
496
499
  if qid.to_s.strip.empty?
497
500
  return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }]
498
501
  end
@@ -508,8 +511,6 @@ module Samagotchi
508
511
  [{}, 409, { error: "question_not_pending", detail: e.message }]
509
512
  rescue ArgumentError => e
510
513
  [{}, 400, { error: "invalid_answer", detail: e.message }]
511
- rescue StandardError => e
512
- [{}, 500, { error: "bridge_error", detail: e.message }]
513
514
  end
514
515
  end
515
516
 
@@ -520,8 +521,6 @@ module Samagotchi
520
521
  # +deadline+ (dismissing an approval denies it): 408, as for an answer.
521
522
  # Returns [headers, status, body].
522
523
  def handle_dismiss_question(session_id, body)
523
- return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
524
-
525
524
  parsed = parse_json(body)
526
525
  return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
527
526
 
@@ -536,8 +535,6 @@ module Samagotchi
536
535
  return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless dismissed
537
536
 
538
537
  [{}, 200, { status: "dismissed", id: qid, session_id: @session_id }]
539
- rescue StandardError => e
540
- [{}, 500, { error: "bridge_error", detail: e.message }]
541
538
  end
542
539
 
543
540
  # Queue a session command (/model, /models, !rollback, !cmd, /continue)
@@ -550,7 +547,6 @@ module Samagotchi
550
547
  # read-only ones (/models) too: its client said it didn't run.
551
548
  # Returns [headers, status, body].
552
549
  def handle_command(session_id, body)
553
- return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
554
550
  return [{}, 501, { error: "commands_unavailable" }] unless @on_command
555
551
 
556
552
  parsed = parse_json(body)
@@ -584,8 +580,6 @@ module Samagotchi
584
580
  return deadline_passed("command") unless queued
585
581
 
586
582
  [{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
587
- rescue StandardError => e
588
- [{}, 500, { error: "bridge_error", detail: e.message }]
589
583
  end
590
584
 
591
585
  # A client asks the worker to exit now (`/exit` in the attached TUI). The
@@ -594,7 +588,6 @@ module Samagotchi
594
588
  # 200 {status: "exiting", discard?: the session is empty and goes}, or 409 {status: "held", reason:} naming what
595
589
  # keeps it up. Returns [headers, status, body].
596
590
  def handle_exit_request(session_id, body)
597
- return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
598
591
  return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
599
592
 
600
593
  parsed = parse_json(body)
@@ -610,11 +603,12 @@ module Samagotchi
610
603
  end
611
604
 
612
605
  [{}, 409, { status: "held", reason: reason.to_s, session_id: @session_id }]
613
- rescue StandardError => e
614
- [{}, 500, { error: "bridge_error", detail: e.message }]
615
606
  end
616
607
 
617
- # Create a turn via file IPC (fire-and-forget). Returns [headers, status, body].
608
+ # Queue a turn for this session through its file IPC (the worker polls
609
+ # its input dir). Returns [headers, status, body]. Only this bridge's own
610
+ # session: another id, in the path or the body, is refused as on every
611
+ # other route.
618
612
  #
619
613
  # A +deadline+ (wall-clock epoch seconds; the client shares this machine's
620
614
  # clock) is when the client stops waiting (BridgeClient#post_turn): a turn
@@ -638,56 +632,40 @@ module Samagotchi
638
632
  return [{ "Allow" => "POST" }, 400,
639
633
  { error: "missing_fields", detail: "session_id and prompt are required" }]
640
634
  end
641
- # Checked before the id names a folder: "../x" would write outside the
642
- # sessions dir (turn_images and enqueue_turn build paths from it).
643
- return [{}, 400, { error: "invalid_session_id" }] unless Session.valid_id?(sid)
635
+ return [{}, 404, { error: "unknown_session" }] unless own_session?(sid)
644
636
 
645
- images = turn_images(sid, fetched(parsed, "images"))
637
+ images = ImageStore.check_refs(session_dir, fetched(parsed, "images"))
646
638
  return [{}, 400, { error: "bad_images", detail: images }] if images.is_a?(String)
647
639
 
648
640
  deadline = fetched(parsed, "deadline")
649
641
  return BAD_DEADLINE unless deadline_valid?(deadline)
650
642
 
651
643
  enqueued_id = SecureRandom.uuid
652
- enqueued =
653
- if own_session?(sid)
654
- # Write and announce with the event log held: the worker can't
655
- # emit this turn's :turn_started (or merge it mid-turn) before
656
- # :turn_enqueued, and a failed write announces nothing.
657
- @engine.synchronize_events do
658
- next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
659
-
660
- enqueue_turn(session_id: sid, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
661
- no_interrupt: no_interrupt, images: images).tap do |ok|
662
- next unless ok
663
-
664
- enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
665
- enqueued_event[:images] = images unless images.empty?
666
- @engine.announce(enqueued_event)
667
- @on_input&.call
668
- end
669
- end
670
- elsif expired?("turn_expired", deadline, sid: sid, client_id: client_id)
671
- :expired
672
- else
673
- enqueue_turn(session_id: sid, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
674
- no_interrupt: no_interrupt, images: images)
644
+ # Write and announce with the event log held: the worker can't emit
645
+ # this turn's :turn_started (or merge it mid-turn) before
646
+ # :turn_enqueued, and a failed write announces nothing.
647
+ enqueued = @engine.synchronize_events do
648
+ next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
649
+
650
+ enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
651
+ no_interrupt: no_interrupt, images: images).tap do |ok|
652
+ next unless ok
653
+
654
+ enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
655
+ enqueued_event[:images] = images unless images.empty?
656
+ @engine.announce(enqueued_event)
657
+ @on_input&.call
675
658
  end
659
+ end
676
660
  return deadline_passed("turn") if enqueued == :expired
677
661
  return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
678
662
 
679
- [{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: sid }]
680
- rescue StandardError => e
681
- [{}, 500, { error: "bridge_error", detail: e.message }]
663
+ [{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id }]
682
664
  end
683
665
 
684
666
  # Read-only snapshot surface. AC #4: too-old reconnects re-derive state
685
667
  # from here.
686
668
  def handle_state(session_id, _body = nil)
687
- unless own_session?(session_id)
688
- return [{}, 404, { error: "unknown_session" }]
689
- end
690
-
691
669
  state = @engine.session_state_snapshot
692
670
  [{}, 200, { session_id: @session_id, session_state_snapshot: state.merge(event_id: event_id(state[:event_seq])) }]
693
671
  end
@@ -696,8 +674,6 @@ module Samagotchi
696
674
  # profile asked from the server when no turn has reported them yet (so,
697
675
  # unlike /state, it may wait on one short /props GET).
698
676
  def handle_stats(session_id, _body = nil)
699
- return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
700
-
701
677
  [{}, 200, { session_id: @session_id, metrics: @engine.stats_snapshot }]
702
678
  end
703
679
 
@@ -706,14 +682,7 @@ module Samagotchi
706
682
  # streams from the snapshot's event_seq, and the ring replays what came
707
683
  # after (or the stream resets). Returns [headers, status, body].
708
684
  def handle_snapshot(session_id, _body = nil)
709
- return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
710
-
711
- body = @engine.synchronize_events do
712
- snap = snapshot
713
- state = @engine.session_state_snapshot.merge(event_seq: snap[:event_seq], event_id: snap[:event_id])
714
- { snapshot: snap, session_state_snapshot: state }
715
- end
716
- [{}, 200, body]
685
+ [{}, 200, snapshot_frame]
717
686
  end
718
687
 
719
688
  # The stream cursor for +seq+ in this worker.
@@ -722,10 +691,9 @@ module Samagotchi
722
691
  end
723
692
 
724
693
  # A per-session bridge only ever owns one Engine (for @session_id). The
725
- # stream and state surfaces must serve that session and nothing else —
726
- # serving a different id's data (or closing with no response) would be a
727
- # cross-session leak. POST/turn enqueue stays lenient (fire-and-forget to
728
- # another worker's input dir) through SessionInbox.write_input.
694
+ # stream, state and turn surfaces must serve that session and nothing
695
+ # else — serving a different id's data (or writing into another
696
+ # session's input dir) would be a cross-session leak.
729
697
  def own_session?(session_id)
730
698
  session_id.to_s == @session_id.to_s
731
699
  end
@@ -751,34 +719,13 @@ module Samagotchi
751
719
  [{}, 408, { error: "deadline_passed", detail: "the #{what} arrived after its client stopped waiting; not run" }]
752
720
  end
753
721
 
754
- # Write a turn into the target session's input dir, reusing the file IPC
755
- # the worker polls. Never calls run_turn across the boundary.
756
- def enqueue_turn(session_id:, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [])
757
- session_dir = Session.session_dir(session_id, state_dir: @state_dir || Session.default_state_dir)
722
+ # Write a turn into this session's input dir, reusing the file IPC the
723
+ # worker polls. Never calls run_turn across the boundary.
724
+ def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [])
758
725
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
759
726
  no_interrupt: no_interrupt, images: images)
760
727
  end
761
728
 
762
- # A turn's images as [{file:, name:}], or a String saying what's wrong.
763
- # Only refs to files already in that session's images/ pass (a web
764
- # upload): never a path, so no client can make the worker read a file.
765
- def turn_images(session_id, raw)
766
- return [] if raw.nil?
767
- return "images must be a list" unless raw.is_a?(Array)
768
- return "at most #{MAX_TURN_IMAGES} images" if raw.size > MAX_TURN_IMAGES
769
-
770
- session_dir = Session.session_dir(session_id, state_dir: @state_dir || Session.default_state_dir)
771
- raw.map do |image|
772
- return "each image must be {file:, name:}" unless image.is_a?(Hash)
773
-
774
- ref = ImageStore.symbolize(image)
775
- return "images are refs to uploaded files, not paths" if ref.key?(:path)
776
- return "unknown image #{ref[:file].to_s[0, 80]}" unless ImageStore.valid_ref?(session_dir, ref)
777
-
778
- { file: ref[:file].to_s, name: File.basename(ref[:name].to_s)[0, 120] }
779
- end
780
- end
781
-
782
729
  # ── HTTP plumbing ────────────────────────────────────────────────────────
783
730
 
784
731
  def read_request(io)