samagotchi 0.103.0 → 0.105.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 +76 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -24
- data/docs/cli.md +97 -21
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +15 -1
- data/docs/internals/anthropic.md +2 -1
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +22 -1
- data/docs/internals/participants.md +167 -0
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/internals/remote-sessions.md +153 -27
- data/docs/internals/working-set.md +4 -3
- data/docs/plugins.md +7 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +89 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
- data/lib/samagotchi/bridge.rb +17 -10
- data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +13 -16
- data/lib/samagotchi/config.rb +1 -1
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +52 -8
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +8 -1
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/launch_mode.rb +4 -0
- data/lib/samagotchi/llm/chat_loop.rb +9 -1
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/participant_name.rb +21 -0
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/queued_command.rb +9 -5
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/run_options.rb +14 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +8 -4
- data/lib/samagotchi/session_access/contract.rb +42 -4
- data/lib/samagotchi/session_access/dispatch.rb +29 -10
- data/lib/samagotchi/session_access/local.rb +52 -1
- data/lib/samagotchi/session_access/remote.rb +112 -16
- data/lib/samagotchi/session_access/web_bridge_client.rb +60 -3
- data/lib/samagotchi/session_access.rb +85 -3
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +21 -5
- data/lib/samagotchi/session_metrics.rb +7 -1
- data/lib/samagotchi/session_participants.rb +59 -0
- data/lib/samagotchi/sessions_command.rb +14 -11
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/steer.rb +83 -10
- data/lib/samagotchi/system_prompt.rb +21 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
- data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +117 -29
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
- data/lib/samagotchi/terminal_ui.rb +4 -1
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/turn_checkpoint.rb +5 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -74
- data/lib/samagotchi/web/grants.rb +303 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/public/index.html +6 -5
- data/lib/samagotchi/web/public/working_set.js +6 -4
- data/lib/samagotchi/web/public/working_set_view.js +1 -1
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +120 -4
- data/lib/samagotchi/worker.rb +44 -11
- data/lib/samagotchi/worker_inbound.rb +5 -2
- metadata +12 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module SessionAccess
|
|
5
7
|
# Which session commands and flags run against another machine's
|
|
@@ -7,13 +9,16 @@ module Samagotchi
|
|
|
7
9
|
# and which run on this machine only (refused under a remote, exit 2:
|
|
8
10
|
# "chi <cmd> runs on this machine only: run it on <remote>"). The v1 scope is the remote-sessions plan's
|
|
9
11
|
# D3: send, answer, note, a session's context (add, push, ls, show, rm)
|
|
10
|
-
# and sessions list/stop/restart/archive/unarchive/delete
|
|
12
|
+
# and sessions list/stop/restart/archive/unarchive/delete, and chi share
|
|
13
|
+
# (the shared-sessions plan's P2); the rest
|
|
11
14
|
# stays local. bin/chi's subcommands that act on the machine, not on
|
|
12
|
-
# sessions, are MACHINE. The run flags (--attach, -p, --resume
|
|
13
|
-
#
|
|
15
|
+
# sessions, are MACHINE. The run flags (chi --attach, -p, --resume: a
|
|
16
|
+
# session in this terminal, a remote attach there) are RUN's.
|
|
14
17
|
#
|
|
15
18
|
# spec/session_access_contract_spec.rb fails when a subcommand, one of
|
|
16
|
-
# its subcommands or one of a supported one's flags is in neither
|
|
19
|
+
# its subcommands or one of a supported one's flags is in neither, or
|
|
20
|
+
# a run flag (RunOptions.switches) is in neither RUN list and isn't a
|
|
21
|
+
# config registry flag.
|
|
17
22
|
module Contract
|
|
18
23
|
SUPPORTED = :supported
|
|
19
24
|
LOCAL_ONLY = :local_only
|
|
@@ -111,11 +116,44 @@ module Samagotchi
|
|
|
111
116
|
"sessions clean" => LOCAL_ONLY,
|
|
112
117
|
"sessions stats" => LOCAL_ONLY,
|
|
113
118
|
|
|
119
|
+
# Participants (docs/internals/participants.md): the remote's link,
|
|
120
|
+
# through its chi web's participants routes.
|
|
121
|
+
"share" => SUPPORTED,
|
|
122
|
+
"share --name" => SUPPORTED,
|
|
123
|
+
"share --agent" => SUPPORTED,
|
|
124
|
+
"share --expires" => SUPPORTED,
|
|
125
|
+
"share ls" => SUPPORTED,
|
|
126
|
+
"share ls --format" => SUPPORTED,
|
|
127
|
+
"share revoke" => SUPPORTED,
|
|
128
|
+
"share revoke --all" => SUPPORTED,
|
|
129
|
+
|
|
114
130
|
"broadcast" => LOCAL_ONLY
|
|
115
131
|
}.freeze
|
|
116
132
|
|
|
133
|
+
# bin/chi's run flags under a remote (chi --remote NAME --attach ID,
|
|
134
|
+
# RemoteAttachCommand): what a remote attach takes, and what runs
|
|
135
|
+
# this machine's REPL or reads this machine's files (P4-D2..D4). A
|
|
136
|
+
# config registry flag (--recap-base-url …) is this machine's
|
|
137
|
+
# config: LOCAL_ONLY without being listed.
|
|
138
|
+
RUN = {
|
|
139
|
+
SUPPORTED => %w[--attach --resume -p --prompt --shared --model --thinking --llm-context --llm-context-apply
|
|
140
|
+
--llm-context-budget --toolkit --tool --no-tool --set --parent-answers --no-interrupt
|
|
141
|
+
--no-default-input --dir --version -h --help],
|
|
142
|
+
LOCAL_ONLY => %w[--no-shared --non-interactive -v --verbose --append-system-prompt --memory --mute --profile
|
|
143
|
+
--port --open --new-token --scope --web-markdown --no-web-markdown]
|
|
144
|
+
}.freeze
|
|
145
|
+
# A short run flag's name in a refusal.
|
|
146
|
+
RUN_WORDS = { "-v" => "--verbose" }.freeze
|
|
147
|
+
|
|
117
148
|
module_function
|
|
118
149
|
|
|
150
|
+
# @param flag [String] a run flag as RunOptions.switches lists it
|
|
151
|
+
# @return [Symbol, nil] SUPPORTED or LOCAL_ONLY; nil: not a run flag
|
|
152
|
+
def run_status(flag)
|
|
153
|
+
RUN.each { |status, flags| return status if flags.include?(flag) }
|
|
154
|
+
LOCAL_ONLY if Config.cli_entries.any? { |entry| [entry.cli_flag, entry.cli_flag.sub("--", "--no-")].include?(flag) }
|
|
155
|
+
end
|
|
156
|
+
|
|
119
157
|
# @param words [Array<String>] a command, maybe its subcommand, maybe
|
|
120
158
|
# a flag's long name: status("context", "add", "--cmd")
|
|
121
159
|
# @return [Symbol, nil] SUPPORTED, LOCAL_ONLY, or nil when not listed
|
|
@@ -21,10 +21,8 @@ module Samagotchi
|
|
|
21
21
|
# bin/chi's own words besides its SUBCOMMANDS (a refusal names them).
|
|
22
22
|
OWN_WORDS = %w[web scratch self].freeze
|
|
23
23
|
HELP = %w[--version -h --help].freeze
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
RUN_FLAGS = { "--attach" => "--attach", "--resume" => "--resume", "-p" => "-p", "--prompt" => "-p",
|
|
27
|
-
"--shared" => "--shared", "--no-shared" => "--no-shared" }.freeze
|
|
24
|
+
# --non-interactive's refusal: what runs one turn there instead.
|
|
25
|
+
ONE_TURN = "(%s send --new --dir D -m TEXT --wait runs one turn there)"
|
|
28
26
|
|
|
29
27
|
# A usage error in the flag itself (--remote with no name, a bad one).
|
|
30
28
|
class Usage < StandardError; end
|
|
@@ -54,6 +52,14 @@ module Samagotchi
|
|
|
54
52
|
|
|
55
53
|
def remote? = !@remote.nil?
|
|
56
54
|
|
|
55
|
+
# Whether the arguments are run flags, no subcommand: a session in
|
|
56
|
+
# this terminal (bin/chi's RunOptions parse them; under a remote,
|
|
57
|
+
# RemoteAttachCommand runs them).
|
|
58
|
+
def run_flags?
|
|
59
|
+
command = @argv.first
|
|
60
|
+
command.nil? || command.start_with?("-")
|
|
61
|
+
end
|
|
62
|
+
|
|
57
63
|
# Whether the arguments run here as they would without a remote: chi
|
|
58
64
|
# remote, --version, --help.
|
|
59
65
|
def here?
|
|
@@ -67,7 +73,8 @@ module Samagotchi
|
|
|
67
73
|
def refusal
|
|
68
74
|
words = local_words or return nil
|
|
69
75
|
|
|
70
|
-
"chi #{words} runs on this machine only: run it on #{@remote}"
|
|
76
|
+
line = "chi #{words} runs on this machine only: run it on #{@remote}"
|
|
77
|
+
words == "--non-interactive" ? "#{line} #{format(ONE_TURN, "chi --remote #{@remote}")}" : line
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
# The session command against the remote: refused (exit 2), or run
|
|
@@ -101,11 +108,11 @@ module Samagotchi
|
|
|
101
108
|
private
|
|
102
109
|
|
|
103
110
|
# The words that run here only: the command (and its subcommand, a
|
|
104
|
-
# flag) the Contract lists local_only, a machine command, or
|
|
105
|
-
#
|
|
111
|
+
# flag) the Contract lists local_only, a machine command, or a run
|
|
112
|
+
# flag Contract::RUN lists local_only. nil: supported.
|
|
106
113
|
def local_words
|
|
107
114
|
command = @argv.first
|
|
108
|
-
return run_words if
|
|
115
|
+
return run_words if run_flags?
|
|
109
116
|
return command if Contract::MACHINE.include?(command) || Contract.status(command).nil?
|
|
110
117
|
return command if Contract.status(command) == Contract::LOCAL_ONLY
|
|
111
118
|
|
|
@@ -124,9 +131,21 @@ module Samagotchi
|
|
|
124
131
|
@argv.drop(1).take_while { |arg| arg != "--" }.select { |arg| arg.start_with?("--") }.map { |arg| arg.split("=", 2).first }
|
|
125
132
|
end
|
|
126
133
|
|
|
134
|
+
# The first local-only run flag, a value (-p TEXT, --model NAME)
|
|
135
|
+
# never read as one. An unknown flag is the parser's to refuse.
|
|
127
136
|
def run_words
|
|
128
|
-
|
|
129
|
-
|
|
137
|
+
require_relative "../run_options"
|
|
138
|
+
values = RunOptions.value_switches
|
|
139
|
+
words = @argv.take_while { |arg| arg != "--" }
|
|
140
|
+
index = 0
|
|
141
|
+
while index < words.size
|
|
142
|
+
arg = words[index]
|
|
143
|
+
flag = arg.start_with?("--") ? arg.split("=", 2).first : arg[0, 2]
|
|
144
|
+
return Contract::RUN_WORDS.fetch(flag, flag) if arg.start_with?("-") && Contract.run_status(flag) == Contract::LOCAL_ONLY
|
|
145
|
+
|
|
146
|
+
index += values.include?(arg) ? 2 : 1
|
|
147
|
+
end
|
|
148
|
+
nil
|
|
130
149
|
end
|
|
131
150
|
end
|
|
132
151
|
end
|
|
@@ -4,6 +4,7 @@ require_relative "../session"
|
|
|
4
4
|
require_relative "../session_access"
|
|
5
5
|
require_relative "../reply_wait"
|
|
6
6
|
require_relative "../bridge_client"
|
|
7
|
+
require_relative "../owner_lock"
|
|
7
8
|
require_relative "../parent_report"
|
|
8
9
|
require_relative "../note_delivery"
|
|
9
10
|
require_relative "../image_store"
|
|
@@ -12,6 +13,10 @@ require_relative "../send_command/busy"
|
|
|
12
13
|
require_relative "../chi_invocation"
|
|
13
14
|
require_relative "../worker_sidecar"
|
|
14
15
|
require_relative "../launch_mode"
|
|
16
|
+
require_relative "../participants"
|
|
17
|
+
require_relative "../config"
|
|
18
|
+
require_relative "../web/grants"
|
|
19
|
+
require_relative "../web/info_probe"
|
|
15
20
|
|
|
16
21
|
module Samagotchi
|
|
17
22
|
# Loaded on first use: SessionManager and its ~80 files only when a
|
|
@@ -69,7 +74,10 @@ module Samagotchi
|
|
|
69
74
|
|
|
70
75
|
# The session's worker for an attach: the live one as is, else
|
|
71
76
|
# resumed and waited for up to +wait+ seconds (chi --attach on this
|
|
72
|
-
# machine; chi web's POST …/wake for a remote one).
|
|
77
|
+
# machine; chi web's POST …/wake for a remote one). A worker still
|
|
78
|
+
# leaving (its Bridge closed, its recap on leave being written) is
|
|
79
|
+
# waited for first (OwnerLock.await_release: its recap's timeout and
|
|
80
|
+
# a margin), so the resume finds the session free.
|
|
73
81
|
# @return [Woken] its sidecar nil when the Bridge didn't come in time
|
|
74
82
|
# @raise [ArgumentError] no such session
|
|
75
83
|
# @raise [Refused] code scratch: a leftover chi scratch session
|
|
@@ -81,6 +89,7 @@ module Samagotchi
|
|
|
81
89
|
live = WorkerSidecar.live(session_dir(id), unlink: true)
|
|
82
90
|
return Woken.new(woke: false, sidecar: live) if live
|
|
83
91
|
|
|
92
|
+
OwnerLock.await_release(session_dir(id))
|
|
84
93
|
SessionManager.resume_session(id, **dir)
|
|
85
94
|
found = BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: wait)
|
|
86
95
|
Woken.new(woke: true, sidecar: found && WorkerSidecar.read(session_dir(id)))
|
|
@@ -322,8 +331,50 @@ module Samagotchi
|
|
|
322
331
|
end
|
|
323
332
|
end
|
|
324
333
|
|
|
334
|
+
# --- participants (chi share)
|
|
335
|
+
|
|
336
|
+
# Remote's: through this machine's grants store, as chi web would.
|
|
337
|
+
# @return [Array<ParticipantView>]
|
|
338
|
+
def participants(id = nil)
|
|
339
|
+
grants.list(session_id: id).map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# The link names this machine's chi web: its LAN address when one
|
|
343
|
+
# runs on the LAN (web.port), else 127.0.0.1 (+lan+ false).
|
|
344
|
+
# @return [Invitation]
|
|
345
|
+
# @raise [ArgumentError] no such session
|
|
346
|
+
# @raise [BadRequest] a bad name, kind or ttl, an archived session
|
|
347
|
+
def invite(id, name:, kind:, ttl: nil)
|
|
348
|
+
issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, state_dir: state_dir!)
|
|
349
|
+
ip, port = local_web
|
|
350
|
+
Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
|
|
351
|
+
link: "http://#{ip || "127.0.0.1"}:#{port}/?token=#{issued.token}#s=#{id}", lan: !ip.nil?)
|
|
352
|
+
rescue Web::Grants::Invalid => e
|
|
353
|
+
raise BadRequest.new(e.message, code: "invalid_participant")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# @return [Array<ParticipantView>] the ones it revoked
|
|
357
|
+
def revoke_participants(id = nil, participant: nil)
|
|
358
|
+
Participants.revoke(grants, session_id: id, id: participant, state_dir: state_dir!)
|
|
359
|
+
.map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
|
|
360
|
+
end
|
|
361
|
+
|
|
325
362
|
private
|
|
326
363
|
|
|
364
|
+
def grants = @grants ||= Web::Grants.new(Web::Grants.path(state_dir: state_dir!))
|
|
365
|
+
|
|
366
|
+
# Web::Server::DEFAULT_PORT, without loading chi web.
|
|
367
|
+
WEB_PORT = 4567
|
|
368
|
+
LOCAL_PROBE_TIMEOUT = 1.0
|
|
369
|
+
|
|
370
|
+
# [LAN ip or nil, port] of this machine's chi web (GET /api/info).
|
|
371
|
+
def local_web
|
|
372
|
+
port = Config.get("web.port").to_s.to_i
|
|
373
|
+
port = WEB_PORT unless port.positive?
|
|
374
|
+
info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
|
|
375
|
+
[info.is_a?(Hash) ? info["lan"] : nil, port]
|
|
376
|
+
end
|
|
377
|
+
|
|
327
378
|
def dir = @state_dir ? { state_dir: @state_dir } : {}
|
|
328
379
|
def state_dir! = @state_dir || Session.default_state_dir
|
|
329
380
|
def session_dir(id) = Session.session_dir(id, state_dir: state_dir!)
|
|
@@ -74,8 +74,8 @@ module Samagotchi
|
|
|
74
74
|
# add checks the same).
|
|
75
75
|
# @return [RemoteFailed, nil]
|
|
76
76
|
def self.info_problem(entry, reply)
|
|
77
|
-
|
|
78
|
-
return
|
|
77
|
+
refused = access_error(entry, reply)
|
|
78
|
+
return refused if refused
|
|
79
79
|
unless reply.status == 200 && reply.json.is_a?(Hash) && reply.json["app"] == "chi-web"
|
|
80
80
|
return RemoteFailed.new("#{entry.label}: not chi web (GET /api/info answered #{reply.status})")
|
|
81
81
|
end
|
|
@@ -91,20 +91,31 @@ module Samagotchi
|
|
|
91
91
|
"update chi there (chi update)"
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
# The
|
|
95
|
-
#
|
|
96
|
-
|
|
94
|
+
# The errors any route of +entry+'s chi web can answer, each its one
|
|
95
|
+
# message: the token refused (401 unauthorized) or ended (401
|
|
96
|
+
# token_revoked / token_expired), the LAN IP only (403 forbidden), a
|
|
97
|
+
# participant's token on an owner's route (403 not_permitted) or over
|
|
98
|
+
# its limits (429 too_many_turns / cap_reached). nil for any other
|
|
99
|
+
# answer (chi remote add says the same).
|
|
100
|
+
# @return [RemoteFailed, nil]
|
|
101
|
+
def self.access_error(entry, reply)
|
|
102
|
+
error = reply.error
|
|
103
|
+
known = ACCESS_ERRORS.find { |klass| error == klass::CODE }
|
|
104
|
+
return known.new(entry.label) if known
|
|
105
|
+
|
|
97
106
|
case reply.status
|
|
98
|
-
when 401 then "#{entry.label}: the token was refused (chi web's LAN link carries the current one: " \
|
|
99
|
-
|
|
107
|
+
when 401 then AuthRefused.new("#{entry.label}: the token was refused (chi web's LAN link carries the current one: " \
|
|
108
|
+
"chi remote add #{entry.name} #{entry.url} --token-file FILE)")
|
|
100
109
|
when 403
|
|
101
|
-
return nil unless
|
|
110
|
+
return nil unless error == "forbidden"
|
|
102
111
|
|
|
103
112
|
ip = reply.json["detail"].to_s[/ and (\S+) are answered/, 1]
|
|
104
|
-
"#{entry.label}: chi web answers its LAN IP only: use the LAN IP#{" #{ip}" if ip}, not a hostname"
|
|
113
|
+
AuthRefused.new("#{entry.label}: chi web answers its LAN IP only: use the LAN IP#{" #{ip}" if ip}, not a hostname")
|
|
105
114
|
end
|
|
106
115
|
end
|
|
107
116
|
|
|
117
|
+
ACCESS_ERRORS = [TokenRevoked, TokenExpired, NotPermitted, TooManyTurns, CapReached].freeze
|
|
118
|
+
|
|
108
119
|
# --- ids and sessions
|
|
109
120
|
|
|
110
121
|
# @raise [Session::AmbiguousId]
|
|
@@ -220,6 +231,40 @@ module Samagotchi
|
|
|
220
231
|
|
|
221
232
|
# @return [NoteDelivery::Result]
|
|
222
233
|
# @raise [SessionInbox::NoteRejected]
|
|
234
|
+
# --- participants (chi share)
|
|
235
|
+
|
|
236
|
+
# @param id [String, nil] one session's, or every session's
|
|
237
|
+
# @return [Array<ParticipantView>]
|
|
238
|
+
def participants(id = nil)
|
|
239
|
+
reply = request(:get, id ? "#{session_path(id)}/participants" : "/api/participants")
|
|
240
|
+
raise_refusal(reply) unless reply.status == 200
|
|
241
|
+
|
|
242
|
+
Array(reply.json["participants"]).map { |row| ParticipantView.from_h(row) }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @param ttl [Integer, nil] seconds; nil: the grant's default
|
|
246
|
+
# @return [Invitation]
|
|
247
|
+
# @raise [BadRequest] a bad name, kind or ttl, an archived session
|
|
248
|
+
def invite(id, name:, kind:, ttl: nil)
|
|
249
|
+
reply = request(:post, "#{session_path(id)}/participants", body: { name: name, kind: kind, ttl: ttl }.compact)
|
|
250
|
+
raise_refusal(reply) unless reply.status == 201
|
|
251
|
+
|
|
252
|
+
json = reply.json
|
|
253
|
+
Invitation.new(participant: ParticipantView.from_h(json.fetch("participant")), token: json.fetch("token"),
|
|
254
|
+
link: json.fetch("link"), lan: json["lan"] == true)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Revokes one participant (+participant+), a session's, or with no
|
|
258
|
+
# +id+ every session's.
|
|
259
|
+
# @return [Array<ParticipantView>] the ones it revoked
|
|
260
|
+
def revoke_participants(id = nil, participant: nil)
|
|
261
|
+
reply = request(:delete, id ? "#{session_path(id)}/participants" : "/api/participants",
|
|
262
|
+
query: { id: participant }.compact)
|
|
263
|
+
raise_refusal(reply) unless reply.status == 200
|
|
264
|
+
|
|
265
|
+
Array(reply.json["revoked"]).map { |row| ParticipantView.from_h(row) }
|
|
266
|
+
end
|
|
267
|
+
|
|
223
268
|
def note(id, text:, source:)
|
|
224
269
|
reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
|
|
225
270
|
json = reply.json.is_a?(Hash) ? reply.json : {}
|
|
@@ -317,13 +362,13 @@ module Samagotchi
|
|
|
317
362
|
|
|
318
363
|
# --- sessions
|
|
319
364
|
|
|
320
|
-
# chi web stops it and waits
|
|
321
|
-
#
|
|
365
|
+
# chi web stops it and waits up to +wait+ seconds (capped there at
|
|
366
|
+
# SessionManager::STOP_WAIT; a chi web before the field waits its
|
|
367
|
+
# Web::App::STOP_WAIT_SECONDS).
|
|
322
368
|
# @return [Boolean] whether the worker had released it by then, as
|
|
323
369
|
# chi web's released says (absent: true)
|
|
324
370
|
def stop(id, wait:)
|
|
325
|
-
|
|
326
|
-
reply = request(:post, "#{session_path(id)}/stop")
|
|
371
|
+
reply = request(:post, "#{session_path(id)}/stop", body: { wait: wait })
|
|
327
372
|
raise_refusal(reply) unless reply.status == 200
|
|
328
373
|
|
|
329
374
|
reply.json["released"] != false
|
|
@@ -454,6 +499,50 @@ module Samagotchi
|
|
|
454
499
|
WebBridgeClient.new(remote: self, session_id: id, worker_key: worker["epoch"] || worker["started_at"])
|
|
455
500
|
end
|
|
456
501
|
|
|
502
|
+
# This chi web has +feature+ (GET /api/info's), else RemoteTooOld in
|
|
503
|
+
# Remote.too_old_message's words.
|
|
504
|
+
# @raise [RemoteFailed]
|
|
505
|
+
def require_feature!(feature)
|
|
506
|
+
check_server!
|
|
507
|
+
raise RemoteTooOld, too_old(@version) unless Array(@features).include?(feature)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# POST …/wake: the session's live worker for a remote attach, started
|
|
511
|
+
# when none runs, waited for up to +wait+ seconds there (a leaving
|
|
512
|
+
# worker first: +read_timeout+ covers that wait too).
|
|
513
|
+
# @return [WakeReply]
|
|
514
|
+
# @raise [SessionManager::OwnedByTUI] a chi REPL has it there
|
|
515
|
+
# @raise [Refused] a leftover scratch session (code scratch), or its
|
|
516
|
+
# Bridge didn't come in time (code worker_timeout): chi web's words,
|
|
517
|
+
# LaunchMode's constants
|
|
518
|
+
# @raise [ArgumentError] no such session
|
|
519
|
+
def attach_wake(id, wait:, read_timeout: nil)
|
|
520
|
+
reply = request(:post, "#{session_path(id)}/wake", body: { wait: wait }, read_timeout: read_timeout)
|
|
521
|
+
if reply.status != 200 && %w[scratch worker_timeout].include?(reply.error)
|
|
522
|
+
raise Refused.new((reply.json["message"] || reply.json["detail"]).to_s, code: reply.error)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
raise_refusal(reply) unless reply.status == 200
|
|
526
|
+
|
|
527
|
+
json = reply.json
|
|
528
|
+
WakeReply.new(woke: json["woke"] == true, worker: json["worker"], default_model: json["default_model"],
|
|
529
|
+
strategy: strategy_or_hard(json["parent_strategy"]), installed: json["installed"])
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# GET …/worker: the session's worker now (never started).
|
|
533
|
+
# @return [WorkerReply]
|
|
534
|
+
# @raise [ArgumentError] no such session
|
|
535
|
+
def attach_worker(id)
|
|
536
|
+
reply = request(:get, "#{session_path(id)}/worker")
|
|
537
|
+
raise_refusal(reply) unless reply.status == 200
|
|
538
|
+
|
|
539
|
+
json = reply.json
|
|
540
|
+
seconds = json["leave_seconds"]
|
|
541
|
+
WorkerReply.new(live: json["live"] == true, stopped: json["stopped"] == true, worker: json["worker"],
|
|
542
|
+
strategy: strategy_or_hard(json["parent_strategy"]), default_model: json["default_model"],
|
|
543
|
+
leaving: json["leaving"] == true, leave_seconds: (seconds.to_f if seconds.is_a?(Numeric)))
|
|
544
|
+
end
|
|
545
|
+
|
|
457
546
|
# One request to a route, as the session commands make them (the
|
|
458
547
|
# server check first; a refused token or a missing route raise
|
|
459
548
|
# RemoteFailed): WebBridgeClient's, which maps the reply itself.
|
|
@@ -476,7 +565,8 @@ module Samagotchi
|
|
|
476
565
|
def request(method, path, **)
|
|
477
566
|
check_server!
|
|
478
567
|
reply = @transport.call(method, path, **)
|
|
479
|
-
|
|
568
|
+
refused = Remote.access_error(@entry, reply)
|
|
569
|
+
raise refused if refused
|
|
480
570
|
raise RemoteTooOld, too_old(@version) if missing_route?(reply)
|
|
481
571
|
|
|
482
572
|
reply
|
|
@@ -486,7 +576,10 @@ module Samagotchi
|
|
|
486
576
|
return if @checked
|
|
487
577
|
|
|
488
578
|
reply = @transport.call(:get, "/api/info")
|
|
489
|
-
|
|
579
|
+
if reply.json.is_a?(Hash)
|
|
580
|
+
@version = reply.json["version"]
|
|
581
|
+
@features = Array(reply.json["features"])
|
|
582
|
+
end
|
|
490
583
|
problem = Remote.info_problem(@entry, reply)
|
|
491
584
|
raise problem if problem
|
|
492
585
|
|
|
@@ -495,6 +588,8 @@ module Samagotchi
|
|
|
495
588
|
|
|
496
589
|
def too_old(version) = Remote.too_old_message(@entry, version)
|
|
497
590
|
|
|
591
|
+
def strategy_or_hard(json) = strategy_of(json) || Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
|
|
592
|
+
|
|
498
593
|
# chi web's 404 for a path no route takes (#serve_static's), not a
|
|
499
594
|
# route's own "no such session".
|
|
500
595
|
def missing_route?(reply)
|
|
@@ -527,6 +622,7 @@ module Samagotchi
|
|
|
527
622
|
# spawn_session raises here (chi send: could not start a session).
|
|
528
623
|
when "invalid_model" then raise ModelProfile::MissingModel, message.to_s
|
|
529
624
|
when /\Ainvalid_/, "missing_fields", "bad_images"
|
|
625
|
+
# invalid_participant: chi share's name, kind or ttl, an archived session
|
|
530
626
|
raise BadRequest.new(message.to_s, code: error)
|
|
531
627
|
end
|
|
532
628
|
raise RemoteFailed, "#{@entry.label} answered #{reply.status}#{" #{error}" unless error.empty?}" \
|
|
@@ -535,7 +631,7 @@ module Samagotchi
|
|
|
535
631
|
|
|
536
632
|
def create_body(spec)
|
|
537
633
|
body = { model: spec.model_name, client_id: spec.client_id, history: false }
|
|
538
|
-
if spec.preview
|
|
634
|
+
if spec.preview || spec.idle
|
|
539
635
|
body.merge!(idle: true, preview: spec.preview)
|
|
540
636
|
else
|
|
541
637
|
body[:prompt] = spec.prompt
|
|
@@ -36,6 +36,9 @@ module Samagotchi
|
|
|
36
36
|
# No byte for this long on the stream (the Bridge pings every 15 s): a
|
|
37
37
|
# half-open connection (Wi-Fi gone, the laptop asleep), reconnected.
|
|
38
38
|
STREAM_IDLE_TIMEOUT = 40.0
|
|
39
|
+
# A read a join waits on (a turn's saved records, the children
|
|
40
|
+
# counts): a slow chi web can't hold the join up for long (L9).
|
|
41
|
+
JOIN_READ_TIMEOUT = 5
|
|
39
42
|
# chi web answers no live worker for the session (Mapping).
|
|
40
43
|
NO_WORKER = "no live worker for session %s on %s"
|
|
41
44
|
|
|
@@ -44,7 +47,7 @@ module Samagotchi
|
|
|
44
47
|
module_function
|
|
45
48
|
|
|
46
49
|
# @param route [Symbol] :turn, :command, :answer, :dismiss,
|
|
47
|
-
# :dequeue, :cancel
|
|
50
|
+
# :dequeue, :cancel, :exit, :recap, :stats, :children, :records
|
|
48
51
|
# @param reply [HTTPTransport::Reply]
|
|
49
52
|
# @param session_id [String]
|
|
50
53
|
# @param remote [String] the remote's name
|
|
@@ -89,9 +92,11 @@ module Samagotchi
|
|
|
89
92
|
def port = nil
|
|
90
93
|
|
|
91
94
|
# POST …/turn. history: false (chi web's prompt history is that
|
|
92
|
-
# machine's page's).
|
|
95
|
+
# machine's page's). +author+ is never sent: the remote chi web stamps
|
|
96
|
+
# a turn's author from the token this client holds (Web::Principal),
|
|
97
|
+
# never from the body.
|
|
93
98
|
# @return [BridgeClient::Response]
|
|
94
|
-
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil)
|
|
99
|
+
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil)
|
|
95
100
|
body = { prompt: prompt, client_id: client_id, history: false }
|
|
96
101
|
body[:no_interrupt] = true if no_interrupt
|
|
97
102
|
body[:images] = images if images && !images.empty?
|
|
@@ -133,6 +138,51 @@ module Samagotchi
|
|
|
133
138
|
post(:cancel, "cancel", { reason: reason })
|
|
134
139
|
end
|
|
135
140
|
|
|
141
|
+
# POST …/exit: the worker asked to exit (chi web passes client_id on
|
|
142
|
+
# only when it is an attached TUI's).
|
|
143
|
+
# @return [BridgeClient::Response] 200 {status: exiting, discard}, 409
|
|
144
|
+
# held {reason, tasks}, 408 late, 404 an older worker's
|
|
145
|
+
def request_exit(client_id:, delete: false)
|
|
146
|
+
body = { client_id: client_id }
|
|
147
|
+
body[:delete] = true if delete
|
|
148
|
+
post(:exit, "exit", body)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# POST …/recap: the saved recap, and a new one asked for.
|
|
152
|
+
# @return [BridgeClient::Response] 200 {enabled, saved, request, min_user_turns, session_off}
|
|
153
|
+
def request_recap = post(:recap, "recap", {})
|
|
154
|
+
|
|
155
|
+
# GET …/stats: the worker's metrics (its state's, for an older
|
|
156
|
+
# worker), nil when it didn't give them.
|
|
157
|
+
# @return [Hash, nil]
|
|
158
|
+
# @raise [Unreachable]
|
|
159
|
+
def stats
|
|
160
|
+
response = get(:stats, "stats", read_timeout: READ_TIMEOUT)
|
|
161
|
+
response.status == 200 ? response.json&.dig("metrics") : nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# GET …/children/counts: the session's delegates' counts there.
|
|
165
|
+
# @return [Hash] {running, waiting, unreported}
|
|
166
|
+
# @raise [Unreachable] chi web couldn't answer (or the session is gone)
|
|
167
|
+
def children_counts
|
|
168
|
+
response = get(:children, "children/counts", read_timeout: JOIN_READ_TIMEOUT)
|
|
169
|
+
raise Unreachable, "children counts: chi web answered #{response.status}" unless response.status == 200
|
|
170
|
+
|
|
171
|
+
response.json
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# GET …/turns/:turn_id/records: a turn's saved tool records and turn
|
|
175
|
+
# record there.
|
|
176
|
+
# @return [Hash] {tool_records: [...], turn_record: {...} | nil}
|
|
177
|
+
# @raise [Unreachable]
|
|
178
|
+
def turn_records(turn_id)
|
|
179
|
+
response = get(:records, "turns/#{URI.encode_www_form_component(turn_id.to_s)}/records",
|
|
180
|
+
read_timeout: JOIN_READ_TIMEOUT)
|
|
181
|
+
raise Unreachable, "turn records: chi web answered #{response.status}" unless response.status == 200
|
|
182
|
+
|
|
183
|
+
response.json
|
|
184
|
+
end
|
|
185
|
+
|
|
136
186
|
# Follow the session's events through chi web (see EventStream): with
|
|
137
187
|
# epoch_watch, announce_retries and STREAM_IDLE_TIMEOUT.
|
|
138
188
|
# @return [BridgeClient::EventStream] started
|
|
@@ -172,6 +222,13 @@ module Samagotchi
|
|
|
172
222
|
raise Unreachable, e.message
|
|
173
223
|
end
|
|
174
224
|
|
|
225
|
+
def get(route, path, read_timeout:)
|
|
226
|
+
reply = @remote.route_request(:get, "/api/sessions/#{@session_id}/#{path}", read_timeout: read_timeout)
|
|
227
|
+
Mapping.response(route, reply, session_id: @session_id, remote: @remote.name)
|
|
228
|
+
rescue RemoteFailed => e
|
|
229
|
+
raise Unreachable, e.message
|
|
230
|
+
end
|
|
231
|
+
|
|
175
232
|
def refused(reason)
|
|
176
233
|
Log.warn(:attached, "stream_refused", reason: reason, remote: @remote.name)
|
|
177
234
|
nil
|
|
@@ -28,6 +28,47 @@ module Samagotchi
|
|
|
28
28
|
# --remote: update chi there (chi update)").
|
|
29
29
|
class RemoteTooOld < RemoteFailed; end
|
|
30
30
|
|
|
31
|
+
# A remote's chi web refused this machine's token (401), or answers its
|
|
32
|
+
# LAN IP only (403 forbidden): Remote.access_error's words.
|
|
33
|
+
class AuthRefused < RemoteFailed; end
|
|
34
|
+
|
|
35
|
+
# The errors a participant's token meets (docs/internals/participants.md),
|
|
36
|
+
# one message each, built from the remote's label ("remote studio
|
|
37
|
+
# (http://…)"). chi web's code for each is its CODE.
|
|
38
|
+
|
|
39
|
+
# 401 token_revoked: the owner revoked the grant, or archived or
|
|
40
|
+
# deleted its session.
|
|
41
|
+
class TokenRevoked < AuthRefused
|
|
42
|
+
CODE = "token_revoked"
|
|
43
|
+
def initialize(label) = super("#{label}: this review link was revoked by the session's owner")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 401 token_expired: the grant's time ran out.
|
|
47
|
+
class TokenExpired < AuthRefused
|
|
48
|
+
CODE = "token_expired"
|
|
49
|
+
def initialize(label) = super("#{label}: this review link expired: ask the session's owner for a new one")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 403 not_permitted: a participant's token on a route only the session's
|
|
53
|
+
# owner may use.
|
|
54
|
+
class NotPermitted < RemoteFailed
|
|
55
|
+
CODE = "not_permitted"
|
|
56
|
+
def initialize(label) = super("#{label}: this token is a participant's: only the session's owner can do that")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 429 too_many_turns (P3b): the participant's turns already wait in the
|
|
60
|
+
# queue up to their limit.
|
|
61
|
+
class TooManyTurns < RemoteFailed
|
|
62
|
+
CODE = "too_many_turns"
|
|
63
|
+
def initialize(label) = super("#{label}: this participant has too many turns waiting: wait for one to finish")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 429 cap_reached (P3b): the participant's grant spent its cap.
|
|
67
|
+
class CapReached < RemoteFailed
|
|
68
|
+
CODE = "cap_reached"
|
|
69
|
+
def initialize(label) = super("#{label}: this participant's spending cap is reached: ask the session's owner to raise it")
|
|
70
|
+
end
|
|
71
|
+
|
|
31
72
|
# A remote's chi web refused a request for a reason this machine checks
|
|
32
73
|
# itself before sending (a parent's /continue yes, a loosening
|
|
33
74
|
# /parent-answers, images to a model that can't see them): +message+
|
|
@@ -47,11 +88,30 @@ module Samagotchi
|
|
|
47
88
|
# exits 2 with its message.
|
|
48
89
|
class BadRequest < Refused; end
|
|
49
90
|
|
|
91
|
+
# One participant's grant as chi share ls shows it
|
|
92
|
+
# (Web::Grants::Grant#public_h): never its token or digest. +state+
|
|
93
|
+
# "active", "revoked" or "expired".
|
|
94
|
+
ParticipantView = Data.define(:id, :session_id, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
|
|
95
|
+
:revoked_by, :last_used_at, :state) do
|
|
96
|
+
def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key] || hash[key.to_s]] })
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# What chi share made: the participant, their token and the link that
|
|
100
|
+
# carries it (both shown this once: chi keeps only a digest), and
|
|
101
|
+
# whether that chi web listens on the LAN (+lan+ false: the link works
|
|
102
|
+
# on that machine only).
|
|
103
|
+
Invitation = Data.define(:participant, :token, :link, :lan) do
|
|
104
|
+
def inspect = "#<Invitation #{participant.id}>"
|
|
105
|
+
alias_method :to_s, :inspect
|
|
106
|
+
end
|
|
107
|
+
|
|
50
108
|
# A session to create (chi send --new): a new one, or with +continues+
|
|
51
109
|
# the next link of that session's chain (which brings its own folder,
|
|
52
110
|
# LLM context, thinking, toolkit and settings: the rest is nil then).
|
|
53
111
|
# +preview+ set: the session starts idle with it as its preview, and
|
|
54
|
-
# +prompt+ (nil) goes in later as a turn (images, a session command)
|
|
112
|
+
# +prompt+ (nil) goes in later as a turn (images, a session command);
|
|
113
|
+
# +idle+ alone: it starts idle with no preview (a remote attach's new
|
|
114
|
+
# session, whose -p goes in once attached).
|
|
55
115
|
# +setup+ is a SessionSetup for Local, a SetupWords for Remote (the
|
|
56
116
|
# flags as typed: the server checks them against its own config and
|
|
57
117
|
# bundles). +client_id+ (chi send's, or chi answer's for a parent's
|
|
@@ -59,9 +119,9 @@ module Samagotchi
|
|
|
59
119
|
# which checks the first line and the model as chi send checks them
|
|
60
120
|
# here (Local checks before it creates).
|
|
61
121
|
NewSession = Data.define(:prompt, :preview, :continues, :working_directory, :model_name, :setup, :client_id,
|
|
62
|
-
:images) do
|
|
122
|
+
:images, :idle) do
|
|
63
123
|
def initialize(prompt:, preview: nil, continues: nil, working_directory: nil, model_name: nil, setup: nil,
|
|
64
|
-
client_id: nil, images: false) = super
|
|
124
|
+
client_id: nil, images: false, idle: false) = super
|
|
65
125
|
end
|
|
66
126
|
|
|
67
127
|
# chi send --new's setup flags as typed, for a Remote: +llm_context+
|
|
@@ -94,6 +154,10 @@ module Samagotchi
|
|
|
94
154
|
# its OwnerLock::Owner (a worker, a chi REPL, nil).
|
|
95
155
|
WorkerState = Data.define(:sidecar, :stopped, :owner) do
|
|
96
156
|
def live? = !sidecar.nil?
|
|
157
|
+
|
|
158
|
+
# No Bridge, and the owner is a worker leaving (writing its recap on
|
|
159
|
+
# leave): a wake waits for it (Local#wake).
|
|
160
|
+
def leaving? = !live? && !!owner&.leaving?
|
|
97
161
|
end
|
|
98
162
|
|
|
99
163
|
# What a wake for an attach did (Local#wake, chi web's POST …/wake):
|
|
@@ -101,6 +165,24 @@ module Samagotchi
|
|
|
101
165
|
# worker's (nil: its Bridge didn't come within the wait).
|
|
102
166
|
Woken = Data.define(:woke, :sidecar)
|
|
103
167
|
|
|
168
|
+
# A remote attach's wake (Remote#attach_wake, chi web's POST …/wake):
|
|
169
|
+
# +worker+ the live worker as chi web names it ({"epoch", "started_at",
|
|
170
|
+
# "version", "features"}, string keys: Remote#attach_client's), and
|
|
171
|
+
# what the TUI reads of that machine: its config's +default_model+,
|
|
172
|
+
# the session's parent +strategy+ (Guardrails::ParentPolicy::Strategy),
|
|
173
|
+
# the newest chi +installed+ there.
|
|
174
|
+
WakeReply = Data.define(:woke, :worker, :default_model, :strategy, :installed)
|
|
175
|
+
|
|
176
|
+
# A remote session's worker now (Remote#attach_worker, chi web's GET
|
|
177
|
+
# …/worker): +live+, +stopped+ (its stop marker), +worker+ as in
|
|
178
|
+
# WakeReply (nil: none runs), the parent +strategy+ read now, and that
|
|
179
|
+
# machine's +default_model+ (nil from a chi web before it sent one).
|
|
180
|
+
# +leaving+: its worker is leaving (WorkerState#leaving?), for up to
|
|
181
|
+
# +leave_seconds+ more there (nil from a chi web before it sent them).
|
|
182
|
+
WorkerReply = Data.define(:live, :stopped, :worker, :strategy, :default_model, :leaving, :leave_seconds) do
|
|
183
|
+
def initialize(live:, stopped:, worker:, strategy:, default_model: nil, leaving: false, leave_seconds: nil) = super
|
|
184
|
+
end
|
|
185
|
+
|
|
104
186
|
# What chi send --wait and chi answer read before anything is sent, so
|
|
105
187
|
# neither an older reply nor a turn that ends before the first look is
|
|
106
188
|
# taken for the answer (ReplyWait's cursor:, baseline:, turn:).
|