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,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "rack"
6
+ require "rackup/handler/webrick"
7
+ require "uri"
8
+ require "webrick"
9
+
10
+ require_relative "app"
11
+ require_relative "session_hub"
12
+ require_relative "../project_scope"
13
+ require_relative "../log"
14
+ require_relative "../version"
15
+
16
+ module Samagotchi
17
+ module Web
18
+ # Launcher for the single-port Web UI.
19
+ #
20
+ # Binds strictly to 127.0.0.1 (localhost-only). Use `bin/chi web` to start.
21
+ class Server
22
+ # WEBrick logs every exception out of its request loop as an ERROR with
23
+ # a backtrace, among them a browser dropping a kept-alive connection
24
+ # (sock.eof? raising ECONNRESET): harmless, so not logged.
25
+ class Log < WEBrick::Log
26
+ DROPPED = [Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED].freeze
27
+
28
+ def error(msg)
29
+ return if DROPPED.any? { |klass| msg.is_a?(klass) }
30
+
31
+ super
32
+ end
33
+
34
+ # WEBrick logs the signal that ends its loop (Ctrl-C, a kill) as
35
+ # FATAL with a backtrace, then re-raises it: .launch says it in a
36
+ # line instead.
37
+ def fatal(msg)
38
+ return if msg.is_a?(SignalException)
39
+
40
+ super
41
+ end
42
+ end
43
+
44
+ DEFAULT_PORT = 4567
45
+ DEFAULT_HOST = "127.0.0.1"
46
+ # How long `chi web` waits for a server already on its port to answer.
47
+ PROBE_TIMEOUT = 0.3
48
+
49
+ # `chi web`: one server serves every project, the scope is in the page
50
+ # URL. If a chi web already runs on the port, print (and with --open,
51
+ # open) its page for +dir+ and leave it be; else start one.
52
+ # @param scope ["project", "all"] "all": the plain page, every session
53
+ # @return [Integer] the exit status
54
+ def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false, turn_view: true)
55
+ host = resolve_host(host)
56
+ port = resolve_port(port)
57
+ url = scope_url(host, port, dir: dir, scope: scope)
58
+ found = probe(host, port)
59
+ case found
60
+ when Hash
61
+ puts "chi web already runs on port #{port} (pid #{found["pid"]}): #{url}"
62
+ open_url(url) if open_browser
63
+ 0
64
+ when :other
65
+ warn in_use_message(port)
66
+ 1
67
+ else
68
+ start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, turn_view: turn_view) ? 0 : 1
69
+ end
70
+ rescue Interrupt
71
+ # Ctrl-C: the server has stopped (start's ensure); one line, and the
72
+ # status a shell gives a command it interrupted.
73
+ puts "#{"\n" if $stdout.tty?}Chi Web stopped."
74
+ 130
75
+ end
76
+
77
+ # @param hub [SessionHub, nil] the session projection the page streams
78
+ # from; built over the app's state dir unless given
79
+ # @return [Boolean] false when the port was taken (said so on stderr)
80
+ def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
81
+ turn_view: true, hub: nil)
82
+ port = resolve_port(port)
83
+ host = resolve_host(host)
84
+ url ||= "http://#{url_host(host)}:#{port}/"
85
+
86
+ hub ||= SessionHub.new(state_dir: state_dir || Session.default_state_dir, manager: manager || SessionManager)
87
+ app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, turn_view: turn_view, hub: hub)
88
+ Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
89
+ hub.start
90
+ # Said once the port is bound: a second chi web racing for it gets
91
+ # the in-use line instead.
92
+ started = lambda do
93
+ puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
94
+ puts "Press Ctrl-C to stop."
95
+ $stdout.flush # a log file isn't line-buffered
96
+ if open_browser
97
+ Thread.new do
98
+ sleep 0.8
99
+ open_url(url)
100
+ end
101
+ end
102
+ end
103
+
104
+ # The event loops (GET /api/events) end once WEBrick leaves
105
+ # :Running: it joins every request thread before run returns.
106
+ Rackup::Handler::WEBrick.run(app, Host: host, Port: port, AccessLog: [], Logger: Log.new($stderr, WEBrick::Log::WARN),
107
+ StartCallback: started) do |server|
108
+ app.server_running = -> { server.status == :Running }
109
+ end
110
+ true
111
+ rescue Errno::EADDRINUSE
112
+ warn in_use_message(port)
113
+ false
114
+ ensure
115
+ hub&.stop
116
+ Samagotchi::Log.info(:web, "stop") if app
117
+ end
118
+
119
+ def self.resolve_host(host)
120
+ host = (host || ENV.fetch("SAMAGOTCHI_WEB_HOST", DEFAULT_HOST)).to_s.strip
121
+ host = DEFAULT_HOST if host.empty?
122
+ return host if %w[127.0.0.1 ::1 localhost].include?(host)
123
+
124
+ Samagotchi::Log.warn(:web, "bind_forced", echo: "Web server only binds to 127.0.0.1 (got #{host}); forcing 127.0.0.1", host: host.to_s)
125
+ DEFAULT_HOST
126
+ end
127
+
128
+ # The page for +dir+: its project view (?dir=) when it is in a repo,
129
+ # the plain page (every session) outside one or with scope "all".
130
+ def self.scope_url(host, port, dir:, scope: "project")
131
+ base = "http://#{url_host(host)}:#{port}/"
132
+ return base if scope.to_s == "all" || ProjectScope.root_for(dir).nil?
133
+
134
+ # Slashes stay as they are (valid in a query): a URL people can read.
135
+ "#{base}?dir=#{URI.encode_www_form_component(dir).gsub("%2F", "/")}"
136
+ end
137
+
138
+ def self.url_host(host)
139
+ host.include?(":") ? "[#{host}]" : host
140
+ end
141
+
142
+ # What answers on the port: the /api/info hash of a chi web that knows
143
+ # ?dir, :free when nothing listens, :other for anything else (an older
144
+ # chi web, another program).
145
+ def self.probe(host, port, timeout: PROBE_TIMEOUT)
146
+ response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
147
+ probe_verdict(response.code.to_i, response.body)
148
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL
149
+ :free
150
+ rescue StandardError
151
+ :other
152
+ end
153
+
154
+ def self.probe_verdict(status, body)
155
+ info = status == 200 ? JSON.parse(body.to_s) : nil
156
+ info.is_a?(Hash) && info["app"] == "chi-web" && Array(info["features"]).include?("dir") ? info : :other
157
+ rescue JSON::ParserError
158
+ :other
159
+ end
160
+
161
+ def self.in_use_message(port)
162
+ "Error: port #{port} is in use (an older chi web? restart it, or use --port)"
163
+ end
164
+
165
+ def self.resolve_port(port)
166
+ raw = port || ENV.fetch("SAMAGOTCHI_WEB_PORT", DEFAULT_PORT.to_s)
167
+ parsed = raw.to_s.to_i
168
+ parsed.positive? ? parsed : DEFAULT_PORT
169
+ end
170
+
171
+ def self.open_url(url)
172
+ case RbConfig::CONFIG["host_os"]
173
+ when /darwin/ then system("open", url)
174
+ when /linux/ then system("xdg-open", url)
175
+ when /mswin|mingw/ then system("start", url)
176
+ else Samagotchi::Log.info(:web, "open_manually", echo: "Please open #{url} manually")
177
+ end
178
+ rescue StandardError => e
179
+ Samagotchi::Log.warn(:web, "open_failed", echo: "Failed to open browser: #{e.message} — please open #{url} manually", error: e.class.name)
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "time"
6
+
7
+ require_relative "../session"
8
+ require_relative "../session_manager"
9
+ require_relative "../bridge_client"
10
+ require_relative "../log"
11
+ require_relative "session_summary"
12
+
13
+ module Samagotchi
14
+ module Web
15
+ # chi web's one in-memory projection of the session list, fed by a cheap
16
+ # watcher over the state dir, pushed to every open tab (GET /api/events).
17
+ # Files stay the source of truth; workers and the Bridge don't know it.
18
+ #
19
+ # Every session-JSON writer goes tmp + rename, and rename-into-dir and
20
+ # unlink bump the parent dir's mtime, so one stat of the state dir says
21
+ # "some session file changed" and one stat of `<id>/` says "recap.json
22
+ # or bridge.json appeared, changed or went". Nothing in-place is
23
+ # watched: the pid file and owner.lock are read by the owner probes.
24
+ #
25
+ # #scan does one pass and is driven by the tick thread (#start) or by a
26
+ # spec. Scan, #touch, the projection and the deliveries all run under
27
+ # one Monitor, and a subscriber is added and its snapshot taken as one
28
+ # step under it, so no stale upsert can follow a fresh snapshot.
29
+ class SessionHub
30
+ SCAN_INTERVAL = 1.0
31
+ # The flock leaves no file trace when its holder dies, so owners are
32
+ # probed: every tick for the sessions the projection believes owned
33
+ # (a handful; OwnerLock.owner is open + LOCK_SH|LOCK_NB + close), and
34
+ # all sessions this often (a plain REPL that opened an old session
35
+ # without saving it has no file signal until its first save).
36
+ FULL_PROBE_INTERVAL = 10.0
37
+
38
+ # What a subscriber gets: `session` with {session: summary} for an
39
+ # upsert, `session_gone` with {id:} for a removal. seq is monotonic per
40
+ # hub, for the log's sake: there is no replay.
41
+ Event = Struct.new(:type, :seq, :data, keyword_init: true)
42
+
43
+ # The projection's entry: the summary the page sees, the parsed
44
+ # (messages-less) Session it was built from, and the stamps that say
45
+ # whether anything changed: the file's [mtime, size], the session
46
+ # dir's mtime, and the live stamp {pid, kind, bridge_started_at} so
47
+ # a change of worker is a change even when `owner` reads the same.
48
+ Entry = Struct.new(:session, :summary, :stamp, :dir_stamp, :owner, :live, keyword_init: true)
49
+
50
+ # Returned by #subscribe.
51
+ class Subscription
52
+ def initialize(sink, hub)
53
+ @sink = sink
54
+ @hub = hub
55
+ end
56
+
57
+ def call(event)
58
+ @sink.call(event)
59
+ end
60
+
61
+ def unsubscribe
62
+ @hub.unsubscribe(self)
63
+ end
64
+ end
65
+
66
+ # @param state_dir [String] the sessions dir (Session.default_state_dir)
67
+ # @param manager [#session_owner, #retention_sweep_if_due] SessionManager,
68
+ # or a stand-in
69
+ # @param now [#call] a monotonic clock in seconds (specs drive it)
70
+ def initialize(state_dir:, manager: SessionManager, now: nil, scan_interval: SCAN_INTERVAL,
71
+ full_probe_interval: FULL_PROBE_INTERVAL)
72
+ @state_dir = state_dir
73
+ @manager = manager
74
+ @now = now || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
75
+ @scan_interval = scan_interval
76
+ @full_probe_interval = full_probe_interval
77
+ @monitor = Monitor.new
78
+ @sessions = {} # id → Entry
79
+ @subscribers = []
80
+ @seq = 0
81
+ @dir_mtime = nil # the state dir's mtime at the last glob
82
+ @root_cache = {} # folder → project root, for sessions saved before project_root
83
+ @next_full_probe = nil
84
+ @wakeup = Queue.new # stop wakes the tick thread out of its sleep
85
+ @thread = nil
86
+ @stopped = false
87
+ end
88
+
89
+ # Spawn the tick thread (idempotent). Logs nothing: Server.start's
90
+ # start and stop lines are the web log's.
91
+ def start
92
+ return self if @thread&.alive?
93
+
94
+ @stopped = false
95
+ @thread = Thread.new { run_loop }
96
+ self
97
+ end
98
+
99
+ # End the tick thread (idempotent); the event loops see #stopped?.
100
+ def stop
101
+ @stopped = true
102
+ @wakeup << :stop
103
+ thread = @thread
104
+ @thread = nil
105
+ thread&.join(@scan_interval + 1.0)
106
+ nil
107
+ end
108
+
109
+ def stopped?
110
+ @stopped
111
+ end
112
+
113
+ # One pass: what changed on disk since the last one, emitted. Every
114
+ # +full_probe_interval+ it also probes every session's owner and
115
+ # gives the retention sweep its chance (it used to run on
116
+ # GET /api/sessions, which the page no longer calls).
117
+ def scan
118
+ @monitor.synchronize do
119
+ full = full_probe_due?
120
+ scan_files
121
+ @sessions.each_key { |id| refresh(id, probe: full) }
122
+ sweep if full
123
+ end
124
+ nil
125
+ end
126
+
127
+ # Rescan one session now (its file, its folder, its owner), for the
128
+ # page's own actions: no waiting for the next tick.
129
+ def touch(id)
130
+ @monitor.synchronize do
131
+ path = File.join(@state_dir, "#{id}#{Session::FILE_EXT}")
132
+ if File.file?(path)
133
+ @sessions[id] ||= Entry.new
134
+ refresh(id, probe: true)
135
+ elsif @sessions.key?(id)
136
+ drop(id)
137
+ end
138
+ end
139
+ nil
140
+ end
141
+
142
+ # The projection as the list route answers it: sorted, and only
143
+ # +project_root+'s sessions when one is given (nil: every session, as
144
+ # outside a repo).
145
+ # @return [Array<Hash>] summaries
146
+ def snapshot(project_root: nil, sort: "updated_at", order: "desc")
147
+ @monitor.synchronize do
148
+ entries = @sessions.values.reject { |e| e.summary.nil? }
149
+ entries = entries.select { |e| e.summary[:project_root] == project_root } if project_root
150
+ key = sort.to_s == "created_at" ? :created_at : :updated_at
151
+ sorted = entries.sort_by { |e| sort_time(e.summary[key]) }
152
+ sorted.reverse! unless order.to_s == "asc"
153
+ sorted.map(&:summary)
154
+ end
155
+ end
156
+
157
+ # Register a sink (#call(event)). With +snapshot:+ the current
158
+ # projection comes back with the handle, taken in the same step, so
159
+ # every event after it is newer than it.
160
+ # @return [Subscription, (Subscription, Array<Hash>)]
161
+ def subscribe(sink, snapshot: false, project_root: nil)
162
+ @monitor.synchronize do
163
+ handle = Subscription.new(sink, self)
164
+ @subscribers << handle
165
+ return handle unless snapshot
166
+
167
+ [handle, self.snapshot(project_root: project_root)]
168
+ end
169
+ end
170
+
171
+ def unsubscribe(handle)
172
+ @monitor.synchronize { !@subscribers.delete(handle).nil? }
173
+ end
174
+
175
+ private
176
+
177
+ def run_loop
178
+ until @stopped
179
+ begin
180
+ scan
181
+ rescue StandardError => e
182
+ Log.warn(:web, "hub_scan_failed", error: e.class.name, msg: e.message)
183
+ end
184
+ @wakeup.pop(timeout: @scan_interval)
185
+ end
186
+ end
187
+
188
+ def full_probe_due?
189
+ now = @now.call
190
+ return false if @next_full_probe && now < @next_full_probe
191
+
192
+ @next_full_probe = now + @full_probe_interval
193
+ true
194
+ end
195
+
196
+ def sweep
197
+ return unless @manager.respond_to?(:retention_sweep_if_due)
198
+
199
+ @manager.retention_sweep_if_due(state_dir: @state_dir)
200
+ rescue StandardError
201
+ nil
202
+ end
203
+
204
+ # The state dir's files: a new id is parsed and emitted, an id gone
205
+ # from disk is dropped, a changed file is re-parsed. Skipped when the
206
+ # dir's mtime says nothing was renamed in or unlinked since.
207
+ def scan_files
208
+ dir_stat = begin
209
+ File.stat(@state_dir)
210
+ rescue SystemCallError
211
+ nil
212
+ end
213
+ if dir_stat.nil?
214
+ @sessions.keys.each { |id| drop(id) }
215
+ @dir_mtime = nil
216
+ return
217
+ end
218
+ return if dir_stat.mtime == @dir_mtime
219
+
220
+ @dir_mtime = dir_stat.mtime
221
+ on_disk = Dir.glob(File.join(@state_dir, "*#{Session::FILE_EXT}")).to_h do |path|
222
+ [File.basename(path, Session::FILE_EXT), path]
223
+ end
224
+ (@sessions.keys - on_disk.keys).each { |id| drop(id) }
225
+ on_disk.each_key { |id| @sessions[id] ||= Entry.new }
226
+ end
227
+
228
+ # Bring one entry up to date with its file, its folder and its owner;
229
+ # emit when the page would see a difference. The owner is probed when
230
+ # asked (+probe+), when the entry believes it has one (to see it go),
231
+ # and when its folder changed (a new owner.lock, a bridge.json).
232
+ def refresh(id, probe: false)
233
+ entry = @sessions[id]
234
+ path = File.join(@state_dir, "#{id}#{Session::FILE_EXT}")
235
+ stat = begin
236
+ File.stat(path)
237
+ rescue SystemCallError
238
+ nil
239
+ end
240
+ return drop(id) if stat.nil?
241
+
242
+ stamp = [stat.mtime, stat.size]
243
+ changed = false
244
+ if stamp != entry.stamp
245
+ session = Session.summary_from_file(path)
246
+ # Corrupt, or missing a field: not a session, as Session.list has it.
247
+ return drop(id) if session.nil?
248
+
249
+ # Looked up once per parse (a git call for a session saved before
250
+ # the field), never per tick.
251
+ session.project_root = session.project_root(cache: @root_cache)
252
+ entry.session = session
253
+ entry.stamp = stamp
254
+ changed = true
255
+ end
256
+ dir = Session.session_dir(id, state_dir: @state_dir)
257
+ dir_stamp = dir_mtime(dir)
258
+ if dir_stamp != entry.dir_stamp
259
+ entry.dir_stamp = dir_stamp
260
+ changed = true
261
+ end
262
+ owner = probe || changed || entry.owner ? owner_of(id) : entry.owner
263
+ live = live_stamp(owner, dir)
264
+ live_changed = live != entry.live
265
+ return unless changed || live_changed
266
+
267
+ entry.owner = owner
268
+ entry.live = live
269
+ summary = SessionSummary.build(entry.session, owner: owner, session_dir: dir)
270
+ # A new worker (pid) or a new Bridge for the same session is an
271
+ # event for the page even when the summary reads the same: it
272
+ # reconnects its stream on it.
273
+ return if summary == entry.summary && !live_changed
274
+
275
+ entry.summary = summary
276
+ emit("session", session: summary)
277
+ end
278
+
279
+ def drop(id)
280
+ entry = @sessions.delete(id)
281
+ emit("session_gone", id: id) if entry&.summary
282
+ end
283
+
284
+ def owner_of(id)
285
+ return nil unless @manager.respond_to?(:session_owner)
286
+
287
+ @manager.session_owner(id, state_dir: @state_dir)
288
+ rescue StandardError
289
+ nil
290
+ end
291
+
292
+ def dir_mtime(dir)
293
+ File.stat(dir).mtime
294
+ rescue SystemCallError
295
+ nil
296
+ end
297
+
298
+ # A change of worker is a change even when `owner` reads "worker"
299
+ # both times: the pid, or the Bridge's start time, tells them apart.
300
+ def live_stamp(owner, dir)
301
+ return nil unless owner
302
+
303
+ { pid: owner["pid"], kind: owner["kind"], bridge_started_at: bridge_started_at(dir) }
304
+ end
305
+
306
+ def bridge_started_at(dir)
307
+ JSON.parse(File.read(File.join(dir, BridgeClient::SIDECAR_FILE)))["started_at"]
308
+ rescue StandardError
309
+ nil
310
+ end
311
+
312
+ def emit(type, data)
313
+ @seq += 1
314
+ event = Event.new(type: type, seq: @seq, data: data)
315
+ @subscribers.dup.each do |handle|
316
+ handle.call(event)
317
+ rescue StandardError => e
318
+ Log.warn(:web, "hub_subscriber_failed", error: e.class.name, msg: e.message)
319
+ end
320
+ end
321
+
322
+ def sort_time(value)
323
+ Time.iso8601(value.to_s)
324
+ rescue ArgumentError
325
+ Time.at(0)
326
+ end
327
+ end
328
+ end
329
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../recap_store"
5
+ require_relative "../bridge_client"
6
+
7
+ module Samagotchi
8
+ module Web
9
+ # A session as the page's cards and list see it: the saved session's
10
+ # fields with its live owner applied. Built the same way wherever a
11
+ # session is answered (GET /api/sessions, the session view, the session
12
+ # hub's events), so every path agrees on status, owner and bridge_up.
13
+ module SessionSummary
14
+ module_function
15
+
16
+ # @param session [Session] a saved session (messages may be empty)
17
+ # @param owner [Hash, nil] SessionManager.session_owner's answer; its
18
+ # kind is shown as `owner`
19
+ # @param session_dir [String] the session's folder (recap.json, bridge.json)
20
+ # @param status [String] the turn state to show; by default
21
+ # .displayed_status of the session and its owner
22
+ # @param root_cache [Hash, nil] ProjectScope.root_for's cache, shared
23
+ # across one listing (a session saved before project_root existed
24
+ # looks its project up)
25
+ # @return [Hash] symbol keys
26
+ def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil)
27
+ used = session.respond_to?(:used_memory_names) ? Array(session.used_memory_names) : []
28
+ {
29
+ id: session.id,
30
+ status: status,
31
+ mode: session.mode,
32
+ model_name: session.model_name,
33
+ working_directory: session.working_directory,
34
+ created_at: session.created_at,
35
+ updated_at: session.updated_at,
36
+ last_prompt: session.last_prompt,
37
+ short_id: session.id.to_s[0, 8],
38
+ test_run: !!session.test_run,
39
+ used_memory_names: used,
40
+ # The session's --memory and --mute lists (info-bar tooltip).
41
+ preloaded_memory_names: session.respond_to?(:preloaded_memory_names) ? Array(session.preloaded_memory_names) : [],
42
+ muted_memory_names: session.respond_to?(:muted_memory_names) ? Array(session.muted_memory_names) : [],
43
+ # The session that delegated this one (the `delegate` tool), else nil.
44
+ parent_id: session.respond_to?(:parent_id) ? session.parent_id : nil,
45
+ first_preview: first_preview_for(session),
46
+ owner: owner&.fetch("kind", nil),
47
+ # The saved recap's first sentence, for the session card.
48
+ recap: RecapStore.preview(session_dir),
49
+ project_root: session.project_root(cache: root_cache),
50
+ # A worker is reachable: its Bridge sidecar is there and the
51
+ # owner lock is held. A sidecar a dead worker left is not up.
52
+ bridge_up: bridge_up?(session_dir, owner)
53
+ }
54
+ end
55
+
56
+ # status is turn state (idle/running). The live worker's snapshot is the
57
+ # truth; on disk, a "running" with no live owner was left by a worker
58
+ # that died mid-turn.
59
+ def displayed_status(session, snapshot = nil, owner:)
60
+ return snapshot["status"] if snapshot.is_a?(Hash) && snapshot["status"]
61
+ return session.status unless session.status == Session::STATUS_RUNNING
62
+
63
+ owner ? session.status : Session::STATUS_IDLE
64
+ end
65
+
66
+ # @return [Boolean] the sidecar file is there and the session has a
67
+ # live owner (the file alone says nothing: the worker may be gone)
68
+ def bridge_up?(session_dir, owner)
69
+ !owner.nil? && File.file?(File.join(session_dir, BridgeClient::SIDECAR_FILE))
70
+ rescue StandardError
71
+ false
72
+ end
73
+
74
+ def first_preview_for(session)
75
+ raw = session.first_preview || session.last_prompt || ""
76
+ norm = raw.to_s.gsub(/\s+/, " ").strip
77
+ return "" if norm.empty?
78
+
79
+ norm.length > 80 ? "#{norm[0, 80]}…" : norm
80
+ rescue StandardError
81
+ ""
82
+ end
83
+ end
84
+ end
85
+ end