samagotchi 0.120.0 → 0.121.1
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 +116 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +36 -11
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +10 -4
- data/docs/configuration.md +34 -11
- data/docs/guardrails.md +48 -19
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/participants.md +6 -2
- data/docs/internals/prompt-caching.md +7 -6
- data/docs/internals/remote-sessions.md +15 -11
- data/docs/internals/web-push.md +22 -11
- data/docs/plugins.md +28 -8
- data/docs/sessions.md +15 -0
- data/docs/testing.md +44 -2
- data/lib/samagotchi/answer_command.rb +1 -1
- data/lib/samagotchi/bridge.rb +8 -8
- data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
- data/lib/samagotchi/bridge_client/stream_open.rb +79 -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 +21 -7
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +5 -7
- data/lib/samagotchi/children_status.rb +2 -1
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/guardrail_wiring.rb +19 -2
- 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/kernel_loop.rb +5 -0
- data/lib/samagotchi/live_versions.rb +6 -7
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/plugin/api.rb +14 -8
- data/lib/samagotchi/plugin/sessions.rb +13 -4
- 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/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/local.rb +18 -8
- data/lib/samagotchi/session_access/remote.rb +5 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
- data/lib/samagotchi/session_access.rb +21 -9
- data/lib/samagotchi/session_continue.rb +2 -3
- data/lib/samagotchi/session_manager.rb +118 -28
- data/lib/samagotchi/sessions_command.rb +10 -5
- data/lib/samagotchi/system_one/client.rb +6 -1
- data/lib/samagotchi/system_one/fake_backend.rb +0 -2
- data/lib/samagotchi/system_one/log.rb +21 -6
- 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/question_slot.rb +19 -8
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/toolkit.rb +117 -27
- data/lib/samagotchi/toolkit_binding.rb +32 -0
- data/lib/samagotchi/toolkit_command.rb +23 -0
- data/lib/samagotchi/tools/deferred_tools.rb +10 -2
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +9 -7
- data/lib/samagotchi/tools/registry.rb +20 -6
- data/lib/samagotchi/tools/tool_index.rb +103 -20
- data/lib/samagotchi/turn_checkpoint.rb +15 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +123 -65
- data/lib/samagotchi/web/participant_access.rb +26 -7
- data/lib/samagotchi/web/participant_review.rb +11 -6
- data/lib/samagotchi/web/participant_view.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +13 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/forms.css +12 -0
- data/lib/samagotchi/web/public/index.html +7 -5
- data/lib/samagotchi/web/public/level_chip.js +2 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
- data/lib/samagotchi/web/public/notifications.js +21 -1
- data/lib/samagotchi/web/public/participant.html +4 -2
- data/lib/samagotchi/web/public/participant.js +34 -11
- data/lib/samagotchi/web/public/participants_panel.js +49 -14
- data/lib/samagotchi/web/public/push.js +4 -3
- data/lib/samagotchi/web/public/push_devices.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +15 -1
- data/lib/samagotchi/web/public/question_cards.js +11 -2
- data/lib/samagotchi/web/public/review_history.js +24 -4
- 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/toolkit_chip.js +3 -0
- 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/session_access_routes.rb +25 -17
- 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/tunnel.rb +10 -1
- data/lib/samagotchi/worker.rb +1 -1
- data/lib/samagotchi/worker_link.rb +213 -0
- data/lib/samagotchi/worker_sidecar.rb +15 -37
- metadata +4 -1
|
@@ -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.
|
|
@@ -147,6 +161,6 @@ attach claude-outbox --why "Claude's answers to what you sent it (claude-peer)"
|
|
|
147
161
|
if [ -z "$inbox" ]; then
|
|
148
162
|
echo "paired with chi $id (socket $sock)"
|
|
149
163
|
else
|
|
150
|
-
echo "paired with chi $id (socket $inbox on chi's machine, forwarded
|
|
164
|
+
echo "paired with chi $id (socket $inbox on chi's machine, forwarded to this Claude's $sock)"
|
|
151
165
|
fi
|
|
152
166
|
$chi context ls "$id"
|
|
@@ -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,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.7.
|
|
3
|
+
version: 0.7.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; their tools go into chi's tool index: the model searches them with find_tools and calls one with call_tool (<server>/<tool>); /mcp lists them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:c4bb4cf99ad43168ea68c25283a153c5dea1e5be123743c8a4c1278c16b5ea90
|
|
11
11
|
# 0.67.0 is the release with chi.tool_source (the core tool index the bundle publishes into).
|
|
12
|
-
requires_chi: ">= 0.
|
|
12
|
+
requires_chi: ">= 0.121.0"
|
|
@@ -354,6 +354,7 @@ class Plugin
|
|
|
354
354
|
def register(chi)
|
|
355
355
|
@chi = chi
|
|
356
356
|
ctx = chi.ctx
|
|
357
|
+
@ctx = ctx
|
|
357
358
|
configs = @settings["servers"]
|
|
358
359
|
configs = {} unless configs.is_a?(Hash)
|
|
359
360
|
unless configs.empty?
|
|
@@ -952,7 +953,10 @@ class Plugin
|
|
|
952
953
|
return "No servers. Add them in config.yml under `bundles: mcp: servers:` (docs/plugins.md, The mcp bundle)." if @servers.empty?
|
|
953
954
|
|
|
954
955
|
servers = @servers.map do |server|
|
|
955
|
-
|
|
956
|
+
marker = if @source && !@source.declared_groups.include?(server.name)
|
|
957
|
+
" (not in toolkit #{@ctx.toolkit})"
|
|
958
|
+
end
|
|
959
|
+
head = "**#{server.name}**: #{state_text(server)}#{marker}"
|
|
956
960
|
# Sorted: a server lists its tools in its own (often grouped) order.
|
|
957
961
|
tools = server.tools.sort_by(&:name).map { |t| ["- `#{t.server}/#{t.name}`", mark(t)].compact.join(" ") }
|
|
958
962
|
tools.empty? ? head : "#{head}\n#{tools.join("\n")}"
|
|
@@ -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/engine.rb
CHANGED
|
@@ -377,6 +377,7 @@ module Samagotchi
|
|
|
377
377
|
# A cut tool output is saved whole in the session's folder.
|
|
378
378
|
@kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
|
|
379
379
|
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
380
|
+
@kernel.participant_turn = -> { Steer.participant_author?(turn_author) }
|
|
380
381
|
# The turn-end warm-up; the kernel asks it for a slot pin per request.
|
|
381
382
|
@prompt_warmup = PromptWarmup.new
|
|
382
383
|
@kernel.warmup = @prompt_warmup
|
|
@@ -271,7 +271,14 @@ module Samagotchi
|
|
|
271
271
|
# System 1: the ask is judged before the user sees it, and in `on` mode
|
|
272
272
|
# one that passes every gate is allowed once without reaching them
|
|
273
273
|
# (docs/guardrails.md, "System 1").
|
|
274
|
-
judgment =
|
|
274
|
+
judgment = begin
|
|
275
|
+
system_one_judge(payload)
|
|
276
|
+
rescue LLM::RequestCancelled
|
|
277
|
+
return turn_stopped(verdict)
|
|
278
|
+
end
|
|
279
|
+
# A turn stopped while the judge was asked opens no card.
|
|
280
|
+
return turn_stopped(verdict) if @cancelled_lookup.call
|
|
281
|
+
|
|
275
282
|
if judgment && judgment[:auto]
|
|
276
283
|
verdict.judgment = { id: judgment[:id], p: judgment[:p] }
|
|
277
284
|
verdict.settle!(:allow, decided_by: "system one")
|
|
@@ -286,6 +293,12 @@ module Samagotchi
|
|
|
286
293
|
|
|
287
294
|
private
|
|
288
295
|
|
|
296
|
+
# The deny for a call whose turn was stopped before its ask reached the
|
|
297
|
+
# user (as Guardrails::Gate words one stopped before the vote).
|
|
298
|
+
def turn_stopped(verdict)
|
|
299
|
+
verdict.settle!(:deny, decided_by: "core", note: "The turn was stopped before the user was asked.")
|
|
300
|
+
end
|
|
301
|
+
|
|
289
302
|
# The judgments log (SystemOne::Log), beside the approvals store; nil
|
|
290
303
|
# while there is no store dir.
|
|
291
304
|
def system_one_log
|
|
@@ -319,6 +332,8 @@ module Samagotchi
|
|
|
319
332
|
entries: entries),
|
|
320
333
|
questions: { SystemOne::ApprovalQuestion::ID => SystemOne::ApprovalQuestion.question },
|
|
321
334
|
cancel_controller: @cancel_controller.call)
|
|
335
|
+
rescue LLM::RequestCancelled
|
|
336
|
+
raise
|
|
322
337
|
rescue StandardError => e
|
|
323
338
|
# The client already turns every failure it knows into a nil; this is
|
|
324
339
|
# the unexpected one. Either way the ask goes to the user.
|
|
@@ -338,6 +353,8 @@ module Samagotchi
|
|
|
338
353
|
latency_ms: latency_ms, decision: decision, origin: "live")
|
|
339
354
|
# A judgment the log cannot hold is no judgment: the user is asked.
|
|
340
355
|
{ id: id, p: p, auto: auto && !id.nil? }
|
|
356
|
+
rescue LLM::RequestCancelled
|
|
357
|
+
raise
|
|
341
358
|
rescue StandardError => e
|
|
342
359
|
# The approval never fails because of System 1.
|
|
343
360
|
Samagotchi::Log.warn(:guardrails, "system_one_judgment_failed", error: "#{e.class}: #{e.message}")
|
|
@@ -408,7 +425,7 @@ module Samagotchi
|
|
|
408
425
|
"deny"
|
|
409
426
|
end
|
|
410
427
|
end
|
|
411
|
-
system_one_log.label(id, label)
|
|
428
|
+
system_one_log.label(id, label, question_id: (answer.id if answer.is_a?(Samagotchi::QuestionAnswer)))
|
|
412
429
|
rescue StandardError => e
|
|
413
430
|
Samagotchi::Log.warn(:guardrails, "system_one_label_failed", error: "#{e.class}: #{e.message}")
|
|
414
431
|
nil
|
|
@@ -69,6 +69,15 @@ module Samagotchi
|
|
|
69
69
|
# memory-remove rule's ask, which a parent may answer.
|
|
70
70
|
return false if fetch(facts, :rule).to_s == MEMORY_REMOVE_RULE
|
|
71
71
|
|
|
72
|
+
chi_files?(facts)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Whether an approval's paths or command name chi's own dirs (#chi_dirs)
|
|
76
|
+
# or CHI_TEXT, whatever its rule: the part of #protected_facts? that
|
|
77
|
+
# System 1's SystemOne::SelfProtect shares.
|
|
78
|
+
def chi_files?(facts)
|
|
79
|
+
return false unless facts.is_a?(Hash)
|
|
80
|
+
|
|
72
81
|
dirs = chi_dirs
|
|
73
82
|
paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
|
|
74
83
|
return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }
|