samagotchi 0.102.0 → 0.103.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 +40 -1
- data/bin/chi +23 -191
- data/docs/architecture.md +54 -0
- data/docs/cli.md +114 -7
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +2 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +26 -2
- data/docs/sessions.md +12 -5
- data/docs/sub-agent.md +8 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +24 -4
- data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +22 -38
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +43 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/engine.rb +12 -8
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +233 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +173 -108
- data/lib/samagotchi/session.rb +30 -5
- data/lib/samagotchi/session_access/contract.rb +129 -0
- data/lib/samagotchi/session_access/dispatch.rb +133 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +349 -0
- data/lib/samagotchi/session_access/remote.rb +638 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
- data/lib/samagotchi/session_access.rb +141 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
- data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
- data/lib/samagotchi/terminal_ui.rb +2 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +360 -69
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -4
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/public/working_set.js +159 -28
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +91 -33
- data/lib/samagotchi/web/session_access_routes.rb +343 -0
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +88 -14
- data/lib/samagotchi/working_set_command.rb +133 -30
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +191 -27
- metadata +19 -1
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../session_access"
|
|
5
|
+
require_relative "../remotes"
|
|
6
|
+
require_relative "../reply_wait"
|
|
7
|
+
require_relative "../pending_question"
|
|
8
|
+
require_relative "../note_delivery"
|
|
9
|
+
require_relative "../owner_lock"
|
|
10
|
+
require_relative "../context_sources"
|
|
11
|
+
require_relative "../bridge_client"
|
|
12
|
+
require_relative "../session_continue"
|
|
13
|
+
require_relative "../guardrails/parent_policy"
|
|
14
|
+
require_relative "../model_profile"
|
|
15
|
+
require_relative "http_transport"
|
|
16
|
+
require_relative "../chi_invocation"
|
|
17
|
+
|
|
18
|
+
module Samagotchi
|
|
19
|
+
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
20
|
+
|
|
21
|
+
module SessionAccess
|
|
22
|
+
# Another machine's sessions, through its chi web (the remote-sessions
|
|
23
|
+
# plan): SessionAccess::Local's methods, each a request to the route of
|
|
24
|
+
# the same name (Web::SessionAccessRoutes and the page's own), with the
|
|
25
|
+
# remote's token as Authorization: Bearer. Results are Local's value
|
|
26
|
+
# objects (SessionView and Created where Local hands out a Session), and
|
|
27
|
+
# a refusal raises the exception Local's callee raises, built from the
|
|
28
|
+
# fields chi web sends (its message is the same). A request that can't
|
|
29
|
+
# be made, or a chi web without these routes, raises RemoteFailed naming
|
|
30
|
+
# the remote. Waits and prefixes run on the server (plan D2); a wait
|
|
31
|
+
# here asks again until it ends or the caller's own timeout passes.
|
|
32
|
+
class Remote
|
|
33
|
+
# The longest a wait route waits (Web::SessionAccessRoutes::
|
|
34
|
+
# WAIT_MAX_SECONDS): each request asks for at most this.
|
|
35
|
+
WAIT_SLICE = 30.0
|
|
36
|
+
# chi web stopping mid-wait (503 server_stopping): asked again after
|
|
37
|
+
# this, until the caller's own timeout.
|
|
38
|
+
RETRY_AFTER = 1.0
|
|
39
|
+
|
|
40
|
+
attr_reader :entry
|
|
41
|
+
|
|
42
|
+
# @param entry [Remotes::Entry]
|
|
43
|
+
# @param transport [#call] HTTPTransport by default; specs pass one
|
|
44
|
+
# that calls the Rack app in this process
|
|
45
|
+
# @param clock [#call] the monotonic seconds a wait's deadline is
|
|
46
|
+
# counted on (specs pass their own)
|
|
47
|
+
def initialize(entry, transport: nil, sleeper: ->(seconds) { sleep(seconds) },
|
|
48
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
49
|
+
@entry = entry
|
|
50
|
+
@transport = transport || HTTPTransport.new(entry)
|
|
51
|
+
@sleeper = sleeper
|
|
52
|
+
@clock = clock
|
|
53
|
+
@strategies = {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inspect = "#<#{self.class.name} #{@entry.label}>"
|
|
57
|
+
|
|
58
|
+
def remote? = true
|
|
59
|
+
def name = @entry.name
|
|
60
|
+
|
|
61
|
+
# The printed commands' spelling: chi --remote NAME.
|
|
62
|
+
# @return [ChiInvocation]
|
|
63
|
+
def invocation = ChiInvocation.new(remote: name)
|
|
64
|
+
|
|
65
|
+
# Web::SessionAccessRoutes::FEATURE (GET /api/info's), without
|
|
66
|
+
# loading chi web here.
|
|
67
|
+
FEATURE = "session_commands"
|
|
68
|
+
# Web::SessionAccessRoutes::ATTACH_FEATURE: the routes a remote
|
|
69
|
+
# attached TUI needs (wake, worker).
|
|
70
|
+
ATTACH_FEATURE = "remote_attach"
|
|
71
|
+
|
|
72
|
+
# What GET /api/info's +reply+ says is wrong with +entry+'s chi web for
|
|
73
|
+
# chi --remote, as the error to raise; nil when it will do (chi remote
|
|
74
|
+
# add checks the same).
|
|
75
|
+
# @return [RemoteFailed, nil]
|
|
76
|
+
def self.info_problem(entry, reply)
|
|
77
|
+
failure = auth_failure(entry, reply)
|
|
78
|
+
return RemoteFailed.new(failure) if failure
|
|
79
|
+
unless reply.status == 200 && reply.json.is_a?(Hash) && reply.json["app"] == "chi-web"
|
|
80
|
+
return RemoteFailed.new("#{entry.label}: not chi web (GET /api/info answered #{reply.status})")
|
|
81
|
+
end
|
|
82
|
+
return nil if Array(reply.json["features"]).include?(FEATURE)
|
|
83
|
+
|
|
84
|
+
RemoteTooOld.new(too_old_message(entry, reply.json["version"]))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# By feature, not by a release number (that is the release's to
|
|
88
|
+
# name): a chi without the routes, or without the one a command asks.
|
|
89
|
+
def self.too_old_message(entry, version)
|
|
90
|
+
"remote #{entry.name} runs chi #{version || "(unknown)"}, too old for this command through --remote: " \
|
|
91
|
+
"update chi there (chi update)"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The words for a 401 or a 403 forbidden from +entry+'s chi web, nil
|
|
95
|
+
# for any other answer (chi remote add says the same).
|
|
96
|
+
def self.auth_failure(entry, reply)
|
|
97
|
+
case reply.status
|
|
98
|
+
when 401 then "#{entry.label}: the token was refused (chi web's LAN link carries the current one: " \
|
|
99
|
+
"chi remote add #{entry.name} #{entry.url} --token-file FILE)"
|
|
100
|
+
when 403
|
|
101
|
+
return nil unless reply.error == "forbidden"
|
|
102
|
+
|
|
103
|
+
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"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# --- ids and sessions
|
|
109
|
+
|
|
110
|
+
# @raise [Session::AmbiguousId]
|
|
111
|
+
def resolve_id(given)
|
|
112
|
+
reply = request(:get, "/api/sessions/resolve", query: { id: given.to_s })
|
|
113
|
+
case reply.status
|
|
114
|
+
when 200 then reply.json.fetch("id")
|
|
115
|
+
when 404 then given.to_s
|
|
116
|
+
else raise_refusal(reply)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @raise [ArgumentError] no such session (Session::AmbiguousId: a
|
|
121
|
+
# prefix of several)
|
|
122
|
+
def resolve(given)
|
|
123
|
+
id = resolve_id(given)
|
|
124
|
+
raise ArgumentError, "Session not found: #{given}" if id == given.to_s && !exists?(id)
|
|
125
|
+
|
|
126
|
+
id
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @return [SessionView]
|
|
130
|
+
# @raise [ArgumentError] no such session
|
|
131
|
+
def show(id)
|
|
132
|
+
reply = request(:get, session_path(id), query: { brief: 1 })
|
|
133
|
+
raise_refusal(reply) unless reply.status == 200
|
|
134
|
+
|
|
135
|
+
view_of(reply.json["session"] || {}, reply.json["pending_question"])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [OwnerLock::Owner, nil]
|
|
139
|
+
def owner(id) = show(id).owner
|
|
140
|
+
|
|
141
|
+
# The rows chi sessions list prints, as SessionManager.session_summaries
|
|
142
|
+
# gives them (symbol keys).
|
|
143
|
+
def summaries(live: false, cwd: nil, limit: nil, include_tests: true, project_root: nil, include_archived: false,
|
|
144
|
+
sort: nil, order: nil)
|
|
145
|
+
query = { live: (1 if live), cwd: cwd, limit: limit, tests: (0 unless include_tests), dir: project_root,
|
|
146
|
+
archived: (1 if include_archived), sort: sort, order: order }
|
|
147
|
+
reply = request(:get, "/api/sessions/summaries", query: query)
|
|
148
|
+
raise_refusal(reply) unless reply.status == 200
|
|
149
|
+
|
|
150
|
+
Array(reply.json).map { |row| symbolize(row) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# --- create and send
|
|
154
|
+
|
|
155
|
+
# @param spec [NewSession] its setup a SetupWords
|
|
156
|
+
# @return [Created]
|
|
157
|
+
def create(spec)
|
|
158
|
+
reply = request(:post, "/api/sessions", body: create_body(spec))
|
|
159
|
+
raise_refusal(reply) unless reply.status == 201
|
|
160
|
+
|
|
161
|
+
json = reply.json
|
|
162
|
+
Created.new(id: json.fetch("id"), model_warning: json["model_warning"], continues: json["continues"],
|
|
163
|
+
moved_children: json["moved"] || [], parent_answers_warning: json["parent_answers_warning"])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Whether a just-created session's worker came up within +timeout+
|
|
167
|
+
# seconds: its owner lock, as the brief read names it (chi web's
|
|
168
|
+
# create waited for its Bridge a bounded time already).
|
|
169
|
+
def await_worker(id, timeout:)
|
|
170
|
+
deadline = monotonic + timeout
|
|
171
|
+
loop do
|
|
172
|
+
return true if owner(id)&.worker?
|
|
173
|
+
return false if monotonic >= deadline
|
|
174
|
+
|
|
175
|
+
sleep(WORKER_POLL)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
WORKER_POLL = 0.1
|
|
180
|
+
|
|
181
|
+
# Each image (an ImageStore ref stored in +from+) uploaded into the
|
|
182
|
+
# session (POST …/images), as the refs a turn takes.
|
|
183
|
+
def upload_images(id, refs, from:)
|
|
184
|
+
refs.map do |ref|
|
|
185
|
+
file = (ref[:file] || ref["file"]).to_s
|
|
186
|
+
name = (ref[:name] || ref["name"]).to_s
|
|
187
|
+
reply = request(:post, "#{session_path(id)}/images", query: { name: name },
|
|
188
|
+
raw: File.binread(File.join(from.to_s, file)))
|
|
189
|
+
raise_refusal(reply) unless reply.status == 201
|
|
190
|
+
|
|
191
|
+
{ file: reply.json.fetch("file"), name: reply.json["name"] || name }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @return [SessionManager::DeliverResult]
|
|
196
|
+
# @raise [SessionManager::OwnedByTUI]
|
|
197
|
+
def deliver_turn(id, prompt:, client_id:, images:, delivery: nil)
|
|
198
|
+
body = { prompt: prompt, client_id: client_id, history: false }
|
|
199
|
+
body[:images] = images.map { |ref| { file: ref[:file], name: ref[:name] } } unless images.empty?
|
|
200
|
+
body[:check_vision] = true unless images.empty?
|
|
201
|
+
body[:delivery] = delivery if delivery
|
|
202
|
+
reply = request(:post, "#{session_path(id)}/turn", body: body)
|
|
203
|
+
ack = reply.json.is_a?(Hash) ? reply.json : {}
|
|
204
|
+
case reply.status
|
|
205
|
+
when 202
|
|
206
|
+
via = ack["via"] == "file" ? :file : :bridge
|
|
207
|
+
SessionManager::DeliverResult.new(status: :accepted, via: via, ack: ack.except("via"))
|
|
208
|
+
when 504 then SessionManager::DeliverResult.new(status: :timeout, ack: { "detail" => ack["detail"] })
|
|
209
|
+
when 409
|
|
210
|
+
raise SessionManager::OwnedByTUI, ack["session_id"] || id if ack["error"] == "owned_by_tui"
|
|
211
|
+
|
|
212
|
+
SessionManager::DeliverResult.new(status: :refused, code: 409, ack: ack)
|
|
213
|
+
when 404 then raise_refusal(reply)
|
|
214
|
+
else
|
|
215
|
+
return SessionManager::DeliverResult.new(status: :refused, code: reply.status, ack: ack) if (400..499).cover?(reply.status)
|
|
216
|
+
|
|
217
|
+
SessionManager::DeliverResult.new(status: :failed, ack: ack)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @return [NoteDelivery::Result]
|
|
222
|
+
# @raise [SessionInbox::NoteRejected]
|
|
223
|
+
def note(id, text:, source:)
|
|
224
|
+
reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
|
|
225
|
+
json = reply.json.is_a?(Hash) ? reply.json : {}
|
|
226
|
+
case reply.status
|
|
227
|
+
when 200 then NoteDelivery::Result.new(id: id, status: json["status"].to_sym, queued: json["queued"].to_i)
|
|
228
|
+
when 409
|
|
229
|
+
return NoteDelivery::Result.new(id: id, status: :refused, queued: 0) if json["error"] == "owned_by_tui"
|
|
230
|
+
|
|
231
|
+
raise_refusal(reply)
|
|
232
|
+
when 400
|
|
233
|
+
raise SessionInbox::NoteRejected, json["detail"].to_s if json["error"] == "note_rejected"
|
|
234
|
+
|
|
235
|
+
raise_refusal(reply)
|
|
236
|
+
else raise_refusal(reply)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# --- waiting for the reply
|
|
241
|
+
|
|
242
|
+
# The pre-send state, read on the server (POST …/wait/prepare).
|
|
243
|
+
# +session+ is the caller's read, unused here: the server reads its
|
|
244
|
+
# own.
|
|
245
|
+
# @return [WaitStart]
|
|
246
|
+
def prepare_wait(id, session: nil, question_id: :pending, turn: nil, created: false)
|
|
247
|
+
_ = session
|
|
248
|
+
body = { turn: turn, created: created }
|
|
249
|
+
body[:question_id] = question_id unless question_id == :pending
|
|
250
|
+
reply = request(:post, "#{session_path(id)}/wait/prepare", body: body)
|
|
251
|
+
raise_refusal(reply) unless reply.status == 200
|
|
252
|
+
|
|
253
|
+
wait_start_of(reply.json)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# chi send --wait's busy check, on the server (the wait prepare
|
|
257
|
+
# route's busy: true).
|
|
258
|
+
# @return [SendCommand::Busy, nil]
|
|
259
|
+
def busy(id, queue:, session: nil)
|
|
260
|
+
_ = session
|
|
261
|
+
reply = request(:post, "#{session_path(id)}/wait/prepare", body: { busy: true, queue: queue })
|
|
262
|
+
return nil if reply.status == 200
|
|
263
|
+
|
|
264
|
+
raise_refusal(reply) unless reply.status == 409 && %w[busy waiting].include?(reply.error)
|
|
265
|
+
|
|
266
|
+
require_relative "../send_command/busy"
|
|
267
|
+
SendCommand::Busy.new(error: reply.error, session_id: reply.json["session_id"] || id,
|
|
268
|
+
question: PendingQuestion.from_file(reply.json["question"]),
|
|
269
|
+
strategy: strategy_of(reply.json["parent_strategy"]))
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# ReplyWait on the server, asked again each WAIT_SLICE until it ends
|
|
273
|
+
# or +timeout+ passes.
|
|
274
|
+
# @return [ReplyWait::Result]
|
|
275
|
+
# @raise [ArgumentError] the session is gone
|
|
276
|
+
def wait_reply(id, cursor:, timeout:, baseline:, owner_grace: nil, turn: nil, queued: false, ended_before: false,
|
|
277
|
+
poll_interval: nil)
|
|
278
|
+
_ = poll_interval
|
|
279
|
+
deadline = timeout && (monotonic + timeout)
|
|
280
|
+
body = { cursor: cursor, baseline: baseline&.to_h, turn: turn, queued: queued, ended_before: ended_before,
|
|
281
|
+
owner_grace: owner_grace }
|
|
282
|
+
loop do
|
|
283
|
+
left = deadline && (deadline - monotonic)
|
|
284
|
+
return ReplyWait::Result.new(status: :timeout) if left && left <= 0
|
|
285
|
+
|
|
286
|
+
reply = request(:post, "#{session_path(id)}/wait", body: body.merge(timeout: [left || WAIT_SLICE, WAIT_SLICE].min))
|
|
287
|
+
if reply.status == 503 && reply.error == "server_stopping"
|
|
288
|
+
@sleeper.call(RETRY_AFTER)
|
|
289
|
+
next
|
|
290
|
+
end
|
|
291
|
+
raise_refusal(reply) unless reply.status == 200
|
|
292
|
+
|
|
293
|
+
@strategies[id] = strategy_of(reply.json["parent_strategy"])
|
|
294
|
+
result = result_of(reply.json)
|
|
295
|
+
return result unless result.status == :timeout
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# What a parent may pick on the session's questions: the server's
|
|
300
|
+
# (its config.yml tightened by the session's own), as its last wait
|
|
301
|
+
# said, else the strictest.
|
|
302
|
+
def parent_strategy(id) = @strategies[id] || Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
|
|
303
|
+
|
|
304
|
+
# --- questions
|
|
305
|
+
|
|
306
|
+
# The answer, through chi web to the session's worker. chi web's
|
|
307
|
+
# answers mapped back to the Bridge's own (AnswerCommand reads those).
|
|
308
|
+
# @return [BridgeClient::Response, nil] nil when no worker runs it
|
|
309
|
+
def answer(id, question_id:, selected:, freeform:, client_id:)
|
|
310
|
+
bridge_reply(request(:post, "#{session_path(id)}/answer",
|
|
311
|
+
body: { id: question_id, selected: selected, freeform: freeform, client_id: client_id }))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def dismiss(id, question_id:)
|
|
315
|
+
bridge_reply(request(:post, "#{session_path(id)}/question/dismiss", body: { id: question_id }))
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# --- sessions
|
|
319
|
+
|
|
320
|
+
# chi web stops it and waits its own bounded time (Web::App::
|
|
321
|
+
# STOP_WAIT_SECONDS, not +wait+).
|
|
322
|
+
# @return [Boolean] whether the worker had released it by then, as
|
|
323
|
+
# chi web's released says (absent: true)
|
|
324
|
+
def stop(id, wait:)
|
|
325
|
+
_ = wait
|
|
326
|
+
reply = request(:post, "#{session_path(id)}/stop")
|
|
327
|
+
raise_refusal(reply) unless reply.status == 200
|
|
328
|
+
|
|
329
|
+
reply.json["released"] != false
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# @return [SessionManager::Restarted]
|
|
333
|
+
def restart(id)
|
|
334
|
+
reply = request(:post, "#{session_path(id)}/restart")
|
|
335
|
+
raise_refusal(reply) unless reply.status == 200
|
|
336
|
+
|
|
337
|
+
SessionManager::Restarted.new(session_id: reply.json["session_id"] || id, from_version: reply.json["from_version"],
|
|
338
|
+
version: reply.json["version"])
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# @return [Hash] {id:, archived:, stopped:, discarded:}
|
|
342
|
+
def archive(id)
|
|
343
|
+
reply = request(:post, "#{session_path(id)}/archive")
|
|
344
|
+
raise_refusal(reply) unless reply.status == 200
|
|
345
|
+
|
|
346
|
+
{ id: reply.json["session_id"] || id, archived: Array(reply.json["archived"]), stopped: Array(reply.json["stopped"]),
|
|
347
|
+
discarded: Array(reply.json["discarded"]) }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# @return [Hash] {id:, unarchived:}
|
|
351
|
+
def unarchive(id)
|
|
352
|
+
reply = request(:post, "#{session_path(id)}/unarchive")
|
|
353
|
+
raise_refusal(reply) unless reply.status == 200
|
|
354
|
+
|
|
355
|
+
{ id: reply.json["session_id"] || id, unarchived: Array(reply.json["unarchived"]) }
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# @return [Hash] {id:, stopped:}
|
|
359
|
+
def delete(id, force:)
|
|
360
|
+
reply = request(:delete, session_path(id), query: { force: (true if force) })
|
|
361
|
+
raise_refusal(reply) unless reply.status == 200
|
|
362
|
+
|
|
363
|
+
{ id: reply.json["session_id"] || id, stopped: reply.json["stopped"] == true }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# --- context (a session's own sources: a project's are local only)
|
|
367
|
+
|
|
368
|
+
# @raise [ContextSources::Invalid] refused (a name taken, a bad one)
|
|
369
|
+
def context_add(target, source)
|
|
370
|
+
body = { name: source.name, why: source.why, hint: source.hint, added_by: source.added_by }
|
|
371
|
+
source.push? ? body[:push] = true : body.merge!(cmd: source.cmd, every: source.every_seconds)
|
|
372
|
+
context_added(request(:post, "#{session_path(target.session_id)}/context", body: body.compact))
|
|
373
|
+
nil
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# A URL through the remote's installed bundles' providers (chi web's
|
|
377
|
+
# {url} body: the page's own).
|
|
378
|
+
# @return [String] the source's name
|
|
379
|
+
# @raise [ContextProviders::Invalid] no bundle there resolves it
|
|
380
|
+
def context_add_url(target, url, why:, hint:, added_by:)
|
|
381
|
+
reply = request(:post, "#{session_path(target.session_id)}/context",
|
|
382
|
+
body: { url: url, why: why, hint: hint, added_by: added_by }.compact)
|
|
383
|
+
if reply.status == 422
|
|
384
|
+
require_relative "../context_providers"
|
|
385
|
+
detail = if reply.error == "no_provider" && reply.json["detail"].to_s.start_with?("no installed bundle resolves")
|
|
386
|
+
"no installed bundle on #{name} resolves #{url} (chi bundle install github-pr there for GitHub PRs)"
|
|
387
|
+
else
|
|
388
|
+
"#{reply.json["detail"]} (on #{name})"
|
|
389
|
+
end
|
|
390
|
+
raise ContextProviders::Invalid, detail
|
|
391
|
+
end
|
|
392
|
+
context_added(reply)
|
|
393
|
+
reply.json.fetch("name")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# @param fetched [ContextSources::Fetched]
|
|
397
|
+
# @return [ContextPush]
|
|
398
|
+
def context_push(target, name, fetched)
|
|
399
|
+
reply = request(:post, "#{session_path(target.session_id)}/context/#{name}/push",
|
|
400
|
+
body: { text: fetched.text, summary: fetched.summary, wake: fetched.wake, hint: fetched.hint })
|
|
401
|
+
case reply.status
|
|
402
|
+
when 200 then ContextPush.new(status: :pushed, before: reply.json["before"], revision: reply.json["revision"])
|
|
403
|
+
when 404
|
|
404
|
+
return ContextPush.new(status: :missing) if reply.error == "not_found"
|
|
405
|
+
|
|
406
|
+
raise_refusal(reply)
|
|
407
|
+
when 409 then ContextPush.new(status: :not_push)
|
|
408
|
+
when 400 then raise ContextSources::Invalid, reply.json["detail"].to_s
|
|
409
|
+
else raise_refusal(reply)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# @return [Array<ContextEntry>]
|
|
414
|
+
def context_entries(target)
|
|
415
|
+
reply = request(:get, "#{session_path(target.session_id)}/context", query: { full: 1 })
|
|
416
|
+
raise_refusal(reply) unless reply.status == 200
|
|
417
|
+
|
|
418
|
+
Array(reply.json["sources"]).map { |row| context_entry_of(row) }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# @return [ContextEntry, nil]
|
|
422
|
+
def context_entry(target, name)
|
|
423
|
+
ContextSources.check_name!(name)
|
|
424
|
+
reply = request(:get, "#{session_path(target.session_id)}/context/#{name}", query: { full: 1 })
|
|
425
|
+
return nil if reply.status == 404 && reply.error == "not_found" && reply.json["detail"].to_s.start_with?("no source")
|
|
426
|
+
|
|
427
|
+
raise_refusal(reply) unless reply.status == 200
|
|
428
|
+
|
|
429
|
+
context_entry_of(reply.json, text: reply.json["text"])
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# @return [Symbol] :removed, :project or :missing
|
|
433
|
+
def context_remove(target, name)
|
|
434
|
+
ContextSources.check_name!(name)
|
|
435
|
+
reply = request(:delete, "#{session_path(target.session_id)}/context/#{name}", query: { own: 1 })
|
|
436
|
+
case reply.status
|
|
437
|
+
when 200 then :removed
|
|
438
|
+
when 409 then reply.error == "project_source" ? :project : raise_refusal(reply)
|
|
439
|
+
when 404 then reply.json["detail"].to_s.start_with?("no source") ? :missing : raise_refusal(reply)
|
|
440
|
+
else raise_refusal(reply)
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# --- a remote attach
|
|
445
|
+
|
|
446
|
+
# The client a remote attached TUI talks to the session's worker
|
|
447
|
+
# with, through this chi web.
|
|
448
|
+
# @param worker [Hash] the live worker as POST …/wake or GET …/worker
|
|
449
|
+
# gave it: its epoch is its key (started_at for a worker from before
|
|
450
|
+
# the epoch in its sidecar)
|
|
451
|
+
# @return [WebBridgeClient]
|
|
452
|
+
def attach_client(id, worker:)
|
|
453
|
+
require_relative "web_bridge_client"
|
|
454
|
+
WebBridgeClient.new(remote: self, session_id: id, worker_key: worker["epoch"] || worker["started_at"])
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# One request to a route, as the session commands make them (the
|
|
458
|
+
# server check first; a refused token or a missing route raise
|
|
459
|
+
# RemoteFailed): WebBridgeClient's, which maps the reply itself.
|
|
460
|
+
# @return [HTTPTransport::Reply]
|
|
461
|
+
# @raise [RemoteFailed]
|
|
462
|
+
def route_request(method, path, **) = request(method, path, **)
|
|
463
|
+
|
|
464
|
+
private
|
|
465
|
+
|
|
466
|
+
def session_path(id) = "/api/sessions/#{id}"
|
|
467
|
+
def monotonic = @clock.call
|
|
468
|
+
|
|
469
|
+
def exists?(id)
|
|
470
|
+
request(:get, session_path(id), query: { brief: 1 }).status == 200
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# One request, after the first one's check that this chi web has the
|
|
474
|
+
# routes (GET /api/info). A 401, a 403 forbidden, and a 404 for a
|
|
475
|
+
# route it doesn't have (an older chi web) raise RemoteFailed here.
|
|
476
|
+
def request(method, path, **)
|
|
477
|
+
check_server!
|
|
478
|
+
reply = @transport.call(method, path, **)
|
|
479
|
+
raise RemoteFailed, Remote.auth_failure(@entry, reply) if Remote.auth_failure(@entry, reply)
|
|
480
|
+
raise RemoteTooOld, too_old(@version) if missing_route?(reply)
|
|
481
|
+
|
|
482
|
+
reply
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def check_server!
|
|
486
|
+
return if @checked
|
|
487
|
+
|
|
488
|
+
reply = @transport.call(:get, "/api/info")
|
|
489
|
+
@version = reply.json["version"] if reply.json.is_a?(Hash)
|
|
490
|
+
problem = Remote.info_problem(@entry, reply)
|
|
491
|
+
raise problem if problem
|
|
492
|
+
|
|
493
|
+
@checked = true
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def too_old(version) = Remote.too_old_message(@entry, version)
|
|
497
|
+
|
|
498
|
+
# chi web's 404 for a path no route takes (#serve_static's), not a
|
|
499
|
+
# route's own "no such session".
|
|
500
|
+
def missing_route?(reply)
|
|
501
|
+
reply.status == 404 && reply.error == "not_found" && reply.json["detail"].to_s.start_with?("not found: /")
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Raises what Local's callee raises for this refusal (chi web sends
|
|
505
|
+
# the fields each class is built from; see docs/architecture.md, chi
|
|
506
|
+
# web's routes for the session commands), else RemoteFailed.
|
|
507
|
+
def raise_refusal(reply)
|
|
508
|
+
json = reply.json.is_a?(Hash) ? reply.json : {}
|
|
509
|
+
error = json["error"].to_s
|
|
510
|
+
message = json["message"] || json["detail"]
|
|
511
|
+
sid = json["session_id"]
|
|
512
|
+
reason = json["reason"]&.to_sym
|
|
513
|
+
case error
|
|
514
|
+
when "not_found" then raise ArgumentError, message.to_s
|
|
515
|
+
when "ambiguous_id" then raise Session::AmbiguousId.new(message, ids: Array(json["ids"]))
|
|
516
|
+
when "owned_by_tui" then raise SessionManager::OwnedByTUI, sid
|
|
517
|
+
when "worker_running", "still_stopping" then raise SessionManager::DeleteRefused.new(sid, reason)
|
|
518
|
+
when "busy", "scratch"
|
|
519
|
+
raise SessionManager::ArchiveRefused.new(sid, reason, busy_id: json["busy_id"]) if reason
|
|
520
|
+
when "continued", "open_children", "folder_gone"
|
|
521
|
+
raise SessionContinue::Refused.new(sid, reason, ids: Array(json["ids"]))
|
|
522
|
+
when "held", "not_running", "unsupported", "failed"
|
|
523
|
+
raise SessionManager::RestartRefused.new(sid, reason, version: json["version"]) if reason
|
|
524
|
+
when "parent_answers_refused", "parent_continue_refused", "no_vision"
|
|
525
|
+
raise Refused.new(message.to_s, code: error)
|
|
526
|
+
# No model configured there, or --model's host unknown there: what
|
|
527
|
+
# spawn_session raises here (chi send: could not start a session).
|
|
528
|
+
when "invalid_model" then raise ModelProfile::MissingModel, message.to_s
|
|
529
|
+
when /\Ainvalid_/, "missing_fields", "bad_images"
|
|
530
|
+
raise BadRequest.new(message.to_s, code: error)
|
|
531
|
+
end
|
|
532
|
+
raise RemoteFailed, "#{@entry.label} answered #{reply.status}#{" #{error}" unless error.empty?}" \
|
|
533
|
+
"#{": #{message}" if message}"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def create_body(spec)
|
|
537
|
+
body = { model: spec.model_name, client_id: spec.client_id, history: false }
|
|
538
|
+
if spec.preview
|
|
539
|
+
body.merge!(idle: true, preview: spec.preview)
|
|
540
|
+
else
|
|
541
|
+
body[:prompt] = spec.prompt
|
|
542
|
+
end
|
|
543
|
+
body[:check_vision] = true if spec.images
|
|
544
|
+
if spec.continues
|
|
545
|
+
body[:continues] = spec.continues
|
|
546
|
+
else
|
|
547
|
+
body[:dir] = spec.working_directory
|
|
548
|
+
body.merge!(setup_body(spec.setup)) if spec.setup
|
|
549
|
+
end
|
|
550
|
+
body.compact
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def setup_body(words)
|
|
554
|
+
llm = words.llm_context&.to_h { |field, word| [LLM_CONTEXT_KEYS.fetch(field), word] }
|
|
555
|
+
{ llm_context: llm, thinking: words.thinking, toolkit: words.toolkit,
|
|
556
|
+
tools: (words.tools unless Array(words.tools).empty?), no_tools: (words.no_tools unless Array(words.no_tools).empty?),
|
|
557
|
+
settings: words.settings, parent_answers: words.parent_answers }.compact
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# SetupWords' llm_context fields as the create route names them
|
|
561
|
+
# (LLMContextOverride::COMMAND_WORDS).
|
|
562
|
+
LLM_CONTEXT_KEYS = { strategy: "strategy", apply: "apply", budget_tokens: "budget" }.freeze
|
|
563
|
+
private_constant :LLM_CONTEXT_KEYS
|
|
564
|
+
|
|
565
|
+
def view_of(json, pending)
|
|
566
|
+
owner = json["owner"] && OwnerLock::Owner.new(pid: nil, kind: json["owner"].to_s, started_at: nil)
|
|
567
|
+
SessionView.new(id: json["id"], status: json["status"], title: json["title"],
|
|
568
|
+
display_title: json["display_title"] || json["title"] || json["first_preview"].to_s,
|
|
569
|
+
project_root: json["project_root"], working_directory: json["working_directory"],
|
|
570
|
+
model_name: json["model_name"], model_typed: json["model_typed"],
|
|
571
|
+
parent_answers: json["parent_answers"], owner: owner,
|
|
572
|
+
pending_question: PendingQuestion.from_file(pending))
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def wait_start_of(json)
|
|
576
|
+
baseline = ReplyWait::Baseline.new(**(json["baseline"] || {}).transform_keys(&:to_sym))
|
|
577
|
+
WaitStart.new(cursor: json["cursor"], baseline: baseline, own_turn: json["own_turn"], known_turn: json["known_turn"],
|
|
578
|
+
running: json["running"] == true, live: json["live"] == true, settled: json["settled"] == true,
|
|
579
|
+
last_turn_id: json["last_turn_id"])
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
RESULT_FIELDS = %i[text file outcome limit error_kind retryable cancel_reason stopped_by kept_steps turn_id origin].freeze
|
|
583
|
+
private_constant :RESULT_FIELDS
|
|
584
|
+
|
|
585
|
+
def result_of(json)
|
|
586
|
+
fields = RESULT_FIELDS.to_h { |key| [key, json[key.to_s]] }
|
|
587
|
+
ReplyWait::Result.new(status: json["status"].to_s.to_sym, question: PendingQuestion.from_file(json["question"]),
|
|
588
|
+
**fields)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def strategy_of(json)
|
|
592
|
+
return nil unless json.is_a?(Hash) && json["name"]
|
|
593
|
+
|
|
594
|
+
Guardrails::ParentPolicy::Strategy.new(name: json["name"].to_s, allowed: Array(json["allowed"]).map(&:to_s).freeze,
|
|
595
|
+
origin: (json["origin"] || "config").to_sym)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# chi web's answer/dismiss reply as the worker's Bridge would have
|
|
599
|
+
# given it: 503 not_live is no worker (nil), 504 the Bridge's 408, 501
|
|
600
|
+
# not_supported its 404 for a route it lacks, 409 question_not_pending
|
|
601
|
+
# its 409; the rest as it came.
|
|
602
|
+
def bridge_reply(reply)
|
|
603
|
+
json = reply.json.is_a?(Hash) ? reply.json : {}
|
|
604
|
+
case [reply.status, json["error"]]
|
|
605
|
+
in [503, "not_live"] then nil
|
|
606
|
+
in [404, "not_found"] then nil
|
|
607
|
+
in [504, _] then BridgeClient::Response.new(status: 408, body: reply.body)
|
|
608
|
+
in [501, "not_supported"] then BridgeClient::Response.new(status: 404, body: JSON.generate(json.merge("error" => "not_found")))
|
|
609
|
+
else BridgeClient::Response.new(status: reply.status, body: reply.body)
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def context_added(reply)
|
|
614
|
+
return if reply.status == 201
|
|
615
|
+
|
|
616
|
+
case reply.error
|
|
617
|
+
when "exists", "invalid_source", "invalid_json" then raise ContextSources::Invalid, reply.json["detail"].to_s
|
|
618
|
+
else raise_refusal(reply)
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def context_entry_of(row, text: nil)
|
|
623
|
+
source = ContextSources::Source.from_h(row["source"] || {})
|
|
624
|
+
snapshot = ContextSources::Snapshot.from_h((row["snapshot"] || {}).merge("text" => text))
|
|
625
|
+
ContextEntry.new(source: source, snapshot: snapshot, muted: row["muted"], shadowed: row["shadowed"],
|
|
626
|
+
unread: row["unread"])
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def symbolize(value)
|
|
630
|
+
case value
|
|
631
|
+
when Hash then value.to_h { |key, item| [key.to_sym, symbolize(item)] }
|
|
632
|
+
when Array then value.map { |item| symbolize(item) }
|
|
633
|
+
else value
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
end
|