samagotchi 0.6.0 → 0.8.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/README.md +7 -3
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +19 -4
  6. data/docs/configuration.md +20 -0
  7. data/docs/desktop.md +49 -0
  8. data/docs/guardrails.md +2 -2
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/context-telemetry.md +3 -1
  11. data/docs/memory.md +48 -1
  12. data/docs/plugins.md +7 -5
  13. data/docs/releasing.md +5 -1
  14. data/docs/sessions.md +2 -2
  15. data/lib/samagotchi/archive_store.rb +3 -3
  16. data/lib/samagotchi/atomic_file.rb +34 -0
  17. data/lib/samagotchi/bootstrap/bundles.rb +37 -0
  18. data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
  19. data/lib/samagotchi/bootstrap/probe.rb +9 -5
  20. data/lib/samagotchi/bootstrap_command.rb +78 -8
  21. data/lib/samagotchi/bridge/pending_card.rb +2 -3
  22. data/lib/samagotchi/bridge.rb +56 -116
  23. data/lib/samagotchi/bridge_client.rb +7 -11
  24. data/lib/samagotchi/bundle_command.rb +81 -21
  25. data/lib/samagotchi/bundle_needs.rb +2 -11
  26. data/lib/samagotchi/bundles/core/manifest.yml +11 -0
  27. data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
  28. data/lib/samagotchi/bundles/system/identity.md +1 -0
  29. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  30. data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
  31. data/lib/samagotchi/client.rb +3 -23
  32. data/lib/samagotchi/config.rb +11 -11
  33. data/lib/samagotchi/context_note.rb +1 -1
  34. data/lib/samagotchi/context_status.rb +255 -0
  35. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  36. data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
  37. data/lib/samagotchi/desktop/macos/Images.swift +38 -3
  38. data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
  39. data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
  40. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
  41. data/lib/samagotchi/desktop/macos.rb +25 -11
  42. data/lib/samagotchi/desktop_command.rb +1 -1
  43. data/lib/samagotchi/engine.rb +6 -7
  44. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  45. data/lib/samagotchi/guardrails/approvals.rb +2 -7
  46. data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
  47. data/lib/samagotchi/hooks/loader.rb +2 -1
  48. data/lib/samagotchi/hooks/registry.rb +21 -0
  49. data/lib/samagotchi/image_store.rb +2 -3
  50. data/lib/samagotchi/kernel_loop.rb +21 -283
  51. data/lib/samagotchi/live_versions.rb +9 -13
  52. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  53. data/lib/samagotchi/llm/native_backend.rb +8 -8
  54. data/lib/samagotchi/llm/openai_chat.rb +4 -17
  55. data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
  56. data/lib/samagotchi/log_path.rb +2 -3
  57. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
  58. data/lib/samagotchi/memory_bundle/installer.rb +5 -7
  59. data/lib/samagotchi/memory_bundle/listing.rb +27 -20
  60. data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
  61. data/lib/samagotchi/memory_bundle/profile.rb +145 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
  64. data/lib/samagotchi/memory_bundle/source.rb +2 -0
  65. data/lib/samagotchi/memory_bundle/status.rb +1 -2
  66. data/lib/samagotchi/memory_bundle.rb +1 -0
  67. data/lib/samagotchi/note_command.rb +5 -4
  68. data/lib/samagotchi/owner_lock.rb +0 -6
  69. data/lib/samagotchi/paths.rb +32 -0
  70. data/lib/samagotchi/plugin/api.rb +3 -6
  71. data/lib/samagotchi/plugin/context.rb +2 -3
  72. data/lib/samagotchi/plugin/loader.rb +1 -1
  73. data/lib/samagotchi/question_desk.rb +3 -2
  74. data/lib/samagotchi/recap_store.rb +2 -2
  75. data/lib/samagotchi/reply_wait.rb +2 -1
  76. data/lib/samagotchi/self_report.rb +2 -3
  77. data/lib/samagotchi/send_command.rb +3 -2
  78. data/lib/samagotchi/session.rb +41 -143
  79. data/lib/samagotchi/session_inbox.rb +175 -0
  80. data/lib/samagotchi/session_manager.rb +62 -363
  81. data/lib/samagotchi/session_metrics.rb +2 -3
  82. data/lib/samagotchi/session_retention.rb +259 -0
  83. data/lib/samagotchi/sessions_command.rb +3 -2
  84. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
  85. data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
  86. data/lib/samagotchi/terminal_ui.rb +10 -20
  87. data/lib/samagotchi/tools/memory.rb +3 -2
  88. data/lib/samagotchi/tools/send_note.rb +4 -3
  89. data/lib/samagotchi/tools/task_runtime.rb +2 -3
  90. data/lib/samagotchi/turn_note.rb +13 -2
  91. data/lib/samagotchi/update_command.rb +10 -3
  92. data/lib/samagotchi/version.rb +1 -1
  93. data/lib/samagotchi/web/app.rb +44 -76
  94. data/lib/samagotchi/web/info_probe.rb +33 -0
  95. data/lib/samagotchi/web/public/app.js +1 -1
  96. data/lib/samagotchi/web/public/index.html +1 -0
  97. data/lib/samagotchi/web/public/stage_model.js +22 -3
  98. data/lib/samagotchi/web/public/stage_view.js +25 -10
  99. data/lib/samagotchi/web/server.rb +8 -11
  100. data/lib/samagotchi/web/session_hub.rb +8 -7
  101. data/lib/samagotchi/web/session_summary.rb +4 -3
  102. data/lib/samagotchi/web/token.rb +2 -5
  103. data/lib/samagotchi/worker.rb +34 -20
  104. data/lib/samagotchi/worker_sidecar.rb +42 -0
  105. metadata +15 -1
@@ -4,6 +4,7 @@ require "json"
4
4
  require "yaml"
5
5
  require "fileutils"
6
6
  require "ipaddr"
7
+ require_relative "../atomic_file"
7
8
  require_relative "../config"
8
9
 
9
10
  module Samagotchi
@@ -328,13 +329,10 @@ module Samagotchi
328
329
  candidate
329
330
  end
330
331
 
332
+ # The target's mode is kept (a fresh file's when it is new).
331
333
  def atomic_write(target, text)
332
- tmp = "#{target}.tmp-#{Process.pid}"
333
- File.binwrite(tmp, text)
334
- File.chmod(File.stat(target).mode & 0o7777, tmp) if File.exist?(target)
335
- File.rename(tmp, target)
336
- ensure
337
- FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
334
+ perm = File.stat(target).mode & 0o7777 if File.exist?(target)
335
+ AtomicFile.write(target, text, perm: perm)
338
336
  end
339
337
  end
340
338
  end
@@ -7,6 +7,8 @@ require "uri"
7
7
  require "ipaddr"
8
8
  require_relative "../version"
9
9
  require_relative "../llm/openai_chat"
10
+ require_relative "../llm/http"
11
+ require_relative "../llm/api_key"
10
12
 
11
13
  module Samagotchi
12
14
  module Bootstrap
@@ -223,12 +225,14 @@ module Samagotchi
223
225
  [response.code.to_i, response.body]
224
226
  end
225
227
 
228
+ # One attempt through LLM::HTTP (User-Agent, the key's Bearer header),
229
+ # any status returned as is. A key whose variable is not set sends no
230
+ # header: the server's 401 then says one is needed.
226
231
  def send_request(uri, request, key_env, read_timeout:)
227
- request["User-Agent"] = Samagotchi::USER_AGENT
228
- key = key_env && @env[key_env].to_s
229
- request["Authorization"] = "Bearer #{key}" if key && !key.strip.empty?
230
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
231
- read_timeout: read_timeout) { |http| http.request(request) }
232
+ key = key_env && !@env[key_env].to_s.strip.empty? ? LLM::ApiKey.for(key_env, host: uri.host, env: @env) : nil
233
+ LLM::HTTP.new(label: uri.host, open_timeout: @timeout, read_timeout: read_timeout, api_key: key,
234
+ retry_policy: LLM::HTTP::RetryPolicy.none)
235
+ .fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "probe" })
232
236
  end
233
237
 
234
238
  def network_errors
@@ -5,13 +5,15 @@ require_relative "config"
5
5
  require_relative "model_profile"
6
6
  require_relative "bootstrap/probe"
7
7
  require_relative "bootstrap/config_writer"
8
+ require_relative "bootstrap/bundles"
8
9
 
9
10
  module Samagotchi
10
11
  # `chi bootstrap [TARGET]`: the first setup. It names a model server, works
11
12
  # out what it is (llama.cpp's native API or an OpenAI-compatible one),
12
13
  # picks the model, sends one test request and writes config.yml: a fresh
13
- # file, or a hosts: entry added to an existing one. Prompts only on a
14
- # terminal; a script gets the list and exit 2 instead.
14
+ # file, or a hosts: entry added to an existing one. Then it installs the
15
+ # system bundle and the core profile, and on a terminal asks about dev.
16
+ # Prompts only on a terminal; a script gets the list and exit 2 instead.
15
17
  class BootstrapCommand
16
18
  USAGE = <<~TEXT
17
19
  Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--no-test] [--dry-run]
@@ -31,16 +33,25 @@ module Samagotchi
31
33
  --dry-run show what would be written; write nothing
32
34
  With no config.yml it writes one; an existing one gets a hosts:
33
35
  entry added (after a backup), and its other lines stay as they are.
36
+ Then it installs the system bundle and the core bundles (loop-guard,
37
+ check-in, guardrails), and on a terminal offers the dev bundles.
34
38
  TEXT
35
39
 
36
40
  PICK_SHOWN = 20
37
41
  # A test request slower than this says it is waiting (a model loading).
38
42
  LOADING_AFTER = 3
43
+ # The profile bootstrap installs, and the one it offers on a terminal.
44
+ CORE = "core"
45
+ DEV = "dev"
46
+ # The config outcomes after which the bundles are installed.
47
+ INSTALLS_BUNDLES = %i[new appended exists snippet].freeze
39
48
 
40
49
  # @param argv [Array<String>] the arguments after "bootstrap"
41
50
  # @param probe [Bootstrap::Probe, nil] (specs)
51
+ # @param bundles [Bootstrap::Bundles, nil] (specs: the bundles go where
52
+ # the process ENV points, not +env+)
42
53
  def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, env: ENV, probe: nil, config_path: nil,
43
- platform: RbConfig::CONFIG["host_os"])
54
+ platform: RbConfig::CONFIG["host_os"], bundles: nil)
44
55
  @argv = argv.dup
45
56
  @stdin = stdin
46
57
  @stdout = stdout
@@ -49,6 +60,7 @@ module Samagotchi
49
60
  @probe = probe || Bootstrap::Probe.new(env: env)
50
61
  @config_path = config_path || ConfigFile.global_path(env: env)
51
62
  @platform = platform
63
+ @bundles = bundles || Bootstrap::Bundles.new
52
64
  end
53
65
 
54
66
  # @return [Integer] 0 written (or already there), 1 failed (a failed
@@ -244,7 +256,64 @@ module Samagotchi
244
256
  return nil
245
257
  end
246
258
  outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
247
- report(outcome, model)
259
+ code = report(outcome, model)
260
+ return code unless INSTALLS_BUNDLES.include?(outcome.kind)
261
+
262
+ code = [code, install_bundles].max
263
+ next_steps if outcome.kind == :new
264
+ code
265
+ end
266
+
267
+ # The system bundle, core, and dev when a terminal says yes: one line
268
+ # each. 1 when something failed.
269
+ def install_bundles(dry_run: false)
270
+ ok = system_bundle_line(@bundles.sync_system(dry_run: dry_run), dry_run)
271
+ ok = install_profile(CORE, dry_run) && ok
272
+ unless dry_run || !tty? || (members = dev_to_install).empty?
273
+ @stdout.print("Also install #{DEV} (#{members.join(", ")})? [y/N] ")
274
+ ok = install_profile(DEV, false) && ok if @stdin.gets.to_s.strip.downcase.start_with?("y")
275
+ end
276
+ ok ? 0 : 1
277
+ end
278
+
279
+ # One line for the profile; false when it (or a member) failed.
280
+ def install_profile(name, dry_run)
281
+ profile_line(@bundles.install(name, dry_run: dry_run), dry_run)
282
+ rescue StandardError => e
283
+ @stdout.puts("#{name} bundles: failed (#{e.message.lines.first.to_s.strip})")
284
+ false
285
+ end
286
+
287
+ def dev_to_install
288
+ @bundles.to_install(DEV)
289
+ rescue StandardError
290
+ []
291
+ end
292
+
293
+ def system_bundle_line(result, dry_run)
294
+ version = "v#{result.to || result.from}"
295
+ text = case result.status
296
+ when :installed then dry_run ? "would install #{version}" : "#{version} installed"
297
+ when :updated then "#{result.from} → #{result.to} #{dry_run ? "would update" : "updated"}"
298
+ when :restored then "#{version} #{dry_run ? "would restore" : "restored"} missing files"
299
+ when :up_to_date then "#{version} up to date"
300
+ when :newer_installed then "v#{result.from} left as is (newer than this chi's v#{result.to})"
301
+ when :skipped then "skipped (#{result.error})"
302
+ else "failed (#{result.error})"
303
+ end
304
+ @stdout.puts("system bundle: #{text}")
305
+ result.status != :failed
306
+ end
307
+
308
+ def profile_line(result, dry_run = false)
309
+ parts = []
310
+ parts << "#{dry_run ? "would install" : "installed"} #{result.installed.join(", ")}" unless result.installed.empty?
311
+ parts << "already installed #{result.already.join(", ")}" unless result.already.empty?
312
+ result.skipped.each { |member, why| parts << "skipped #{member} (#{why})" }
313
+ result.failed.each { |member, why| parts << "#{member} failed (#{why})" }
314
+ parts << "nothing new to install" if parts.empty?
315
+ @stdout.puts("#{result.name} bundles: #{parts.join("; ")}")
316
+ !result.failed?
248
317
  end
249
318
 
250
319
  # The hosts entry: host/port for a plain http server (api: openai when it
@@ -267,10 +336,9 @@ module Samagotchi
267
336
  @stdout.puts("dry run: would write #{outcome.where} (#{path}):")
268
337
  @stdout.puts(outcome.text.gsub(/^/, " "))
269
338
  @stdout.puts("and print: #{outcome.model_hint}") if outcome.model_hint
270
- 0
339
+ install_bundles(dry_run: true)
271
340
  when :new
272
341
  @stdout.puts("config: #{path} (new)")
273
- next_steps
274
342
  0
275
343
  when :appended
276
344
  @stdout.puts("config: #{path} (hosts entry '#{outcome.name}' added; backup #{File.basename(outcome.backup)})")
@@ -296,10 +364,12 @@ module Samagotchi
296
364
  end
297
365
 
298
366
  def next_steps
299
- lines = [["chi", "a session"], ["chi web --open", "the web UI"], ["chi bundle list", "optional bundles (guardrails, …)"]]
367
+ dev = dev_to_install
368
+ bundles = dev.empty? ? ["chi bundle list", "the installed bundles"] : ["chi bundle install #{DEV}", "more bundles (#{dev.join(", ")})"]
369
+ lines = [["chi", "a session"], ["chi web --open", "the web UI"], bundles]
300
370
  lines << ["chi desktop install", "macOS \"Send to chi\" helper"] if @platform.to_s.include?("darwin")
301
371
  lines.each_with_index do |(command, what), i|
302
- @stdout.puts("#{i.zero? ? "next:" : " "} #{command.ljust(20)} # #{what}")
372
+ @stdout.puts("#{i.zero? ? "next:" : " "} #{command.ljust(22)} # #{what}")
303
373
  end
304
374
  end
305
375
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
+ require_relative "../atomic_file"
5
6
 
6
7
  module Samagotchi
7
8
  class Bridge
@@ -72,9 +73,7 @@ module Samagotchi
72
73
 
73
74
  def write(id, bundle)
74
75
  FileUtils.mkdir_p(File.dirname(@path))
75
- temp = "#{@path}.tmp"
76
- File.write(temp, JSON.generate({ "id" => id, "bundle" => bundle }))
77
- File.rename(temp, @path)
76
+ AtomicFile.write(@path, JSON.generate({ "id" => id, "bundle" => bundle }))
78
77
  @id = id
79
78
  end
80
79
 
@@ -6,6 +6,8 @@ require "uri"
6
6
  require "fileutils"
7
7
  require "securerandom"
8
8
  require "time"
9
+ require_relative "atomic_file"
10
+ require_relative "session_inbox"
9
11
 
10
12
  require_relative "bridge/bounded_queue"
11
13
  require_relative "bridge/card_store"
@@ -20,6 +22,7 @@ require_relative "session_commands"
20
22
  require_relative "image_store"
21
23
  require_relative "log"
22
24
  require_relative "version"
25
+ require_relative "worker_sidecar"
23
26
 
24
27
  module Samagotchi
25
28
  # Bridge is an optional HTTP transport that lets an external web / desktop
@@ -30,7 +33,7 @@ module Samagotchi
30
33
  # Design invariants (see multi_ui_architecture):
31
34
  # * Reuses `Engine#subscribe` for SSE fan-out — never a second Engine, never
32
35
  # `run_turn` across the thread/process boundary. POST reuses the
33
- # `SessionManager` file-IPC input path.
36
+ # `SessionInbox` file-IPC input path.
34
37
  # * One capture observer fills a shared ring buffer; each connection gets a
35
38
  # per-connection live queue (see SSEWriter).
36
39
  # * Binds 127.0.0.1 only. There is NO auth: binding 0.0.0.0 exposes remote
@@ -39,7 +42,6 @@ module Samagotchi
39
42
  # engaged as a transport.
40
43
  class Bridge
41
44
  DEFAULT_BIND = "127.0.0.1"
42
- SIDECAR_FILE = "bridge.json"
43
45
  DEFAULT_RING_CAPACITY = 256
44
46
  DEFAULT_HEARTBEAT_INTERVAL = 15.0
45
47
  # Cancel reasons a client may name (the web sends user, an attached TUI
@@ -63,7 +65,7 @@ module Samagotchi
63
65
  # @param heartbeat_interval [Float] idle `: ping` seconds
64
66
  # @param input_format [Integer, nil] the input-file format the owning
65
67
  # worker reads, advertised in the sidecar for writers (see
66
- # SessionManager.write_turn_input); nil advertises none (plain text)
68
+ # SessionInbox::INPUT_FORMAT); nil advertises none
67
69
  # @param on_input [#call, nil] called once a turn for this session is
68
70
  # queued, to wake the worker loop (Worker::Waker#wake)
69
71
  # @param on_command [#call, nil] takes a session command
@@ -298,54 +300,28 @@ module Samagotchi
298
300
 
299
301
  method = request[:method].to_s.upcase
300
302
  headers = request[:headers]
303
+ route = route_for(method, request[:path])
301
304
  # Every request but a stream gets its answer before #stop kills this
302
305
  # thread; a stream never ends on its own.
303
- Thread.current[:bridge_answering] = !(stream_match(request[:path]) && method == "GET")
306
+ Thread.current[:bridge_answering] = route&.first != :stream
304
307
 
305
308
  if request[:too_large]
306
309
  write_json(io, 413, { "Connection" => "close" }, { error: "too_large", detail: "request body over #{MAX_BODY_BYTES} bytes" })
307
310
  break
308
311
  elsif browser_request?(headers)
309
312
  write_json(io, 403, nil, { error: "cross_origin", detail: "the bridge answers chi's own clients only" })
310
- elsif (m = stream_match(request[:path])) && method == "GET"
313
+ elsif route.nil?
314
+ write_json(io, 404, { "Allow" => "GET, POST" },
315
+ { error: "not_found", path: request[:path] })
316
+ elsif route.first == :stream
311
317
  cursor = reconnect_cursor(headers, request[:query])
312
318
  Log.debug(:bridge, "stream", method: method, path: request[:path], client_id: stream_client_id(request[:query]))
313
- serve_sse(io, m[1], last_event_id: cursor, snapshot: snapshot_requested?(request[:query]),
314
- client_id: stream_client_id(request[:query]))
319
+ serve_sse(io, route.last, last_event_id: cursor, snapshot: snapshot_requested?(request[:query]),
320
+ client_id: stream_client_id(request[:query]))
315
321
  break # SSE owns the connection until the client disconnects.
316
- elsif (m = cancel_match(request[:path])) && method == "POST"
317
- payload, status, body = handle_cancel(m[1], request[:body])
318
- write_json(io, status, payload, body)
319
- elsif (m = answer_match(request[:path])) && method == "POST"
320
- payload, status, body = handle_answer(m[1], request[:body])
321
- write_json(io, status, payload, body)
322
- elsif (m = dismiss_match(request[:path])) && method == "POST"
323
- payload, status, body = handle_dismiss_question(m[1], request[:body])
324
- write_json(io, status, payload, body)
325
- elsif (m = turn_match(request[:path])) && method == "POST"
326
- payload, status, body = handle_post_turn(m[1], request[:body])
327
- write_json(io, status, payload, body)
328
- elsif (m = command_match(request[:path])) && method == "POST"
329
- payload, status, body = handle_command(m[1], request[:body])
330
- write_json(io, status, payload, body)
331
- elsif (m = exit_match(request[:path])) && method == "POST"
332
- payload, status, body = handle_exit_request(m[1], request[:body])
333
- write_json(io, status, payload, body)
334
- elsif (m = recap_match(request[:path])) && method == "POST"
335
- payload, status, body = handle_recap(m[1])
336
- write_json(io, status, payload, body)
337
- elsif (m = state_match(request[:path])) && method == "GET"
338
- payload, status, body = handle_state(m[1])
339
- write_json(io, status, payload, body)
340
- elsif (m = stats_match(request[:path])) && method == "GET"
341
- payload, status, body = handle_stats(m[1])
342
- write_json(io, status, payload, body)
343
- elsif (m = snapshot_match(request[:path])) && method == "GET"
344
- payload, status, body = handle_snapshot(m[1])
345
- write_json(io, status, payload, body)
346
322
  else
347
- write_json(io, 404, { "Allow" => "GET, POST" },
348
- { error: "not_found", path: request[:path] })
323
+ payload, status, body = send(route.first, route.last, request[:body])
324
+ write_json(io, status, payload, body)
349
325
  end
350
326
 
351
327
  Thread.current[:bridge_answering] = false
@@ -428,54 +404,36 @@ module Samagotchi
428
404
  !host.empty? && !LOOPBACK_NAMES.include?(host.downcase.sub(/:\d*\z/, ""))
429
405
  end
430
406
 
431
- def stream_match(path)
432
- %r|\A/session/([^/]+)/stream\z|u.match(path.to_s)
433
- end
434
-
435
- def turn_match(path)
436
- %r|\A/session/([^/]+)/turn\z|u.match(path.to_s)
437
- end
438
-
439
- def state_match(path)
440
- %r|\A/session/([^/]+)/state\z|u.match(path.to_s)
441
- end
442
-
443
- def stats_match(path)
444
- %r|\A/session/([^/]+)/stats\z|u.match(path.to_s)
445
- end
446
-
447
- def snapshot_match(path)
448
- %r|\A/session/([^/]+)/snapshot\z|u.match(path.to_s)
449
- end
450
-
451
- def cancel_match(path)
452
- %r|\A/session/([^/]+)/cancel\z|u.match(path.to_s)
453
- end
454
-
455
- def answer_match(path)
456
- %r|\A/session/([^/]+)/answer\z|u.match(path.to_s)
457
- end
458
-
459
- def dismiss_match(path)
460
- %r|\A/session/([^/]+)/question/dismiss\z|u.match(path.to_s)
461
- end
462
-
463
- def command_match(path)
464
- %r|\A/session/([^/]+)/command\z|u.match(path.to_s)
465
- end
466
-
467
- def exit_match(path)
468
- %r|\A/session/([^/]+)/exit\z|u.match(path.to_s)
469
- end
407
+ # The routes, by method and what follows /session/:id/: a handler takes
408
+ # the session id and the request body and returns [headers, status, body].
409
+ # GET stream is served apart (#serve_sse owns the connection).
410
+ ROUTES = {
411
+ %w[GET stream] => :stream,
412
+ %w[POST cancel] => :handle_cancel,
413
+ %w[POST answer] => :handle_answer,
414
+ %w[POST question/dismiss] => :handle_dismiss_question,
415
+ %w[POST turn] => :handle_post_turn,
416
+ %w[POST command] => :handle_command,
417
+ %w[POST exit] => :handle_exit_request,
418
+ %w[POST recap] => :handle_recap,
419
+ %w[GET state] => :handle_state,
420
+ %w[GET stats] => :handle_stats,
421
+ %w[GET snapshot] => :handle_snapshot
422
+ }.freeze
423
+ ROUTE_PATH = %r|\A/session/([^/]+)/(.+)\z|u
470
424
 
471
- def recap_match(path)
472
- %r|\A/session/([^/]+)/recap\z|u.match(path.to_s)
425
+ # @return [Array(Symbol, String), nil] the handler and the session id, or
426
+ # nil when no route takes this method and path
427
+ def route_for(method, path)
428
+ m = ROUTE_PATH.match(path.to_s) or return nil
429
+ handler = ROUTES[[method, m[2]]] or return nil
430
+ [handler, m[1]]
473
431
  end
474
432
 
475
433
  # /recap in an attached TUI: the saved recap, and a new one asked for at
476
434
  # once (it arrives as :recap_ready). Answers mid-turn too.
477
435
  # 200 {enabled:, saved:, request:, min_user_turns:}. Returns [headers, status, body].
478
- def handle_recap(session_id)
436
+ def handle_recap(session_id, _body = nil)
479
437
  return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
480
438
 
481
439
  recap = @engine.recap
@@ -680,6 +638,10 @@ module Samagotchi
680
638
  return [{ "Allow" => "POST" }, 400,
681
639
  { error: "missing_fields", detail: "session_id and prompt are required" }]
682
640
  end
641
+ # Checked before the id names a folder: "../x" would write outside the
642
+ # sessions dir (turn_images and enqueue_turn build paths from it).
643
+ return [{}, 400, { error: "invalid_session_id" }] unless Session.valid_id?(sid)
644
+
683
645
  images = turn_images(sid, fetched(parsed, "images"))
684
646
  return [{}, 400, { error: "bad_images", detail: images }] if images.is_a?(String)
685
647
 
@@ -716,17 +678,12 @@ module Samagotchi
716
678
 
717
679
  [{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: sid }]
718
680
  rescue StandardError => e
719
- # SessionManager loads lazily (enqueue_turn).
720
- if defined?(SessionManager::ImagesUnsupported) && e.is_a?(SessionManager::ImagesUnsupported)
721
- return [{}, 409, { error: "images_unsupported", detail: e.message }]
722
- end
723
-
724
681
  [{}, 500, { error: "bridge_error", detail: e.message }]
725
682
  end
726
683
 
727
684
  # Read-only snapshot surface. AC #4: too-old reconnects re-derive state
728
685
  # from here.
729
- def handle_state(session_id)
686
+ def handle_state(session_id, _body = nil)
730
687
  unless own_session?(session_id)
731
688
  return [{}, 404, { error: "unknown_session" }]
732
689
  end
@@ -738,7 +695,7 @@ module Samagotchi
738
695
  # /stats for an attached client: the metrics, with the window and prompt
739
696
  # profile asked from the server when no turn has reported them yet (so,
740
697
  # unlike /state, it may wait on one short /props GET).
741
- def handle_stats(session_id)
698
+ def handle_stats(session_id, _body = nil)
742
699
  return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
743
700
 
744
701
  [{}, 200, { session_id: @session_id, metrics: @engine.stats_snapshot }]
@@ -748,7 +705,7 @@ module Samagotchi
748
705
  # the messages elsewhere (the web server strips and formats them): it then
749
706
  # streams from the snapshot's event_seq, and the ring replays what came
750
707
  # after (or the stream resets). Returns [headers, status, body].
751
- def handle_snapshot(session_id)
708
+ def handle_snapshot(session_id, _body = nil)
752
709
  return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
753
710
 
754
711
  body = @engine.synchronize_events do
@@ -768,7 +725,7 @@ module Samagotchi
768
725
  # stream and state surfaces must serve that session and nothing else —
769
726
  # serving a different id's data (or closing with no response) would be a
770
727
  # cross-session leak. POST/turn enqueue stays lenient (fire-and-forget to
771
- # another worker's input dir) and validates via write_turn_input.
728
+ # another worker's input dir) through SessionInbox.write_input.
772
729
  def own_session?(session_id)
773
730
  session_id.to_s == @session_id.to_s
774
731
  end
@@ -797,14 +754,9 @@ module Samagotchi
797
754
  # Write a turn into the target session's input dir, reusing the file IPC
798
755
  # the worker polls. Never calls run_turn across the boundary.
799
756
  def enqueue_turn(session_id:, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [])
800
- require_relative "session_manager"
801
- Samagotchi::SessionManager.write_turn_input(
802
- session_id, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, no_interrupt: no_interrupt,
803
- state_dir: @state_dir, images: images
804
- )
805
- rescue LoadError
806
- # SessionManager not available (e.g. bridge used standalone in a spec).
807
- false
757
+ session_dir = Session.session_dir(session_id, state_dir: @state_dir || Session.default_state_dir)
758
+ SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
759
+ no_interrupt: no_interrupt, images: images)
808
760
  end
809
761
 
810
762
  # A turn's images as [{file:, name:}], or a String saying what's wrong.
@@ -963,31 +915,19 @@ module Samagotchi
963
915
  end
964
916
 
965
917
  def write_sidecar
966
- record = {
967
- "port" => @port,
968
- "bind" => @bind,
969
- "session_id" => @session_id,
970
- "started_at" => Time.now.iso8601(3),
971
- # The chi this worker runs (chi update reports older ones).
972
- "version" => Samagotchi::VERSION
973
- }
974
- record["input_format"] = @input_format if @input_format
975
- path = File.join(session_dir, SIDECAR_FILE)
976
- FileUtils.mkdir_p(session_dir)
977
- temp = "#{path}.tmp"
978
- File.write(temp, JSON.pretty_generate(record) + "\n")
979
- File.rename(temp, path)
918
+ # version: the chi this worker runs (chi update reports older ones).
919
+ WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
920
+ version: Samagotchi::VERSION, input_format: @input_format).write(session_dir)
980
921
  rescue StandardError => e
981
- Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{SIDECAR_FILE}: #{e.class}: #{e.message}", error: e.class.name)
922
+ Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{WorkerSidecar::FILE}: #{e.class}: #{e.message}", error: e.class.name)
982
923
  end
983
924
 
984
925
  # Only while it still names this bridge: a racing worker for the same
985
926
  # session may have written its own since.
986
927
  def remove_sidecar
987
- path = File.join(session_dir, SIDECAR_FILE)
988
- return unless @server && File.file?(path)
928
+ return unless @server
989
929
 
990
- File.unlink(path) if JSON.parse(File.read(path))["port"].to_i == @port
930
+ File.unlink(WorkerSidecar.path(session_dir)) if WorkerSidecar.read(session_dir)&.port == @port
991
931
  rescue StandardError
992
932
  nil
993
933
  end
@@ -3,19 +3,20 @@
3
3
  require "json"
4
4
  require "socket"
5
5
 
6
+ require_relative "worker_sidecar"
6
7
  require_relative "bridge_client/sse_parser"
7
8
  require_relative "bridge_client/event_stream"
8
9
 
9
10
  module Samagotchi
10
11
  # Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
11
12
  # each SessionManager worker runs for its Engine (see Bridge). Discovery goes
12
- # through the worker's `bridge.json` sidecar in the session directory.
13
+ # through the worker's `bridge.json` sidecar (WorkerSidecar) in the session
14
+ # directory.
13
15
  #
14
16
  # Requests are raw one-shot HTTP/1.1 over a TCPSocket (`Connection: close`),
15
17
  # exactly what the Bridge's minimal server expects.
16
18
  class BridgeClient
17
19
  HOST = "127.0.0.1"
18
- SIDECAR_FILE = "bridge.json"
19
20
  PROBE_TIMEOUT = 0.2
20
21
  STREAM_CONNECT_ATTEMPTS = 3
21
22
  # Seconds #stream waits for bytes before it asks `running` again.
@@ -58,13 +59,8 @@ module Samagotchi
58
59
  # @param session_dir [String]
59
60
  # @return [Integer, nil]
60
61
  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
62
+ port = WorkerSidecar.read(session_dir)&.port
63
+ return nil unless port&.positive?
68
64
 
69
65
  # Validate liveness: stale sidecar after worker death causes ECONNREFUSED
70
66
  # which surfaces as WEBrick ERROR. Probe quickly and clean up if dead.
@@ -72,7 +68,7 @@ module Samagotchi
72
68
  Socket.tcp(host, port, connect_timeout: PROBE_TIMEOUT).close
73
69
  rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError, StandardError
74
70
  begin
75
- File.unlink(sidecar)
71
+ File.unlink(WorkerSidecar.path(session_dir))
76
72
  rescue StandardError
77
73
  nil
78
74
  end
@@ -149,7 +145,7 @@ module Samagotchi
149
145
  # @return [Response]
150
146
  # @param no_interrupt [Boolean] run the turn with the raised iteration limit
151
147
  # @param images [Array<Hash>] refs ({file:, name:}) to images already in
152
- # the session's images/ (409 images_unsupported from an older worker)
148
+ # the session's images/
153
149
  def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil)
154
150
  body = { session_id: @session_id, prompt: prompt, client_id: client_id }
155
151
  body[:no_interrupt] = true if no_interrupt