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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Samagotchi
7
+ class BridgeClient
8
+ # A Bridge event stream followed on a reader thread (BridgeClient#follow).
9
+ #
10
+ # Joins with `?snapshot=1`, so the first event is a `snapshot` frame, then
11
+ # yields each live event parsed from JSON (string keys). When the
12
+ # connection drops it reconnects with `Last-Event-ID`: the Bridge replays
13
+ # what was missed, or sends a `reset` frame carrying a fresh snapshot when
14
+ # it can't. Either snapshot kind means "re-render from here".
15
+ #
16
+ # When the Bridge stays unreachable through every reconnect delay, or does
17
+ # not serve the session (404), it yields one synthetic
18
+ # `{"type" => "stream_closed", "reason" => ...}` and stops. #close stops
19
+ # it without that event.
20
+ class EventStream
21
+ DEFAULT_RECONNECT_DELAYS = [0.1, 0.25, 0.5, 1.0, 2.0].freeze
22
+ HEADER_TIMEOUT = 5.0
23
+
24
+ # @return [String, nil] the id of the last frame yielded (the reconnect cursor)
25
+ attr_reader :last_event_id
26
+
27
+ # @param client [BridgeClient]
28
+ # @param snapshot [Boolean] join with a snapshot frame rather than a replay
29
+ # @param client_id [String, nil] names the stream, so the worker can
30
+ # tell this client's stream from others' (Bridge#open_streams_except)
31
+ # @param reconnect_delays [Array<Float>] sleeps between failed attempts;
32
+ # one more failure than there are delays gives up
33
+ # @yieldparam event [Hash] string-keyed event
34
+ def initialize(client, snapshot: true, client_id: nil, reconnect_delays: DEFAULT_RECONNECT_DELAYS, &on_event)
35
+ @client = client
36
+ params = []
37
+ params << ["snapshot", "1"] if snapshot
38
+ params << ["client_id", client_id] if client_id
39
+ @query = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
40
+ @reconnect_delays = reconnect_delays
41
+ @on_event = on_event
42
+ @last_event_id = nil
43
+ @closed = false
44
+ @sock = nil
45
+ @mutex = Mutex.new
46
+ end
47
+
48
+ # @return [self]
49
+ def start
50
+ @thread = Thread.new { run }
51
+ self
52
+ end
53
+
54
+ # Stop following: closes the socket under the reader so it wakes at once.
55
+ # Safe to call from the event callback itself.
56
+ def close
57
+ sock = @mutex.synchronize do
58
+ @closed = true
59
+ @sock
60
+ end
61
+ begin
62
+ sock&.close
63
+ rescue IOError
64
+ nil
65
+ end
66
+ @thread&.join(1) unless Thread.current == @thread
67
+ self
68
+ end
69
+
70
+ def alive? = !!@thread&.alive?
71
+
72
+ # @return [Thread, nil] the reader thread, nil when it did not finish in time
73
+ def join(timeout = nil) = @thread&.join(timeout)
74
+
75
+ private
76
+
77
+ def run
78
+ failures = 0
79
+ until closed?
80
+ outcome = read_connection
81
+ break if closed?
82
+ return finish("unknown_session") if outcome == :unknown_session
83
+
84
+ if outcome == :events
85
+ failures = 0
86
+ next
87
+ end
88
+
89
+ failures += 1
90
+ return finish("unreachable") if failures > @reconnect_delays.size
91
+
92
+ sleep(@reconnect_delays[failures - 1])
93
+ end
94
+ end
95
+
96
+ # One connection: :events when it yielded any, else why it failed.
97
+ def read_connection
98
+ sock, status = @client.connect_stream(query: @query, last_event_id: @last_event_id, timeout: HEADER_TIMEOUT)
99
+ return :unreachable unless sock
100
+
101
+ unless attach(sock)
102
+ sock.close rescue nil
103
+ return :closed
104
+ end
105
+ return :unknown_session if status == 404
106
+ return :unreachable unless status == 200
107
+
108
+ yielded = false
109
+ parser = SSEParser.new
110
+ loop do
111
+ chunk = sock.readpartial(4096)
112
+ parser.feed(chunk) do |frame|
113
+ event = parse(frame[:data])
114
+ next unless event
115
+
116
+ @last_event_id = frame[:id] if frame[:id]
117
+ yielded = true
118
+ @on_event.call(event)
119
+ end
120
+ end
121
+ rescue EOFError, IOError, SystemCallError
122
+ yielded ? :events : :unreachable
123
+ ensure
124
+ detach(sock)
125
+ end
126
+
127
+ # Publish the socket for #close; false when already closed.
128
+ def attach(sock)
129
+ @mutex.synchronize do
130
+ return false if @closed
131
+
132
+ @sock = sock
133
+ end
134
+ true
135
+ end
136
+
137
+ def detach(sock)
138
+ return unless sock
139
+
140
+ @mutex.synchronize { @sock = nil if @sock.equal?(sock) }
141
+ sock.close rescue nil
142
+ end
143
+
144
+ def parse(data)
145
+ event = JSON.parse(data)
146
+ event.is_a?(Hash) ? event : nil
147
+ rescue JSON::ParserError
148
+ nil
149
+ end
150
+
151
+ def finish(reason)
152
+ @on_event.call({ "type" => "stream_closed", "reason" => reason }) unless closed?
153
+ end
154
+
155
+ def closed? = @mutex.synchronize { @closed }
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class BridgeClient
5
+ # Incremental Server-Sent Events parser: feed it raw body chunks as they
6
+ # arrive and it yields one frame per blank-line-terminated event. Accepts
7
+ # CRLF or LF line ends, joins multi-line `data:` with newlines, and skips
8
+ # comments (the Bridge's `: ping` heartbeats) and frames without data.
9
+ class SSEParser
10
+ def initialize
11
+ @buffer = +""
12
+ reset_frame
13
+ end
14
+
15
+ # @param chunk [String] raw bytes from the stream
16
+ # @yieldparam frame [Hash] {id: String|nil, event: String|nil, data: String}
17
+ def feed(chunk)
18
+ @buffer << chunk.to_s.b
19
+ while (newline = @buffer.index("\n"))
20
+ line = @buffer.slice!(0..newline).chomp("\n").chomp("\r").force_encoding(Encoding::UTF_8)
21
+ if line.empty?
22
+ yield({ id: @id, event: @event, data: @data.join("\n") }) if @data.any?
23
+ reset_frame
24
+ else
25
+ field(line)
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def field(line)
33
+ return if line.start_with?(":")
34
+
35
+ name, value = line.split(":", 2)
36
+ value = value.to_s.delete_prefix(" ")
37
+ case name
38
+ when "id" then @id = value
39
+ when "event" then @event = value
40
+ when "data" then @data << value
41
+ end
42
+ end
43
+
44
+ def reset_frame
45
+ @id = nil
46
+ @event = nil
47
+ @data = []
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ require_relative "bridge_client/sse_parser"
7
+ require_relative "bridge_client/event_stream"
8
+
9
+ module Samagotchi
10
+ # Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
11
+ # each SessionManager worker runs for its Engine (see Bridge). Discovery goes
12
+ # through the worker's `bridge.json` sidecar in the session directory.
13
+ #
14
+ # Requests are raw one-shot HTTP/1.1 over a TCPSocket (`Connection: close`),
15
+ # exactly what the Bridge's minimal server expects.
16
+ class BridgeClient
17
+ HOST = "127.0.0.1"
18
+ SIDECAR_FILE = "bridge.json"
19
+ PROBE_TIMEOUT = 0.2
20
+ STREAM_CONNECT_ATTEMPTS = 3
21
+ # Seconds #stream waits for bytes before it asks `running` again.
22
+ STREAM_POLL = 0.5
23
+ # Seconds a one-shot request waits for the whole reply. Every route
24
+ # answers at once (a recap is only asked for); a worker that takes the
25
+ # request and never answers raises Errno::ETIMEDOUT, as a dead one
26
+ # raises Errno::ECONNREFUSED. The event stream has no such limit.
27
+ READ_TIMEOUT = 30
28
+ # Share of the read timeout a request's deadline allows (25 s of 30): the
29
+ # Bridge drops a turn, command, answer or dismissal it reads after the
30
+ # deadline, so one this client timed out on, and said did not go
31
+ # through, never runs when a frozen worker wakes. The rest covers the
32
+ # write and the reply.
33
+ DEADLINE_SHARE = 5 / 6r
34
+
35
+ # A Bridge reply: HTTP status code and raw body (nil when not read).
36
+ Response = Struct.new(:status, :body, keyword_init: true) do
37
+ def ok? = status == 200
38
+
39
+ # @return [Hash, nil] the body parsed as JSON, or nil when it is not
40
+ def json
41
+ JSON.parse(body.to_s)
42
+ rescue JSON::ParserError
43
+ nil
44
+ end
45
+ end
46
+
47
+ # What a UI says when the Bridge answers a route with 404: the worker runs
48
+ # code from before the route. Restarting it picks up the installed chi.
49
+ # @param cant [String] what the old worker can't do, e.g. "run commands"
50
+ def self.stale_worker_message(session_id, cant:)
51
+ "this session's worker runs an older chi and can't #{cant}; " \
52
+ "restart it: chi sessions stop #{session_id} && chi --resume #{session_id} (its turns still work)"
53
+ end
54
+
55
+ # Port of the live Bridge advertised in +session_dir+'s sidecar, or nil.
56
+ # A sidecar whose port refuses a quick connect is stale (its worker died)
57
+ # and is removed.
58
+ # @param session_dir [String]
59
+ # @return [Integer, nil]
60
+ def self.sidecar_port(session_dir, host: HOST)
61
+ sidecar = File.join(session_dir, SIDECAR_FILE)
62
+ return nil unless File.file?(sidecar)
63
+
64
+ data = JSON.parse(File.read(sidecar))
65
+ port = data["port"]
66
+ port = port.is_a?(Integer) ? port : port.to_i
67
+ return nil unless port.to_i > 0
68
+
69
+ # Validate liveness: stale sidecar after worker death causes ECONNREFUSED
70
+ # which surfaces as WEBrick ERROR. Probe quickly and clean up if dead.
71
+ begin
72
+ Socket.tcp(host, port, connect_timeout: PROBE_TIMEOUT).close
73
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError, StandardError
74
+ begin
75
+ File.unlink(sidecar)
76
+ rescue StandardError
77
+ nil
78
+ end
79
+ return nil
80
+ end
81
+ port
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
86
+ # @return [BridgeClient, nil] a client for the live Bridge in +session_dir+
87
+ def self.discover(session_id, session_dir:, host: HOST)
88
+ port = sidecar_port(session_dir, host: host)
89
+ port && new(session_id: session_id, port: port, host: host)
90
+ end
91
+
92
+ # How often a wait for a new worker's Bridge looks: a missing sidecar is
93
+ # one stat, and a 0.1 s step cost a create up to 0.1 s (the web's
94
+ # create landed on 0.53/0.63/0.73 s).
95
+ SPAWN_POLL_INTERVAL = 0.02
96
+
97
+ # Wait for a freshly spawned worker to publish its Bridge.
98
+ # @param timeout [Float] seconds
99
+ # @return [BridgeClient, nil] nil when no live sidecar appeared in time
100
+ def self.wait_for(session_id, session_dir:, timeout:, host: HOST)
101
+ port = poll(timeout, interval: SPAWN_POLL_INTERVAL) { sidecar_port(session_dir, host: host) }
102
+ port && new(session_id: session_id, port: port, host: host)
103
+ end
104
+
105
+ # Call the block every +interval+ seconds until it returns a truthy value
106
+ # or +timeout+ seconds have passed (it is always called at least once).
107
+ # @return [Object, nil] the block's first truthy value
108
+ def self.poll(timeout, interval: 0.1)
109
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f
110
+ loop do
111
+ value = yield
112
+ return value if value
113
+ return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
114
+
115
+ sleep(interval)
116
+ end
117
+ end
118
+
119
+ attr_reader :session_id, :port, :host
120
+
121
+ # @param read_timeout [Numeric] see READ_TIMEOUT
122
+ def initialize(session_id:, port:, host: HOST, read_timeout: READ_TIMEOUT)
123
+ @session_id = session_id
124
+ @port = port
125
+ @host = host
126
+ @read_timeout = read_timeout
127
+ end
128
+
129
+ # POST /session/:id/answer. 200 = recorded, 409 = another client answered
130
+ # first (or the question is gone), 400 = invalid selection, 408
131
+ # deadline_passed = read too late and dropped (see DEADLINE_SHARE).
132
+ # @return [Response]
133
+ def answer(id:, selected:, freeform: nil)
134
+ post("answer", { id: id, selected: selected, freeform: freeform, deadline: deadline }, read_body: true)
135
+ end
136
+
137
+ # POST /session/:id/question/dismiss: leave the question unanswered.
138
+ # 200 = dismissed, 409 = no longer pending (answered, cancelled, or
139
+ # another question), 404 = a worker older than the route, 408 = too late.
140
+ # @return [Response]
141
+ def dismiss_question(id:)
142
+ post("question/dismiss", { id: id, deadline: deadline }, read_body: true)
143
+ end
144
+
145
+ # POST /session/:id/turn. 202 = queued (body carries the enqueued_id the
146
+ # Bridge also announced in :turn_enqueued), 408 deadline_passed = the
147
+ # Bridge read it too late and dropped it (see DEADLINE_SHARE).
148
+ # @param client_id [String, nil] identifies the sending UI in the events
149
+ # @return [Response]
150
+ # @param no_interrupt [Boolean] run the turn with the raised iteration limit
151
+ # @param images [Array<Hash>] refs ({file:, name:}) to images already in
152
+ # the session's images/ (409 images_unsupported from an older worker)
153
+ def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil)
154
+ body = { session_id: @session_id, prompt: prompt, client_id: client_id }
155
+ body[:no_interrupt] = true if no_interrupt
156
+ body[:images] = images if images && !images.empty?
157
+ body[:deadline] = deadline
158
+ post("turn", body, read_body: true)
159
+ end
160
+
161
+ # POST /session/:id/command: a session command (/model, /models,
162
+ # !rollback, !cmd, /continue) for the worker to run. 202 = queued (body
163
+ # carries the command_id its :command_ran will name), 400 = not a
164
+ # command, 404 = a worker older than the route, 408 = too late.
165
+ # @return [Response]
166
+ def post_command(line:, client_id: nil)
167
+ post("command", { line: line, client_id: client_id, deadline: deadline }, read_body: true)
168
+ end
169
+
170
+ # POST /session/:id/exit: ask the worker to exit now. 200 = it will
171
+ # (status "exiting"), 409 = something keeps it up (reason), 404 with
172
+ # error "not_found" = a worker older than the route.
173
+ # @param client_id [String] the asking UI, whose own streams don't hold
174
+ # @return [Response]
175
+ def request_exit(client_id:, delete: false)
176
+ body = { client_id: client_id }
177
+ body[:delete] = true if delete
178
+ post("exit", body, read_body: true)
179
+ end
180
+
181
+ # POST /session/:id/recap: the saved recap, and a new one asked for.
182
+ # @return [Response] 200 {enabled, saved, request, min_user_turns}
183
+ def request_recap
184
+ post("recap", {}, read_body: true)
185
+ end
186
+
187
+ # POST /session/:id/cancel. 202 = requested, 409 = no active turn.
188
+ # @return [Response] (status only)
189
+ def cancel(reason:)
190
+ post("cancel", { reason: reason }, read_body: false)
191
+ end
192
+
193
+ # GET /session/:id/<path> as JSON.
194
+ # @return [Hash, nil] the parsed body, or nil unless the Bridge answered 200
195
+ def get_json(path)
196
+ sock = TCPSocket.new(@host, @port)
197
+ sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nConnection: close\r\n\r\n")
198
+ response = read_reply(sock, path)
199
+ sock.close rescue nil
200
+ return nil unless response
201
+
202
+ status_line = response.lines.first.to_s
203
+ return nil unless status_line.include?("200")
204
+
205
+ body = response.split("\r\n\r\n", 2)[1] || ""
206
+ JSON.parse(body)
207
+ rescue StandardError
208
+ nil
209
+ end
210
+
211
+ # Monotonic SSE cursor of the live Engine, from GET /session/:id/state.
212
+ # @return [Integer, nil]
213
+ def event_seq
214
+ state = get_json("state")
215
+ seq = state && state.dig("session_state_snapshot", "event_seq")
216
+ seq.nil? ? nil : seq.to_i
217
+ rescue StandardError
218
+ nil
219
+ end
220
+
221
+ # GET /session/:id/stream: yield the raw SSE body bytes as they arrive
222
+ # until the Bridge closes the stream.
223
+ # @param query [String] "" or "?from_seq=N"
224
+ # @param last_event_id [String, nil] reconnect cursor; the Bridge prefers it
225
+ # over ?from_seq
226
+ # @param running [#call, nil] asked before every read, and every
227
+ # STREAM_POLL seconds of silence: the stream ends once it returns
228
+ # false. A quiet stream is never cut otherwise.
229
+ def stream(query: "", last_event_id: nil, running: nil)
230
+ sock = nil
231
+ # Absorb the probe→connect race around a resumed worker's bridge:
232
+ # the sidecar probe can succeed a moment before the worker dies (or
233
+ # the bridge socket briefly refuses). A few quick bounded retries
234
+ # avoid the silent empty-200 that EventSource would otherwise keep
235
+ # re-opening.
236
+ STREAM_CONNECT_ATTEMPTS.times do |attempt|
237
+ sock, = connect_stream(query: query, last_event_id: last_event_id)
238
+ break if sock
239
+
240
+ sleep(0.15 * (attempt + 1))
241
+ end
242
+ return unless sock
243
+
244
+ begin
245
+ loop do
246
+ if running
247
+ break unless running.call
248
+ next unless sock.wait_readable(STREAM_POLL)
249
+ end
250
+ chunk = sock.readpartial(4096)
251
+ yield chunk
252
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::ECONNREFUSED
253
+ break
254
+ end
255
+ ensure
256
+ sock&.close rescue nil
257
+ end
258
+ end
259
+
260
+ # Follow the session's events on a reader thread (see EventStream).
261
+ # @param snapshot [Boolean] join with a snapshot frame
262
+ # @param client_id [String, nil] names the stream (see EventStream)
263
+ # @yieldparam event [Hash] string-keyed event
264
+ # @return [EventStream] started
265
+ def follow(snapshot: true, client_id: nil, reconnect_delays: EventStream::DEFAULT_RECONNECT_DELAYS, &on_event)
266
+ EventStream.new(self, snapshot: snapshot, client_id: client_id, reconnect_delays: reconnect_delays,
267
+ &on_event).start
268
+ end
269
+
270
+ # Open GET /session/:id/stream and read past the response headers.
271
+ # @param timeout [Float, nil] give up when the headers don't start in time
272
+ # @return [Array(TCPSocket, Integer), nil] the socket positioned at the
273
+ # body and the HTTP status, or nil when the Bridge can't be reached
274
+ def connect_stream(query: "", last_event_id: nil, timeout: nil)
275
+ sock = TCPSocket.new(@host, @port)
276
+ lei = last_event_id.to_s.strip
277
+ last_event_line = lei.empty? ? "" : "Last-Event-ID: #{lei}\r\n"
278
+ sock.write("GET /session/#{@session_id}/stream#{query} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nAccept: text/event-stream\r\n#{last_event_line}Connection: keep-alive\r\n\r\n")
279
+ raise Errno::ETIMEDOUT if timeout && !sock.wait_readable(timeout)
280
+
281
+ status = sock.gets.to_s[/\AHTTP\/1\.[01] (\d{3})/, 1].to_i
282
+ while (line = sock.gets)
283
+ break if line.strip.empty?
284
+ end
285
+ [sock, status]
286
+ rescue SystemCallError, SocketError, IOError
287
+ sock&.close rescue nil
288
+ nil
289
+ end
290
+
291
+ private
292
+
293
+ # When this client stops waiting for a reply, as epoch seconds (see
294
+ # DEADLINE_SHARE). Wall clock: the worker runs on this machine and reads
295
+ # the same one.
296
+ def deadline = (Time.now.to_f + (@read_timeout * DEADLINE_SHARE)).round(3)
297
+
298
+ def post(path, payload, read_body:)
299
+ sock = TCPSocket.new(@host, @port)
300
+ json_body = JSON.generate(payload)
301
+ sock.write("POST /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nContent-Type: application/json\r\nContent-Length: #{json_body.bytesize}\r\nConnection: close\r\n\r\n#{json_body}")
302
+ reply = read_reply(sock, path, whole: read_body)
303
+ body = read_body ? reply.split("\r\n\r\n", 2)[1] : nil
304
+ Response.new(status: reply[/\AHTTP\/1\.[01] (\d{3})/, 1].to_i, body: body)
305
+ ensure
306
+ sock&.close rescue nil
307
+ end
308
+
309
+ # The reply up to the Bridge's close (or its first line only), within
310
+ # @read_timeout in all.
311
+ # @raise [Errno::ETIMEDOUT]
312
+ def read_reply(sock, path, whole: true)
313
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
314
+ reply = +""
315
+ loop do
316
+ break if !whole && reply.include?("\n")
317
+
318
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
319
+ unless left.positive? && sock.wait_readable(left)
320
+ raise Errno::ETIMEDOUT, "bridge #{path}: no reply within #{@read_timeout}s"
321
+ end
322
+
323
+ reply << sock.readpartial(16_384)
324
+ rescue EOFError
325
+ break
326
+ end
327
+ reply.force_encoding(Encoding::UTF_8)
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Samagotchi
6
+ autoload :MutedMemories, File.expand_path("muted_memories", __dir__)
7
+ autoload :Log, File.expand_path("log", __dir__)
8
+ module MemoryBundle
9
+ autoload :Provenance, File.expand_path("memory_bundle/provenance", __dir__)
10
+ autoload :Manifest, File.expand_path("memory_bundle/manifest", __dir__)
11
+ end
12
+
13
+ # The outside commands a bundle declares in its manifest's `needs:`
14
+ # (docs/memory.md). Advisory only: a PATH lookup in Ruby, no subprocess,
15
+ # nothing installed. Install/upgrade warn, `chi bundle status` lists, and
16
+ # the prompt's index marks a bundle memory whose need is missing.
17
+ module BundleNeeds
18
+ # Whether some PATH folder holds an executable file (not a directory)
19
+ # named +command+. An empty or missing PATH finds nothing.
20
+ def self.found?(command, path: ENV["PATH"])
21
+ return false if path.nil? || path.empty?
22
+
23
+ path.split(File::PATH_SEPARATOR).any? do |dir|
24
+ next false if dir.empty?
25
+
26
+ file = File.join(dir, command.to_s)
27
+ File.file?(file) && File.executable?(file)
28
+ end
29
+ end
30
+
31
+ # The needs (hashes with :command) whose command isn't found.
32
+ def self.missing(needs, path: ENV["PATH"])
33
+ Array(needs).reject { |need| found?(need[:command], path: path) }
34
+ end
35
+
36
+ # "[needs gh, jq: not found on PATH]", or nil when nothing is missing.
37
+ # No em dash: IndexUpdater.extract_description splits on " — ".
38
+ def self.marker(missing)
39
+ commands = Array(missing).map { |need| need[:command] }
40
+ return nil if commands.empty?
41
+
42
+ "[needs #{commands.join(", ")}: not found on PATH]"
43
+ end
44
+
45
+ # The prompt's index text for +scope+ with a marker on each line naming
46
+ # a memory of an installed bundle (of that scope) whose needs aren't all
47
+ # on +path+. Every other byte stays; with nothing missing the text comes
48
+ # back as is. Never raises: on any error the text comes back unchanged.
49
+ def self.annotate_index(text, scope, path: ENV["PATH"], bundles_dir: nil)
50
+ return text if text.nil? || text.empty?
51
+
52
+ markers = entry_markers(scope, path: path, bundles_dir: bundles_dir || MemoryBundle::Provenance.bundles_dir)
53
+ return text if markers.empty?
54
+
55
+ text.each_line.map do |line|
56
+ marker = markers[MutedMemories.index_line_name(line)]
57
+ next line unless marker
58
+
59
+ body = line.chomp
60
+ "#{body} #{marker}#{line[body.length..]}"
61
+ end.join
62
+ rescue StandardError => e
63
+ Log.debug(:memory, "bundle_needs_failed", error: e.class.name, msg: e.message.to_s[0, 200])
64
+ text
65
+ end
66
+
67
+ # entry name → marker, for the installed bundles of +scope+ with a
68
+ # missing need. A manifest.json or needs: that doesn't parse is skipped.
69
+ def self.entry_markers(scope, path:, bundles_dir:)
70
+ return {} unless Dir.exist?(bundles_dir)
71
+
72
+ Dir[File.join(bundles_dir, "*", "manifest.json")].sort.each_with_object({}) do |mjson, acc|
73
+ data = begin
74
+ JSON.parse(File.read(mjson), symbolize_names: true)
75
+ rescue JSON::ParserError, SystemCallError
76
+ next
77
+ end
78
+ next unless data.is_a?(Hash) && data[:scope].to_s == scope.to_s
79
+ next unless data[:needs].is_a?(Array) && !data[:needs].empty?
80
+
81
+ needs = begin
82
+ MemoryBundle::Manifest.parse_needs(data[:needs])
83
+ rescue MemoryBundle::Manifest::ValidationError
84
+ next
85
+ end
86
+ marker = marker(missing(needs, path: path))
87
+ next unless marker
88
+
89
+ (data[:files].is_a?(Hash) ? data[:files].keys : []).each do |key|
90
+ name = MutedMemories.normalize(key)
91
+ acc[name] ||= marker if name
92
+ end
93
+ end
94
+ end
95
+ private_class_method :entry_markers
96
+ end
97
+ end
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: btw
3
+ version: 0.1.1
4
+ scope: system
5
+ description: "/btw <question>: a side answer about the conversation, as a card in every UI, even mid-turn; Keep as session forks it into a child session"
6
+ trust_level: reviewed
7
+ plugin:
8
+ file: plugin.rb
9
+ sha256: sha256:d12a970e7e5ada617fc7d55ee1de80b576835092d268af620f3f7241188e3297
10
+ requires_chi: ">= 0.1.30"