samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "event_id"
6
+
7
+ module Samagotchi
8
+ class Bridge
9
+ # Per-connection SSE writer.
10
+ #
11
+ # This object doubles as the per-connection live observer wired into the
12
+ # Engine via `Engine#subscribe`: its `#call` is invoked synchronously from
13
+ # `Engine#emit_event` inside the turn thread, so it must be strictly
14
+ # non-blocking — it only enqueues onto a bounded queue and returns. A
15
+ # dedicated writer thread (the connection's own thread) drains the queue
16
+ # and serialises SSE frames onto the socket, so a slow / hung client can
17
+ # never stall the running turn or other subscribers.
18
+ #
19
+ # #serve! performs the replay wiring in the correctness-ordered sequence
20
+ # mandated by the bridge design invariants:
21
+ # 1. subscribe the live queue FIRST
22
+ # 2. capture `event_count` (snapshot)
23
+ # 3. replay buffered `seq ∈ (from_seq, snapshot]`
24
+ # 4. drain the live queue for `seq > snapshot`
25
+ # which guarantees no gap and no overlap; a per-connection `event_seq`
26
+ # high-water mark de-duplicates the subscribe→capture window.
27
+ #
28
+ # A client joining with `?snapshot=1` (and no cursor) instead gets one
29
+ # `snapshot` frame: the session's messages, the turn in progress and the
30
+ # event_seq they cover, taken with the event log held, together with the
31
+ # subscribe. Live events follow from the next seq. A `reset` frame (the
32
+ # cursor can't be replayed) carries the same snapshot, numbered with its
33
+ # own seq.
34
+ #
35
+ # Frame ids are `<seq>-<epoch>`: event_seq starts over in each worker, so
36
+ # the epoch (one per Bridge, so per worker) tells whose seq a cursor is.
37
+ # A cursor from another epoch gets a `reset` frame. A plain numeric
38
+ # cursor (a client that keeps event_seq itself) replays as before.
39
+ class SSEWriter
40
+ DEFAULT_MAX_QUEUE = 1024
41
+ DEFAULT_HEARTBEAT_INTERVAL = 15.0
42
+
43
+ # @param engine [Samagotchi::Engine] the owning engine (live fan-out)
44
+ # @param ring [Samagotchi::Bridge::RingBuffer] shared capture buffer
45
+ # @param session_id [String]
46
+ # @param last_event_id [String, nil] reconnect cursor (or ?from_seq=):
47
+ # `<seq>-<epoch>` or a plain seq
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.
54
+ # @param join_with_snapshot [Boolean] start with a snapshot frame rather
55
+ # than a replay
56
+ # @param bridge [Samagotchi::Bridge, nil] for stop signalling
57
+ # @param max_queue [Integer] bounded-queue capacity
58
+ # @param heartbeat_interval [Float] idle-seconds between `: ping` frames
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,
61
+ max_queue: DEFAULT_MAX_QUEUE,
62
+ heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL)
63
+ @engine = engine
64
+ @ring = ring
65
+ @session_id = session_id
66
+ @last_event_id = last_event_id
67
+ @epoch = epoch
68
+ @snapshot_provider = snapshot_provider
69
+ @turn_snapshot_provider = turn_snapshot_provider || -> { { event_seq: @engine.event_count } }
70
+ @join_with_snapshot = join_with_snapshot
71
+ @bridge = bridge
72
+ @max_queue = max_queue
73
+ @heartbeat_interval = heartbeat_interval
74
+
75
+ @queue = BoundedQueue.new(capacity: @max_queue)
76
+ cursor_seq, cursor_epoch = EventId.parse(@last_event_id)
77
+ # A foreign cursor's seq means nothing here; its reset sets the mark.
78
+ @foreign_cursor = !cursor_epoch.nil? && cursor_epoch != @epoch
79
+ @high_water = @foreign_cursor ? 0 : cursor_seq
80
+ @handle = nil
81
+ @serving = false
82
+ @mutex = Monitor.new
83
+ end
84
+
85
+ # Non-blocking enqueue used as the per-connection live observer. Any
86
+ # caller runs in the turn thread; must never block. Events already
87
+ # served (seq <= high water) are dropped to avoid replay overlap.
88
+ def call(payload)
89
+ seq = payload[:event_seq]
90
+ return if seq && seq <= @high_water
91
+
92
+ @queue.push(payload)
93
+ rescue StandardError
94
+ # Enqueue isolation is best-effort; a bad payload must not break the
95
+ # running turn.
96
+ nil
97
+ end
98
+
99
+ # Run the serve loop on the caller's (connection) thread. Blocks until
100
+ # the client disconnects or the bridge stops.
101
+ def serve!(io)
102
+ @serving = true
103
+ write_sse_headers(io)
104
+ begin
105
+ if @join_with_snapshot
106
+ # Subscribe and snapshot as one step of the event log, then go live.
107
+ snapshot = @engine.synchronize_events do
108
+ @handle = @engine.subscribe(observer: self)
109
+ take_snapshot
110
+ end
111
+ write_snapshot_frame(io, :snapshot, snapshot)
112
+ else
113
+ # (1) subscribe the live queue FIRST.
114
+ @handle = @engine.subscribe(observer: self)
115
+ # (2) capture the snapshot sequence.
116
+ snapshot_seq = @engine.event_count
117
+ # (3) replay buffered (from_seq, snapshot].
118
+ replay!(io, snapshot_seq)
119
+ end
120
+ # (4) drain live events for seq > snapshot.
121
+ drain!(io)
122
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
123
+ # Client hung up; nothing to do.
124
+ ensure
125
+ @serving = false
126
+ @engine.unsubscribe(handle: @handle) if @handle
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def replay!(io, snapshot_seq)
133
+ from_seq = @high_water
134
+ oldest = @ring.oldest_seq
135
+ if @foreign_cursor || from_seq > snapshot_seq || (from_seq.positive? && oldest && from_seq < oldest - 1)
136
+ # Cursor is from another worker (epoch), ahead of the engine (a
137
+ # worker restarted, plain cursor) or behind the
138
+ # ring's buffered window (overflow): the server cannot replay the
139
+ # gap, so it emits a reset marker and the client re-syncs. A cursor
140
+ # at oldest - 1 has seen everything before the window, so it
141
+ # replays. A cold
142
+ # connect (from_seq == 0) replays the whole served window, and a
143
+ # fully caught-up cursor (from_seq == snapshot_seq) is NOT a reset
144
+ # case — it gets an empty replay and holds for live events.
145
+ emit_reset(io)
146
+ else
147
+ @ring.events_in_range(after_seq: from_seq, to_seq: snapshot_seq).each { |record| write_event(io, record[:data]) }
148
+ end
149
+ end
150
+
151
+ def drain!(io)
152
+ loop do
153
+ payload = @queue.pop(@heartbeat_interval)
154
+ if payload
155
+ if payload.is_a?(Hash) && payload[:sse_reset]
156
+ emit_reset(io)
157
+ next
158
+ end
159
+
160
+ write_event(io, payload)
161
+ # A fresh overflow since the last drain => the client fell too far
162
+ # behind; nudge a reconnect rather than flooding more events.
163
+ emit_reset(io) if @queue.overflow_dropped?
164
+ @queue.clear_overflow!
165
+ elsif @bridge&.stopped?
166
+ break
167
+ else
168
+ write_heartbeat(io)
169
+ end
170
+ end
171
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
172
+ nil
173
+ end
174
+
175
+ def write_event(io, event)
176
+ seq = event[:event_seq]
177
+ return if seq && seq <= @high_water
178
+
179
+ write_frame(io, seq: seq, data: event)
180
+ @high_water = seq if seq && seq > @high_water
181
+ end
182
+
183
+ def write_sse_headers(io)
184
+ io.write(
185
+ "HTTP/1.1 200 OK\r\n" \
186
+ "Content-Type: text/event-stream\r\n" \
187
+ "Cache-Control: no-cache\r\n" \
188
+ "Connection: keep-alive\r\n" \
189
+ "X-Accel-Buffering: no\r\n" \
190
+ "Access-Control-Allow-Origin: *\r\n" \
191
+ "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" \
192
+ "Access-Control-Allow-Headers: Content-Type, Last-Event-ID\r\n" \
193
+ "\r\n"
194
+ )
195
+ io.flush
196
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
197
+ raise
198
+ end
199
+
200
+ # Serialise one SSE event: an `id:` line (the replay cursor), an optional
201
+ # `event:` line for EventSource.addEventListener, one or more `data:` lines,
202
+ # and the terminating blank line.
203
+ def write_frame(io, seq:, data:)
204
+ lines = []
205
+ lines << "id: #{EventId.format(seq, @epoch)}"
206
+ event_type = data.is_a?(Hash) && (data[:type] || data["type"])
207
+ lines << "event: #{event_type}" if event_type && !event_type.to_s.empty?
208
+ JSON.generate(data).each_line { |line| lines << "data: #{line.chomp}" }
209
+ io.write(lines.join("\r\n") + "\r\n\r\n")
210
+ io.flush
211
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
212
+ raise
213
+ end
214
+
215
+ def write_heartbeat(io)
216
+ io.write(": ping\r\n\r\n")
217
+ io.flush
218
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
219
+ raise
220
+ end
221
+
222
+ # Emit a control frame carrying the current snapshot so a client that
223
+ # cannot replay can re-derive state, then continue live.
224
+ def emit_reset(io)
225
+ write_snapshot_frame(io, :reset, @engine.synchronize_events { take_snapshot })
226
+ end
227
+
228
+ # With the event log held: the snapshot, and the high-water mark moved
229
+ # to its seq (down too, for a cursor from before a worker restart), so
230
+ # events after it pass #call and events it covers are skipped.
231
+ def take_snapshot
232
+ snapshot = @turn_snapshot_provider.call
233
+ @high_water = snapshot[:event_seq].to_i
234
+ snapshot.merge(event_id: EventId.format(@high_water, @epoch))
235
+ end
236
+
237
+ def write_snapshot_frame(io, type, snapshot)
238
+ seq = snapshot[:event_seq]
239
+ state = @snapshot_provider.call.merge(event_seq: seq, event_id: snapshot[:event_id])
240
+ write_frame(io, seq: seq, data: { type: type, snapshot: snapshot, session_state_snapshot: state })
241
+ end
242
+
243
+ def closed?
244
+ @mutex.synchronize { !@serving }
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../kernel_loop"
4
+
5
+ module Samagotchi
6
+ class Bridge
7
+ # Folds the event log into the state of the turn in progress, so a UI
8
+ # that joins mid-turn can render all of it at once (the Bridge's snapshot
9
+ # frame) instead of replaying events the ring buffer may have dropped.
10
+ #
11
+ # A persistent observer: #call runs under the SessionObserver's lock,
12
+ # enqueue-cheap (appends to strings and arrays). The turn is cleared on
13
+ # its terminal event, the same event that brings its messages into the
14
+ # session, so a snapshot shows a turn in exactly one of the two.
15
+ class TurnAccumulator
16
+ TERMINAL_EVENTS = %i[turn_completed turn_canceled turn_failed].freeze
17
+
18
+ def initialize(max_output_chars: KernelLoop::DEFAULT_MAX_TOOL_OUTPUT_CHARS)
19
+ @max_output_chars = max_output_chars
20
+ @mutex = Mutex.new
21
+ @turn = nil
22
+ @queued = []
23
+ @merged_origins = []
24
+ @recap = nil
25
+ @continue_offer = nil
26
+ end
27
+
28
+ def call(event)
29
+ @mutex.synchronize { fold(event) }
30
+ rescue StandardError
31
+ nil # never break the running turn
32
+ end
33
+
34
+ # @return [Hash, nil] a copy of the turn in progress: prompt, origin,
35
+ # continue, ordered parts (thinking / text / tool / input / reminder / hook_notice),
36
+ # pending_question and the last event_seq folded in
37
+ def current_turn
38
+ @mutex.synchronize { @turn && Marshal.load(Marshal.dump(@turn)) }
39
+ end
40
+
41
+ # The turn in progress as conversation messages, for a plugin's
42
+ # ctx.messages mid-turn (plan O1): its prompt (with its images), the
43
+ # model's text so far, and the lines merged into it, in order. Tool
44
+ # calls and thinking are left out.
45
+ # @return [Array<Hash>] [] with no turn running
46
+ def current_messages
47
+ @mutex.synchronize { @turn ? self.class.messages_of(@turn) : [] }
48
+ end
49
+
50
+ # @param turn [Hash] #current_turn's shape
51
+ # @return [Array<Hash>] {role: "user"|"model", content:, images:}
52
+ def self.messages_of(turn)
53
+ messages = []
54
+ text = +""
55
+ flush = lambda do
56
+ messages << { role: "model", content: text.dup } unless text.strip.empty?
57
+ text.clear
58
+ end
59
+ unless turn[:prompt].nil?
60
+ prompt = { role: "user", content: turn[:prompt].to_s }
61
+ prompt[:images] = turn[:images] if turn[:images]
62
+ messages << prompt
63
+ end
64
+ Array(turn[:parts]).each do |part|
65
+ case part[:kind]
66
+ when "text" then text << part[:text].to_s
67
+ when "input"
68
+ flush.call
69
+ messages << { role: "user", content: part[:text].to_s }
70
+ end
71
+ end
72
+ flush.call
73
+ messages
74
+ end
75
+
76
+ # @return [String, nil] the last idle recap, until a turn makes it stale
77
+ def recap
78
+ @mutex.synchronize { @recap }
79
+ end
80
+
81
+ # @return [Hash, nil] a copy of the pending continue offer ({context:,
82
+ # no_interrupt:}), from :continue_offered until :continue_resolved
83
+ def continue_offer
84
+ @mutex.synchronize { @continue_offer && Marshal.load(Marshal.dump(@continue_offer)) }
85
+ end
86
+
87
+ # @return [Array<Hash>] turns announced as queued that haven't started
88
+ # or been merged into a running turn yet
89
+ def queued
90
+ @mutex.synchronize { @queued.map(&:dup) }
91
+ end
92
+
93
+ private
94
+
95
+ def fold(event)
96
+ type = event[:type]
97
+ case type
98
+ when :turn_enqueued
99
+ queued = { enqueued_id: event[:enqueued_id], client_id: event[:client_id], prompt: event[:prompt] }
100
+ queued[:images] = event[:images] if event[:images]
101
+ @queued << queued
102
+ when :recap_ready
103
+ # One collected just as a turn started describes the chat before it.
104
+ @recap = event[:recap] unless @turn
105
+ when :continue_offered
106
+ @continue_offer = { context: event[:context], no_interrupt: !!event[:no_interrupt] }
107
+ when :continue_resolved
108
+ @continue_offer = nil
109
+ when :turn_started
110
+ @recap = nil
111
+ dequeue([event[:origin]])
112
+ @turn = { prompt: event[:prompt], origin: event[:origin], continue: !!event[:continue],
113
+ parts: [], pending_question: nil }
114
+ @turn[:images] = event[:images] if event[:images]
115
+ when :input_merged
116
+ @merged_origins = Array(event[:origins])
117
+ dequeue(@merged_origins)
118
+ end
119
+ return unless @turn
120
+
121
+ fold_turn_event(event)
122
+ if TERMINAL_EVENTS.include?(type)
123
+ @turn = nil
124
+ @merged_origins = []
125
+ else
126
+ @turn[:event_seq] = event[:event_seq] if event[:event_seq]
127
+ end
128
+ end
129
+
130
+ def fold_turn_event(event)
131
+ parts = @turn[:parts]
132
+ case event[:type]
133
+ when :generation_chunk
134
+ if event.key?(:text)
135
+ # Profile-split stream: thinking and visible text separately.
136
+ append_text(event[:iteration], "thinking", event[:thinking])
137
+ append_text(event[:iteration], "text", event[:text])
138
+ else
139
+ append_text(event[:iteration], "text", event[:content])
140
+ end
141
+ when :tool_call_started
142
+ part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
143
+ tool: event[:tool], params: event[:params], status: "running" }
144
+ part[:label] = event[:label] if event[:label]
145
+ parts << part
146
+ when :tool_call_completed
147
+ tool = parts.reverse_each.find do |part|
148
+ part[:kind] == "tool" && part[:iteration] == event[:iteration] && part[:call_index] == event[:call_index]
149
+ end
150
+ return unless tool
151
+
152
+ output = event[:output].to_s
153
+ capped = output.length > @max_output_chars
154
+ tool[:status] = (event.dig(:activity, :status) || "ok").to_s
155
+ tool[:output] = capped ? output[0, @max_output_chars] : output.dup
156
+ tool[:output_truncated] = capped || !!event[:output_truncated]
157
+ tool[:images] = event[:images] if event[:images]
158
+ when :pending_input_merged
159
+ parts << { kind: "input", iteration: event[:iteration], text: event[:content].to_s.dup, origins: @merged_origins }
160
+ @merged_origins = []
161
+ when :reminder_injected
162
+ parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
163
+ when :hook_notice
164
+ parts << { kind: "hook_notice", hook: event[:hook].to_s, text: event[:text].to_s, level: event[:level].to_s }
165
+ when :question_requested
166
+ @turn[:pending_question] = event[:pending_question]&.dup
167
+ when :question_answered, :question_cancelled
168
+ @turn[:pending_question] = nil
169
+ end
170
+ end
171
+
172
+ def append_text(iteration, kind, text)
173
+ return if text.nil? || text.empty?
174
+
175
+ last = @turn[:parts].last
176
+ if last && last[:kind] == kind && last[:iteration] == iteration
177
+ last[:text] << text
178
+ else
179
+ @turn[:parts] << { kind: kind, iteration: iteration, text: String.new(text) }
180
+ end
181
+ end
182
+
183
+ def dequeue(origins)
184
+ ids = origins.filter_map { |origin| origin.is_a?(Hash) ? origin[:enqueued_id] : nil }
185
+ @queued.reject! { |entry| ids.include?(entry[:enqueued_id]) } unless ids.empty?
186
+ end
187
+ end
188
+ end
189
+ end