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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -1
- data/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- data/docs/configuration.md +20 -0
- data/docs/desktop.md +49 -0
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +2 -2
- data/docs/internals/context-telemetry.md +3 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +7 -5
- data/docs/releasing.md +5 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/archive_store.rb +3 -3
- data/lib/samagotchi/atomic_file.rb +34 -0
- data/lib/samagotchi/bootstrap/bundles.rb +37 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
- data/lib/samagotchi/bootstrap/probe.rb +9 -5
- data/lib/samagotchi/bootstrap_command.rb +78 -8
- data/lib/samagotchi/bridge/pending_card.rb +2 -3
- data/lib/samagotchi/bridge.rb +56 -116
- data/lib/samagotchi/bridge_client.rb +7 -11
- data/lib/samagotchi/bundle_command.rb +81 -21
- data/lib/samagotchi/bundle_needs.rb +2 -11
- data/lib/samagotchi/bundles/core/manifest.yml +11 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
- data/lib/samagotchi/bundles/system/identity.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
- data/lib/samagotchi/client.rb +3 -23
- data/lib/samagotchi/config.rb +11 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +25 -11
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +6 -7
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -7
- data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
- data/lib/samagotchi/hooks/loader.rb +2 -1
- data/lib/samagotchi/hooks/registry.rb +21 -0
- data/lib/samagotchi/image_store.rb +2 -3
- data/lib/samagotchi/kernel_loop.rb +21 -283
- data/lib/samagotchi/live_versions.rb +9 -13
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/native_backend.rb +8 -8
- data/lib/samagotchi/llm/openai_chat.rb +4 -17
- data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
- data/lib/samagotchi/log_path.rb +2 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
- data/lib/samagotchi/memory_bundle/installer.rb +5 -7
- data/lib/samagotchi/memory_bundle/listing.rb +27 -20
- data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
- data/lib/samagotchi/memory_bundle/profile.rb +145 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
- data/lib/samagotchi/memory_bundle/source.rb +2 -0
- data/lib/samagotchi/memory_bundle/status.rb +1 -2
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/note_command.rb +5 -4
- data/lib/samagotchi/owner_lock.rb +0 -6
- data/lib/samagotchi/paths.rb +32 -0
- data/lib/samagotchi/plugin/api.rb +3 -6
- data/lib/samagotchi/plugin/context.rb +2 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/recap_store.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -1
- data/lib/samagotchi/self_report.rb +2 -3
- data/lib/samagotchi/send_command.rb +3 -2
- data/lib/samagotchi/session.rb +41 -143
- data/lib/samagotchi/session_inbox.rb +175 -0
- data/lib/samagotchi/session_manager.rb +62 -363
- data/lib/samagotchi/session_metrics.rb +2 -3
- data/lib/samagotchi/session_retention.rb +259 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
- data/lib/samagotchi/terminal_ui.rb +10 -20
- data/lib/samagotchi/tools/memory.rb +3 -2
- data/lib/samagotchi/tools/send_note.rb +4 -3
- data/lib/samagotchi/tools/task_runtime.rb +2 -3
- data/lib/samagotchi/turn_note.rb +13 -2
- data/lib/samagotchi/update_command.rb +10 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +44 -76
- data/lib/samagotchi/web/info_probe.rb +33 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +25 -10
- data/lib/samagotchi/web/server.rb +8 -11
- data/lib/samagotchi/web/session_hub.rb +8 -7
- data/lib/samagotchi/web/session_summary.rb +4 -3
- data/lib/samagotchi/web/token.rb +2 -5
- data/lib/samagotchi/worker.rb +34 -20
- data/lib/samagotchi/worker_sidecar.rb +42 -0
- 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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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.
|
|
14
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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
|
-
# `
|
|
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
|
-
#
|
|
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] =
|
|
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
|
|
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,
|
|
314
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
%
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
%
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
%
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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)
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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 #{
|
|
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
|
-
|
|
988
|
-
return unless @server && File.file?(path)
|
|
928
|
+
return unless @server
|
|
989
929
|
|
|
990
|
-
File.unlink(path) if
|
|
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
|
|
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
|
-
|
|
62
|
-
return nil unless
|
|
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(
|
|
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/
|
|
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
|