samagotchi 0.121.0 → 0.122.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 +110 -1
- data/bin/chi +7 -3
- data/docs/architecture.md +37 -11
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +11 -4
- data/docs/configuration.md +36 -7
- data/docs/guardrails.md +63 -19
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +2 -0
- data/docs/internals/participants.md +16 -8
- data/docs/internals/remote-sessions.md +15 -11
- data/docs/internals/web-push.md +22 -11
- data/docs/memory.md +33 -0
- data/docs/plugins.md +8 -3
- data/docs/sessions.md +21 -0
- data/docs/testing.md +41 -1
- data/lib/samagotchi/answer_command.rb +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +12 -11
- data/lib/samagotchi/bridge_client/event_stream.rb +14 -26
- data/lib/samagotchi/bridge_client/stream_open.rb +85 -0
- data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
- data/lib/samagotchi/bridge_client.rb +52 -65
- data/lib/samagotchi/bridge_secret.rb +9 -7
- data/lib/samagotchi/bundle_command/check.rb +27 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/child_move.rb +5 -7
- data/lib/samagotchi/children_status.rb +2 -1
- data/lib/samagotchi/config.rb +5 -0
- data/lib/samagotchi/engine.rb +107 -1
- data/lib/samagotchi/event_fanout.rb +0 -1
- data/lib/samagotchi/guardrail_wiring.rb +24 -6
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
- data/lib/samagotchi/guardrails_command.rb +41 -6
- data/lib/samagotchi/image_store.rb +6 -3
- data/lib/samagotchi/interrupted_turn.rb +6 -3
- data/lib/samagotchi/live_versions.rb +6 -7
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/memory_orphans.rb +11 -0
- data/lib/samagotchi/participant_mode.rb +11 -0
- data/lib/samagotchi/plugin/api.rb +14 -8
- data/lib/samagotchi/plugin/sessions.rb +13 -4
- data/lib/samagotchi/process_group.rb +13 -0
- data/lib/samagotchi/push_command.rb +1 -1
- data/lib/samagotchi/relay_verifier.rb +2 -3
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +8 -2
- data/lib/samagotchi/reply_wait.rb +4 -3
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/review_start.rb +6 -2
- data/lib/samagotchi/run_options.rb +2 -0
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +18 -8
- data/lib/samagotchi/session_access/remote.rb +37 -12
- data/lib/samagotchi/session_access/web_bridge_client.rb +38 -19
- data/lib/samagotchi/session_access.rb +21 -9
- data/lib/samagotchi/session_continue.rb +2 -3
- data/lib/samagotchi/session_manager.rb +133 -29
- data/lib/samagotchi/session_setup.rb +8 -4
- data/lib/samagotchi/sessions_command.rb +10 -5
- data/lib/samagotchi/steer.rb +7 -6
- data/lib/samagotchi/system_one/client.rb +12 -6
- data/lib/samagotchi/system_one/fake_backend.rb +0 -2
- data/lib/samagotchi/system_one/log.rb +44 -9
- data/lib/samagotchi/system_one/self_protect.rb +39 -43
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_runner.rb +2 -1
- data/lib/samagotchi/toolkit.rb +95 -10
- data/lib/samagotchi/toolkit_binding.rb +115 -7
- data/lib/samagotchi/toolkit_command.rb +15 -3
- data/lib/samagotchi/toolkit_report_command.rb +318 -0
- data/lib/samagotchi/toolkit_suggest.rb +211 -0
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +9 -7
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/turn_checkpoint.rb +15 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +127 -69
- data/lib/samagotchi/web/participant_access.rb +30 -8
- data/lib/samagotchi/web/participant_review.rb +8 -5
- data/lib/samagotchi/web/participant_view.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +13 -0
- data/lib/samagotchi/web/principal.rb +3 -4
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/bubbles.js +3 -2
- data/lib/samagotchi/web/public/notifications.js +21 -1
- data/lib/samagotchi/web/public/participant.html +3 -2
- data/lib/samagotchi/web/public/participant.js +25 -10
- data/lib/samagotchi/web/public/participants_panel.js +46 -11
- data/lib/samagotchi/web/public/push.js +4 -3
- data/lib/samagotchi/web/public/push_devices.js +17 -3
- data/lib/samagotchi/web/public/review_history.js +14 -2
- data/lib/samagotchi/web/public/review_start.js +1 -3
- data/lib/samagotchi/web/public/session_actions.js +14 -2
- data/lib/samagotchi/web/public/storage.js +16 -0
- data/lib/samagotchi/web/public/turn_events.js +2 -1
- data/lib/samagotchi/web/public/turn_html.js +1 -1
- data/lib/samagotchi/web/push/client.rb +5 -0
- data/lib/samagotchi/web/push/store.rb +16 -3
- data/lib/samagotchi/web/push_routes.rb +3 -2
- data/lib/samagotchi/web/server.rb +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +28 -20
- data/lib/samagotchi/web/session_control.rb +4 -2
- data/lib/samagotchi/web/session_create.rb +4 -4
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/web/start_dir.rb +10 -5
- data/lib/samagotchi/worker.rb +17 -3
- data/lib/samagotchi/worker_link.rb +214 -0
- data/lib/samagotchi/worker_sidecar.rb +15 -37
- metadata +6 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../bridge_secret"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class BridgeClient
|
|
8
|
+
# The result of opening an SSE connection. Only Opened owns a socket the
|
|
9
|
+
# caller may read; refused sockets are consumed for their detail and closed.
|
|
10
|
+
module StreamOpen
|
|
11
|
+
Opened = Data.define(:sock, :status, :headers)
|
|
12
|
+
Refused = Data.define(:reason, :detail, :status)
|
|
13
|
+
Unreachable = Data.define(:attempts, :detail) do
|
|
14
|
+
def initialize(attempts:, detail: nil) = super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
BODY_LIMIT = 16_384
|
|
18
|
+
BODY_TIMEOUT = 5.0
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# The one place a stream's response status is interpreted. Through chi
|
|
23
|
+
# web, its mapped bridge-secret and not-live responses have their own
|
|
24
|
+
# status codes (502 and 503).
|
|
25
|
+
def classify(sock, status, headers)
|
|
26
|
+
return Unreachable.new(attempts: 0) unless sock
|
|
27
|
+
return Opened.new(sock: sock, status: 200, headers: headers) if status.to_i == 200
|
|
28
|
+
|
|
29
|
+
if status.to_i == 503
|
|
30
|
+
json = parse(refusal_body(sock, headers))
|
|
31
|
+
return Unreachable.new(attempts: 1, detail: json&.[]("detail"))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
body = refusal_body(sock, headers)
|
|
35
|
+
json = parse(body)
|
|
36
|
+
detail = json&.[]("detail") || (body unless body.empty?)
|
|
37
|
+
code = status.to_i
|
|
38
|
+
error = json&.[]("error")
|
|
39
|
+
outcome = if code == 404
|
|
40
|
+
Refused.new(reason: :unknown_session, detail: detail, status: code)
|
|
41
|
+
elsif BridgeSecret.refused?(code, json || body)
|
|
42
|
+
Refused.new(reason: :bridge_secret, detail: detail, status: code)
|
|
43
|
+
elsif code == 403 && error == "proxied"
|
|
44
|
+
Refused.new(reason: :proxied, detail: detail, status: code)
|
|
45
|
+
elsif code == 403 && error == "cross_origin"
|
|
46
|
+
Refused.new(reason: :cross_origin, detail: detail, status: code)
|
|
47
|
+
elsif code == 502 && error == BridgeSecret::ERROR
|
|
48
|
+
Refused.new(reason: :bridge_secret, detail: detail, status: code)
|
|
49
|
+
else
|
|
50
|
+
Refused.new(reason: :other, detail: detail, status: code)
|
|
51
|
+
end
|
|
52
|
+
outcome
|
|
53
|
+
ensure
|
|
54
|
+
sock&.close unless (status || 0).to_i == 200
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A refusal body is bounded and interruptible by closing its socket.
|
|
58
|
+
# Content-Length replies stop at their declared size; older Bridges
|
|
59
|
+
# without it are read through EOF.
|
|
60
|
+
def refusal_body(sock, headers)
|
|
61
|
+
length = headers.to_h["content-length"]&.to_i
|
|
62
|
+
limit = length&.between?(0, BODY_LIMIT) ? length : BODY_LIMIT
|
|
63
|
+
body = +""
|
|
64
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BODY_TIMEOUT
|
|
65
|
+
while body.bytesize < limit
|
|
66
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
67
|
+
break unless left.positive? && sock.wait_readable(left)
|
|
68
|
+
|
|
69
|
+
body << sock.readpartial(limit - body.bytesize)
|
|
70
|
+
end
|
|
71
|
+
body.force_encoding(Encoding::UTF_8).scrub
|
|
72
|
+
rescue IOError, SystemCallError
|
|
73
|
+
body.force_encoding(Encoding::UTF_8).scrub
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse(body)
|
|
77
|
+
json = JSON.parse(body)
|
|
78
|
+
json if json.is_a?(Hash)
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
private_class_method :parse
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -32,8 +32,14 @@ module Samagotchi
|
|
|
32
32
|
# @return [Array(TCPSocket, Integer, Hash), nil] the socket positioned
|
|
33
33
|
# at the body, the HTTP status and the response headers (names in
|
|
34
34
|
# lower case), or nil when the server can't be reached
|
|
35
|
+
# @raise [Errno::ETIMEDOUT] a connected server didn't finish its headers
|
|
35
36
|
def open(host:, port:, path:, last_event_id: nil, headers: {}, timeout: nil)
|
|
36
|
-
|
|
37
|
+
begin
|
|
38
|
+
sock = TCPSocket.new(host, port, connect_timeout: timeout || CONNECT_TIMEOUT)
|
|
39
|
+
rescue SystemCallError, SocketError, IOError
|
|
40
|
+
return nil
|
|
41
|
+
end
|
|
42
|
+
|
|
37
43
|
sock.write(request(host: host, port: port, path: path, last_event_id: last_event_id, headers: headers))
|
|
38
44
|
deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
|
|
39
45
|
status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
|
|
@@ -47,7 +53,7 @@ module Samagotchi
|
|
|
47
53
|
[sock, status, received]
|
|
48
54
|
rescue SystemCallError, SocketError, IOError
|
|
49
55
|
sock&.close rescue nil
|
|
50
|
-
|
|
56
|
+
raise
|
|
51
57
|
end
|
|
52
58
|
|
|
53
59
|
# The request's bytes.
|
|
@@ -8,6 +8,7 @@ require_relative "worker_sidecar"
|
|
|
8
8
|
require_relative "bridge_secret"
|
|
9
9
|
require_relative "delivery"
|
|
10
10
|
require_relative "chi_invocation"
|
|
11
|
+
require_relative "bridge_client/stream_open"
|
|
11
12
|
require_relative "bridge_client/sse_parser"
|
|
12
13
|
require_relative "bridge_client/event_stream"
|
|
13
14
|
require_relative "bridge_client/stream_request"
|
|
@@ -15,8 +16,8 @@ require_relative "bridge_client/stream_request"
|
|
|
15
16
|
module Samagotchi
|
|
16
17
|
# Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
|
|
17
18
|
# each SessionManager worker runs for its Engine (see Bridge). Discovery goes
|
|
18
|
-
# through the worker's `bridge.json` sidecar
|
|
19
|
-
# directory.
|
|
19
|
+
# through WorkerLink (the worker's `bridge.json` sidecar, WorkerSidecar, in
|
|
20
|
+
# the session directory); .for_sidecar makes the client.
|
|
20
21
|
#
|
|
21
22
|
# Requests are raw one-shot HTTP/1.1 over a TCPSocket (`Connection: close`),
|
|
22
23
|
# exactly what the Bridge's minimal server expects.
|
|
@@ -27,7 +28,7 @@ module Samagotchi
|
|
|
27
28
|
# The request header that carries the Bridge's secret (Bridge#secret_ok?).
|
|
28
29
|
SECRET_HEADER = BridgeSecret::HEADER
|
|
29
30
|
STREAM_CONNECT_ATTEMPTS = 3
|
|
30
|
-
# Seconds #
|
|
31
|
+
# Seconds #open_stream waits for the Bridge's response headers:
|
|
31
32
|
# a worker that took the connection and froze must not hold the web's
|
|
32
33
|
# proxy thread (as EventStream::HEADER_TIMEOUT for the TUI's).
|
|
33
34
|
STREAM_HEADER_TIMEOUT = 5.0
|
|
@@ -49,7 +50,7 @@ module Samagotchi
|
|
|
49
50
|
Response = Struct.new(:status, :body, keyword_init: true) do
|
|
50
51
|
def ok? = status == 200
|
|
51
52
|
|
|
52
|
-
# The Bridge refused it for want of its secret (BridgeSecret
|
|
53
|
+
# The Bridge refused it for want of its secret (BridgeSecret.refused says what to do).
|
|
53
54
|
def secret_refused? = BridgeSecret.refused?(status, body)
|
|
54
55
|
|
|
55
56
|
# @return [Hash, nil] the body parsed as JSON, or nil when it is not
|
|
@@ -75,14 +76,6 @@ module Samagotchi
|
|
|
75
76
|
"this session's worker runs an older chi and can't #{cant}; restart it: #{restart} (its turns still work)"
|
|
76
77
|
end
|
|
77
78
|
|
|
78
|
-
# The live Bridge's sidecar in +session_dir+, or nil. A sidecar whose
|
|
79
|
-
# port refuses a quick connect is stale (its worker died) and is removed.
|
|
80
|
-
# @param session_dir [String]
|
|
81
|
-
# @return [WorkerSidecar, nil]
|
|
82
|
-
def self.live_sidecar(session_dir, host: HOST)
|
|
83
|
-
WorkerSidecar.live(session_dir, unlink: true, host: host)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
79
|
# The one way a client is made for a worker: its port and its secret
|
|
87
80
|
# come from the same sidecar read, so a respawned worker (a new port, a
|
|
88
81
|
# new secret) is reached by a client made after it, never by hand.
|
|
@@ -94,24 +87,11 @@ module Samagotchi
|
|
|
94
87
|
new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout)
|
|
95
88
|
end
|
|
96
89
|
|
|
97
|
-
# @return [BridgeClient, nil] a client for the live Bridge in +session_dir+
|
|
98
|
-
def self.discover(session_id, session_dir:, host: HOST, read_timeout: READ_TIMEOUT)
|
|
99
|
-
for_sidecar(session_id, live_sidecar(session_dir, host: host), host: host, read_timeout: read_timeout)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
90
|
# How often a wait for a new worker's Bridge looks: a missing sidecar is
|
|
103
91
|
# one stat, and a 0.1 s step cost a create up to 0.1 s (the web's
|
|
104
92
|
# create landed on 0.53/0.63/0.73 s).
|
|
105
93
|
SPAWN_POLL_INTERVAL = 0.02
|
|
106
94
|
|
|
107
|
-
# Wait for a freshly spawned worker to publish its Bridge.
|
|
108
|
-
# @param timeout [Float] seconds
|
|
109
|
-
# @return [BridgeClient, nil] nil when no live sidecar appeared in time
|
|
110
|
-
def self.wait_for(session_id, session_dir:, timeout:, host: HOST)
|
|
111
|
-
sidecar = poll(timeout, interval: SPAWN_POLL_INTERVAL) { live_sidecar(session_dir, host: host) }
|
|
112
|
-
for_sidecar(session_id, sidecar, host: host)
|
|
113
|
-
end
|
|
114
|
-
|
|
115
95
|
# Call the block every +interval+ seconds until it returns a truthy value
|
|
116
96
|
# or +timeout+ seconds have passed (it is always called at least once).
|
|
117
97
|
# @return [Object, nil] the block's first truthy value
|
|
@@ -314,22 +294,26 @@ module Samagotchi
|
|
|
314
294
|
# @return [Array(Integer, Object)] the status and the parsed JSON body
|
|
315
295
|
# (nil when it isn't JSON); [nil, nil] when no reply came
|
|
316
296
|
def get(path)
|
|
297
|
+
response = get_response(path)
|
|
298
|
+
[response.status, response.json]
|
|
299
|
+
rescue StandardError
|
|
300
|
+
[nil, nil]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# GET /session/:id/<path>, preserving the raw response and raising on I/O.
|
|
304
|
+
# @return [Response]
|
|
305
|
+
def get_response(path)
|
|
317
306
|
sock = TCPSocket.new(@host, @port)
|
|
318
307
|
sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\n#{secret_line}Connection: close\r\n\r\n")
|
|
319
308
|
response = read_reply(sock, path)
|
|
320
|
-
sock.close rescue nil
|
|
321
309
|
# A 500 whose text has "200" in it is not a 200.
|
|
322
310
|
status = response && response[STATUS_LINE, 1]
|
|
323
|
-
|
|
311
|
+
raise IOError, "bridge #{path}: invalid response status" unless status
|
|
324
312
|
|
|
325
313
|
body = response.split("\r\n\r\n", 2)[1] || ""
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
rescue
|
|
329
|
-
nil
|
|
330
|
-
end]
|
|
331
|
-
rescue StandardError
|
|
332
|
-
[nil, nil]
|
|
314
|
+
Response.new(status: status.to_i, body: body)
|
|
315
|
+
ensure
|
|
316
|
+
sock&.close rescue nil
|
|
333
317
|
end
|
|
334
318
|
|
|
335
319
|
# Monotonic SSE cursor of the live Engine, from GET /session/:id/state.
|
|
@@ -342,46 +326,49 @@ module Samagotchi
|
|
|
342
326
|
nil
|
|
343
327
|
end
|
|
344
328
|
|
|
345
|
-
# GET /session/:id/stream
|
|
346
|
-
#
|
|
347
|
-
# @param query [String] "" or "?from_seq=N"
|
|
348
|
-
# @param last_event_id [String, nil] reconnect cursor; the Bridge prefers it
|
|
349
|
-
# over ?from_seq
|
|
350
|
-
# @param running [#call, nil] asked before every read, and every
|
|
351
|
-
# STREAM_POLL seconds of silence: the stream ends once it returns
|
|
352
|
-
# false. A quiet stream is never cut otherwise.
|
|
329
|
+
# Open GET /session/:id/stream and classify the response before the
|
|
330
|
+
# caller answers its own request.
|
|
353
331
|
# @param header_timeout [Float] see STREAM_HEADER_TIMEOUT
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
sock.close rescue nil
|
|
374
|
-
return
|
|
332
|
+
# @param attempts [Integer] retries for connection failures only
|
|
333
|
+
# @yield [sock] called after response headers arrive, before classifying a
|
|
334
|
+
# refusal; EventStream uses it to publish the socket for #close
|
|
335
|
+
# @return [StreamOpen::Opened, StreamOpen::Refused, StreamOpen::Unreachable]
|
|
336
|
+
def open_stream(query: "", last_event_id: nil, header_timeout: STREAM_HEADER_TIMEOUT,
|
|
337
|
+
attempts: STREAM_CONNECT_ATTEMPTS)
|
|
338
|
+
count = 0
|
|
339
|
+
while count < attempts
|
|
340
|
+
count += 1
|
|
341
|
+
connection = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
|
|
342
|
+
if connection
|
|
343
|
+
sock, status, headers = connection
|
|
344
|
+
unless !block_given? || yield(sock)
|
|
345
|
+
sock.close rescue nil
|
|
346
|
+
return StreamOpen::Unreachable.new(attempts: count)
|
|
347
|
+
end
|
|
348
|
+
return StreamOpen.classify(sock, status, headers)
|
|
349
|
+
end
|
|
350
|
+
sleep(0.15 * count) if count < attempts
|
|
375
351
|
end
|
|
352
|
+
StreamOpen::Unreachable.new(attempts: count)
|
|
353
|
+
rescue Errno::ETIMEDOUT
|
|
354
|
+
StreamOpen::Unreachable.new(attempts: [count, 1].max)
|
|
355
|
+
rescue SystemCallError, SocketError, IOError
|
|
356
|
+
StreamOpen::Unreachable.new(attempts: [count, 1].max)
|
|
357
|
+
end
|
|
376
358
|
|
|
359
|
+
# Read a successful stream opened by #open_stream.
|
|
360
|
+
# @param opened [StreamOpen::Opened]
|
|
361
|
+
# @param running [#call, nil] checked before every read and every
|
|
362
|
+
# STREAM_POLL seconds of silence
|
|
363
|
+
def read_stream(opened, running: nil)
|
|
364
|
+
sock = opened.sock
|
|
377
365
|
begin
|
|
378
366
|
loop do
|
|
379
367
|
if running
|
|
380
368
|
break unless running.call
|
|
381
369
|
next unless sock.wait_readable(STREAM_POLL)
|
|
382
370
|
end
|
|
383
|
-
|
|
384
|
-
yield chunk
|
|
371
|
+
yield sock.readpartial(4096)
|
|
385
372
|
rescue IOError, Errno::ECONNRESET, Errno::ECONNREFUSED # EOFError is an IOError
|
|
386
373
|
break
|
|
387
374
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require_relative "chi_invocation"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
# A session Bridge's secret, as both sides name it: the request header
|
|
@@ -12,16 +13,17 @@ module Samagotchi
|
|
|
12
13
|
HEADER = "X-Chi-Bridge"
|
|
13
14
|
# The 403's error.
|
|
14
15
|
ERROR = "bridge_secret"
|
|
15
|
-
# What a request without this Bridge's secret, or with another one, is
|
|
16
|
-
# told, and what a client shows when it gets that: the asker comes from
|
|
17
|
-
# a chi that reads no secret, a chi web, an attached terminal or a
|
|
18
|
-
# session worker (a delegate's parent) started before chi was updated.
|
|
19
|
-
REFUSED = "the request didn't name this session worker's secret: a chi web, an attached terminal or a session " \
|
|
20
|
-
"worker started before chi was updated can't reach it; restart chi web, re-attach terminals " \
|
|
21
|
-
"(chi --attach ID) and restart the older session workers (chi sessions restart ID...)"
|
|
22
16
|
|
|
23
17
|
module_function
|
|
24
18
|
|
|
19
|
+
# What every client shows when a worker refuses its secret.
|
|
20
|
+
def refused(session_id, via: ChiInvocation::HERE)
|
|
21
|
+
web = via.remote? ? via.web : "chi web"
|
|
22
|
+
"session #{session_id}'s worker refused this chi's request (it didn't name its secret): a chi web or " \
|
|
23
|
+
"terminal started before chi was updated can't reach it; restart #{web}, re-attach with " \
|
|
24
|
+
"#{via.attach(session_id)}, or restart the worker with #{via.command("sessions restart #{session_id}")}"
|
|
25
|
+
end
|
|
26
|
+
|
|
25
27
|
# Whether a Bridge reply (+status+, raw +body+) is this refusal.
|
|
26
28
|
def refused?(status, body)
|
|
27
29
|
return false unless status.to_i == 403
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "prism"
|
|
3
4
|
require "rbconfig"
|
|
4
5
|
require "stringio"
|
|
5
6
|
require "tmpdir"
|
|
@@ -214,8 +215,9 @@ module Samagotchi
|
|
|
214
215
|
floor = lower_bound(manifest.requires_chi)
|
|
215
216
|
return unless floor && File.file?(path(manifest.plugin[:file]))
|
|
216
217
|
|
|
217
|
-
|
|
218
|
-
|
|
218
|
+
source = File.read(path(manifest.plugin[:file]))
|
|
219
|
+
calls = source.scan(API_CALL).flat_map { |call| [call, call.split(".").first(2).join(".")] }
|
|
220
|
+
calls = (calls + keyword_calls(source)).uniq.select { |call| Plugin::Api::SINCE.key?(call) }
|
|
219
221
|
call = calls.max_by { |c| Gem::Version.new(Plugin::Api::SINCE[c]) } or return
|
|
220
222
|
version = Plugin::Api::SINCE[call]
|
|
221
223
|
return unless Gem::Version.new(version) > floor
|
|
@@ -226,6 +228,29 @@ module Samagotchi
|
|
|
226
228
|
fix: "requires_chi: \">= #{version}\"")
|
|
227
229
|
end
|
|
228
230
|
|
|
231
|
+
# Each API call's keywords in +source+, as "chi.toolkit(groups:)": the
|
|
232
|
+
# call's receiver as written, its name and the keyword (Prism sees
|
|
233
|
+
# them with or without parens, over lines). Nothing when it doesn't parse.
|
|
234
|
+
def keyword_calls(source)
|
|
235
|
+
result = Prism.parse(source)
|
|
236
|
+
return [] unless result.success?
|
|
237
|
+
|
|
238
|
+
found = []
|
|
239
|
+
nodes = [result.value]
|
|
240
|
+
while (node = nodes.pop)
|
|
241
|
+
nodes.concat(node.compact_child_nodes)
|
|
242
|
+
next unless node.is_a?(Prism::CallNode) && node.receiver && node.arguments
|
|
243
|
+
|
|
244
|
+
call = "#{node.receiver.slice}.#{node.name}"
|
|
245
|
+
node.arguments.arguments.grep(Prism::KeywordHashNode).each do |hash|
|
|
246
|
+
hash.elements.each do |pair|
|
|
247
|
+
found << "#{call}(#{pair.key.unescaped}:)" if pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
found
|
|
252
|
+
end
|
|
253
|
+
|
|
229
254
|
def lower_bound(requirement)
|
|
230
255
|
requirement.to_s.split(",").filter_map do |part|
|
|
231
256
|
version = part.strip[LOWER_BOUND, 1]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: claude-peer
|
|
3
|
-
version: 0.4.
|
|
3
|
+
version: 0.4.5
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Pair this session with a Claude Code session you work with: the claude_send tool and /claude send it a part of the work (a plan step, a diff, a finding) to validate or as context, and Claude's answer comes back as the claude-outbox context; /claude install-skill installs Claude's chi-peer skill, which pairs"
|
|
6
6
|
trust_level: reviewed
|
|
@@ -12,7 +12,7 @@ plugin:
|
|
|
12
12
|
file: plugin.rb
|
|
13
13
|
sha256: sha256:50f427d3862ae9d0d15693b4d887c528162b96c0320cc663df89d59d830d4660
|
|
14
14
|
scripts:
|
|
15
|
-
pair: sha256:
|
|
15
|
+
pair: sha256:d8089ec422a816367d8c41393235223e06b21cbc0ae1e37b6fc22e94ea9a1497
|
|
16
16
|
chi-peer-SKILL.md: sha256:b9366f8f4aee9c54d209368ce421eab7b971c03c75efab0415d1988fd40f9e60
|
|
17
17
|
# The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
|
|
18
18
|
# An agreeing answer's closing wake (the skills say an OK wakes chi once)
|
|
@@ -26,17 +26,20 @@ while [ $# -gt 0 ]; do
|
|
|
26
26
|
case "$1" in
|
|
27
27
|
--inbox-socket)
|
|
28
28
|
[ $# -ge 2 ] || usage
|
|
29
|
-
inbox=$2
|
|
29
|
+
inbox=$2
|
|
30
|
+
shift 2
|
|
30
31
|
[ -n "$inbox" ] || usage
|
|
31
32
|
;;
|
|
32
33
|
--inbox-socket=*)
|
|
33
|
-
inbox=${1#--inbox-socket=}
|
|
34
|
+
inbox=${1#--inbox-socket=}
|
|
35
|
+
shift
|
|
34
36
|
[ -n "$inbox" ] || usage
|
|
35
37
|
;;
|
|
36
38
|
-h | --help) usage ;;
|
|
37
39
|
*)
|
|
38
40
|
[ -z "$target" ] || usage
|
|
39
|
-
target=$1
|
|
41
|
+
target=$1
|
|
42
|
+
shift
|
|
40
43
|
;;
|
|
41
44
|
esac
|
|
42
45
|
done
|
|
@@ -84,10 +87,21 @@ case "$target" in
|
|
|
84
87
|
*) id=$target ;;
|
|
85
88
|
esac
|
|
86
89
|
|
|
87
|
-
# A JSON string: backslashes
|
|
90
|
+
# A JSON string: backslashes and quotes escaped, newlines, tabs and
|
|
91
|
+
# carriage returns as \n, \t, \r, any other control character as \u00XX.
|
|
88
92
|
json() {
|
|
89
|
-
printf '%s' "$1" |
|
|
90
|
-
|
|
93
|
+
printf '%s' "$1" | awk '
|
|
94
|
+
BEGIN {
|
|
95
|
+
ORS = ""; printf "\""
|
|
96
|
+
for (i = 1; i < 32; i++) esc[sprintf("%c", i)] = sprintf("\\u%04x", i)
|
|
97
|
+
esc["\t"] = "\\t"; esc["\r"] = "\\r"; esc["\\"] = "\\\\"; esc["\""] = "\\\""
|
|
98
|
+
}
|
|
99
|
+
NR > 1 { print "\\n" }
|
|
100
|
+
{
|
|
101
|
+
n = length($0)
|
|
102
|
+
for (i = 1; i <= n; i++) { c = substr($0, i, 1); print ((c in esc) ? esc[c] : c) }
|
|
103
|
+
}
|
|
104
|
+
END { printf "\"" }'
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
# Attaches a pushed source, unless the session has one of that name already.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: codex-peer
|
|
3
|
-
version: 0.3.
|
|
3
|
+
version: 0.3.4
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Pair this session with an OpenAI Codex CLI session you work with: the codex_send tool and /codex send it a part of the work (a plan step, a diff, a finding) to validate or as context, with codex queue, and Codex's answer comes back as the codex-outbox context; /codex install-skill installs Codex's chi-peer skill, which pairs"
|
|
6
6
|
trust_level: reviewed
|
|
@@ -12,7 +12,7 @@ plugin:
|
|
|
12
12
|
file: plugin.rb
|
|
13
13
|
sha256: sha256:b03465566272891237ac1906980ff6956579a4703d3a42c414ce50a81de551f6
|
|
14
14
|
scripts:
|
|
15
|
-
pair: sha256:
|
|
15
|
+
pair: sha256:a61c3d311ce3f3e5386e90f0f867f649a39763a5883877e731e43d84dd9c85aa
|
|
16
16
|
chi-peer-SKILL.md: sha256:7c392ac04c115ea1e762ad562b5c4c44c3eca71558ba5d9dfeaeabdd533b6a82
|
|
17
17
|
# The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
|
|
18
18
|
# An agreeing answer's closing wake (the skills say an OK wakes chi once)
|
|
@@ -93,10 +93,21 @@ MSG
|
|
|
93
93
|
exit 1
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
# A JSON string: backslashes
|
|
96
|
+
# A JSON string: backslashes and quotes escaped, newlines, tabs and
|
|
97
|
+
# carriage returns as \n, \t, \r, any other control character as \u00XX.
|
|
97
98
|
json() {
|
|
98
|
-
printf '%s' "$1" |
|
|
99
|
-
|
|
99
|
+
printf '%s' "$1" | awk '
|
|
100
|
+
BEGIN {
|
|
101
|
+
ORS = ""; printf "\""
|
|
102
|
+
for (i = 1; i < 32; i++) esc[sprintf("%c", i)] = sprintf("\\u%04x", i)
|
|
103
|
+
esc["\t"] = "\\t"; esc["\r"] = "\\r"; esc["\\"] = "\\\\"; esc["\""] = "\\\""
|
|
104
|
+
}
|
|
105
|
+
NR > 1 { print "\\n" }
|
|
106
|
+
{
|
|
107
|
+
n = length($0)
|
|
108
|
+
for (i = 1; i <= n; i++) { c = substr($0, i, 1); print ((c in esc) ? esc[c] : c) }
|
|
109
|
+
}
|
|
110
|
+
END { printf "\"" }'
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
# Attaches a pushed source, unless the session has one of that name already.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: github-pr
|
|
3
|
-
version: 0.5.
|
|
3
|
+
version: 0.5.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can offer the PR with a card first, or attach nothing
|
|
6
6
|
trust_level: reviewed
|
|
@@ -11,7 +11,7 @@ needs:
|
|
|
11
11
|
hint: brew install gh, then gh auth login
|
|
12
12
|
plugin:
|
|
13
13
|
file: plugin.rb
|
|
14
|
-
sha256: sha256:
|
|
14
|
+
sha256: sha256:cfadb7668528302b3631681e570899230b6878c80ccbc87dfb16c80d43dead7b
|
|
15
15
|
scripts:
|
|
16
16
|
pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
|
|
17
17
|
context_providers:
|
|
@@ -649,12 +649,8 @@ class LineLinker
|
|
|
649
649
|
out << head[pos..] << text[MAX_SCAN..].to_s
|
|
650
650
|
end
|
|
651
651
|
|
|
652
|
-
#
|
|
653
|
-
# no PR has the file, or more than one does.
|
|
654
|
-
def url_for(path, from, to = nil) = line_for(path, from, to)&.fetch(:url)
|
|
655
|
-
|
|
656
|
-
# {url:, diff:, path:, line:, end_line:} for +path+'s lines (see #url_for),
|
|
657
|
-
# or nil.
|
|
652
|
+
# {url:, diff:, path:, line:, end_line:} for +path+'s lines +from+..+to+
|
|
653
|
+
# (+to+ nil: one line), or nil: no PR has the file, or more than one does.
|
|
658
654
|
def line_for(path, from, to = nil)
|
|
659
655
|
found = @prs.filter_map do |pr, data|
|
|
660
656
|
file = resolve(data.files, path)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.122.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:df3f8da539a9f5812f79e089c2c8b9894da6124a89654f292160046a6bb060da
|
|
9
9
|
config_modification_protocol.md: sha256:ff4efccf2bdde453fc4e6741df8c18f78da78e353f6b1a5a64d0e7a631aea258
|
|
10
|
-
memory_guide.md: sha256:
|
|
10
|
+
memory_guide.md: sha256:3b9ff779ee962f1307d56937c826d5e581923114f3f39455b779ca618b363cd7
|
|
11
11
|
delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
|
|
@@ -86,6 +86,19 @@ Shape (plain Markdown, no frontmatter):
|
|
|
86
86
|
- **Updating**: when a step turned out different, update the skill in the same turn: `edit` those steps in its file (`<scope dir>/skill_<name>.md`), or rewrite it with `memory_write`; keep the rest as it was, add a dated Changelog line. No confirmation needed.
|
|
87
87
|
- **The `skills` bundle** (`chi bundle install skills`, optional) adds `/skill save [name] [--system]`, `/skill list`, `/skill show <name>`, `/skill diff <name> [N]`; it keeps older versions and shows a short diff line after each update.
|
|
88
88
|
|
|
89
|
+
## Toolkit memories
|
|
90
|
+
|
|
91
|
+
A memory named `toolkit_<name>` defines a kit named `<name>`. Its body is one fenced YAML block with `base`, `add`,
|
|
92
|
+
`remove`, `groups` and a required `description`; never include `prompt:`. Only registered tools are declared, and
|
|
93
|
+
the built-in names (`full`, `lean`, `child`, `review`) are reserved. Project kits take precedence over system kits;
|
|
94
|
+
config and bundle kits take precedence over both. `/toolkit` lists a memory kit's source, and `--mute
|
|
95
|
+
toolkit_<name>` skips it for the session.
|
|
96
|
+
|
|
97
|
+
Write a kit when the user asks for one, when they use `/toolkit add` to choose the same tools for the same kind of
|
|
98
|
+
work twice, or when `chi toolkit report` shows a kit repeatedly short of a tool for one kind of prompt. A tweak to a
|
|
99
|
+
config or bundle kit is a new memory kit based on it (`base: staging`, for example), never a `config.yml` edit.
|
|
100
|
+
Writing or updating a toolkit memory is free like writing a skill; say so in the reply.
|
|
101
|
+
|
|
89
102
|
## Memory Bundles — shareable packs
|
|
90
103
|
|
|
91
104
|
Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and any of: memories (`*.md`), `hooks/*.rb` (bundle hooks, `docs/hooks.md`), `guardrails/*.yml` (rules, `docs/guardrails.md`), a `plugin.rb` (commands, tools, hooks, services; `docs/plugins.md`). They are shareable and installable.
|
|
@@ -6,7 +6,7 @@ require "time"
|
|
|
6
6
|
require_relative "atomic_file"
|
|
7
7
|
require_relative "session"
|
|
8
8
|
require_relative "session_inbox"
|
|
9
|
-
require_relative "
|
|
9
|
+
require_relative "worker_link"
|
|
10
10
|
require_relative "log"
|
|
11
11
|
require_relative "tools/delegate_cursor"
|
|
12
12
|
|
|
@@ -93,13 +93,11 @@ module Samagotchi
|
|
|
93
93
|
# installed (SessionManager.worker_command): it moves.
|
|
94
94
|
# @return [String, nil]
|
|
95
95
|
def unmovable(child_id, state_dir:)
|
|
96
|
-
|
|
97
|
-
return "open in a chi REPL; close it there" if owner
|
|
96
|
+
link = WorkerLink.resolve(Session.session_dir(child_id, state_dir: state_dir), unlink: false)
|
|
97
|
+
return "open in a chi REPL; close it there" if link.owner&.tui?
|
|
98
|
+
return nil if !link.live? || link.features.include?(FEATURE)
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
return nil if sidecar.nil? || sidecar.features.include?(FEATURE)
|
|
101
|
-
|
|
102
|
-
"older chi worker#{" #{sidecar.version}" if sidecar.version}; restart it"
|
|
100
|
+
"older chi worker#{" #{link.version}" if link.version}; restart it"
|
|
103
101
|
end
|
|
104
102
|
|
|
105
103
|
# Move +ids+ (direct children of +from+) to +to+: the intent, the
|
|
@@ -5,6 +5,7 @@ require_relative "session"
|
|
|
5
5
|
require_relative "archive_store"
|
|
6
6
|
require_relative "git_head"
|
|
7
7
|
require_relative "reply_wait"
|
|
8
|
+
require_relative "worker_link"
|
|
8
9
|
require_relative "tools/delegate"
|
|
9
10
|
require_relative "tools/delegate_cursor"
|
|
10
11
|
|
|
@@ -106,7 +107,7 @@ module Samagotchi
|
|
|
106
107
|
# The keys of a children_of row that state_of and child read, for a
|
|
107
108
|
# session counts loaded itself.
|
|
108
109
|
def summary_of(session, state_dir:)
|
|
109
|
-
live =
|
|
110
|
+
live = WorkerLink.resolve(Session.session_dir(session.id, state_dir: state_dir), unlink: false).worker_owned?
|
|
110
111
|
{ live: live, busy: live && session.status == Session::STATUS_RUNNING, status: session.status,
|
|
111
112
|
updated_at: session.updated_at, waiting: session.waiting_question(live: live)&.dig(:kind),
|
|
112
113
|
preview: session.last_prompt.to_s }
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -249,6 +249,11 @@ module Samagotchi
|
|
|
249
249
|
# delegate child's. toolkit.kits (the user's own) is read by Toolkit.kits.
|
|
250
250
|
Entry.new(key: "toolkit.default", yaml_path: %w[toolkit default], type: :string, default: nil, expose: %i[env config], lifecycle: :session_creation),
|
|
251
251
|
Entry.new(key: "toolkit.child", yaml_path: %w[toolkit child], type: :string, default: nil, expose: %i[env config], lifecycle: :session_creation),
|
|
252
|
+
# Toolkit suggestion (ToolkitSuggest): off, audit, or on; on currently behaves as audit and never changes a session.
|
|
253
|
+
Entry.new(key: "toolkit.suggest.mode", yaml_path: %w[toolkit suggest mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :worker_start),
|
|
254
|
+
Entry.new(key: "toolkit.suggest.threshold", yaml_path: %w[toolkit suggest threshold], type: :float, default: 0.5, expose: %i[config], lifecycle: :worker_start),
|
|
255
|
+
Entry.new(key: "toolkit.suggest.defer_turns", yaml_path: %w[toolkit suggest defer_turns], type: :integer, default: 3, expose: %i[config], lifecycle: :worker_start),
|
|
256
|
+
Entry.new(key: "toolkit.suggest.defer_tokens", yaml_path: %w[toolkit suggest defer_tokens], type: :integer, default: 5000, expose: %i[config], lifecycle: :worker_start),
|
|
252
257
|
|
|
253
258
|
Entry.new(key: "default.max_tokens", yaml_path: %w[default max_tokens], type: :integer, default: nil, expose: %i[env config cli], lifecycle: :next_matching_use),
|
|
254
259
|
Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli], lifecycle: :next_matching_use),
|