samagotchi 0.102.0 → 0.104.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 +93 -1
- data/bin/chi +60 -191
- data/docs/architecture.md +67 -1
- data/docs/cli.md +190 -9
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +4 -2
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +5 -2
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +429 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +33 -3
- data/docs/sessions.md +16 -7
- data/docs/sub-agent.md +16 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +31 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +26 -39
- 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 +40 -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/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +28 -14
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +8 -2
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +21 -2
- data/lib/samagotchi/launch_mode.rb +26 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +245 -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 +38 -9
- data/lib/samagotchi/session_access/contract.rb +167 -0
- data/lib/samagotchi/session_access/dispatch.rb +152 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +400 -0
- data/lib/samagotchi/session_access/remote.rb +734 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
- data/lib/samagotchi/session_access.rb +223 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +27 -6
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
- data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
- 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 +28 -3
- data/lib/samagotchi/terminal_ui.rb +6 -3
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +530 -126
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- 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/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +459 -0
- data/lib/samagotchi/worker.rb +26 -5
- 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 +28 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "../session_access"
|
|
6
|
+
require_relative "../bridge_client"
|
|
7
|
+
require_relative "../delivery"
|
|
8
|
+
require_relative "../log"
|
|
9
|
+
require_relative "http_transport"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
module SessionAccess
|
|
13
|
+
# A session's worker reached through another machine's chi web, with the
|
|
14
|
+
# interface the attached TUI (TerminalUI::AttachedLoop) and its
|
|
15
|
+
# EventStream use on a BridgeClient: chi web relays each request to the
|
|
16
|
+
# worker's Bridge, on loopback there. One object per remote attach, for
|
|
17
|
+
# its whole life: a new worker changes its #worker_key (the worker's
|
|
18
|
+
# Bridge epoch), never the object. Built by Remote#attach_client.
|
|
19
|
+
#
|
|
20
|
+
# Every reply goes through one status mapper (Mapping), so the loop
|
|
21
|
+
# reads each condition as it reads it from a Bridge here. Every
|
|
22
|
+
# RemoteFailed (a refused connection, a timeout, a chi web too old, a
|
|
23
|
+
# refused token) becomes Unreachable with its message: the loop catches
|
|
24
|
+
# IOError wherever a Bridge call can fail, so it never crashes on one.
|
|
25
|
+
class WebBridgeClient
|
|
26
|
+
# The worker can't be reached through chi web: no live worker there,
|
|
27
|
+
# the session is gone, or chi web itself can't be asked (the message
|
|
28
|
+
# says which).
|
|
29
|
+
class Unreachable < IOError; end
|
|
30
|
+
|
|
31
|
+
# A one-shot's read timeout: above chi web's own 30 s read of the
|
|
32
|
+
# Bridge plus its <= 5 s wait for a waking worker's Bridge.
|
|
33
|
+
READ_TIMEOUT = 45
|
|
34
|
+
# An answer or a dismissal: chi web sends it once more after a 408.
|
|
35
|
+
ANSWER_READ_TIMEOUT = HTTPTransport::READ_TIMEOUT
|
|
36
|
+
# No byte for this long on the stream (the Bridge pings every 15 s): a
|
|
37
|
+
# half-open connection (Wi-Fi gone, the laptop asleep), reconnected.
|
|
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
|
|
42
|
+
# chi web answers no live worker for the session (Mapping).
|
|
43
|
+
NO_WORKER = "no live worker for session %s on %s"
|
|
44
|
+
|
|
45
|
+
# chi web's answers as the session's Bridge would have given them.
|
|
46
|
+
module Mapping
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# @param route [Symbol] :turn, :command, :answer, :dismiss,
|
|
50
|
+
# :dequeue, :cancel, :exit, :recap, :stats, :children, :records
|
|
51
|
+
# @param reply [HTTPTransport::Reply]
|
|
52
|
+
# @param session_id [String]
|
|
53
|
+
# @param remote [String] the remote's name
|
|
54
|
+
# @return [BridgeClient::Response] 504 as the Bridge's 408
|
|
55
|
+
# deadline_passed; 501 as its 404 not_found (an older worker),
|
|
56
|
+
# and /command's for one builtin as its 400 unknown_command;
|
|
57
|
+
# the rest unchanged (202 acks, 409s, the route's own 404s,
|
|
58
|
+
# 403 parent_answer_refused, 502 bridge_error)
|
|
59
|
+
# @raise [Unreachable] 503 not_live, or 404 not_found (the session)
|
|
60
|
+
def response(route, reply, session_id:, remote:)
|
|
61
|
+
json = reply.json.is_a?(Hash) ? reply.json : {}
|
|
62
|
+
case [reply.status, json["error"]]
|
|
63
|
+
in [503, "not_live"] | [404, "not_found"] then raise Unreachable, format(NO_WORKER, session_id, remote)
|
|
64
|
+
in [504, _] then bridge(408, error: "deadline_passed", detail: json["detail"])
|
|
65
|
+
in [501, _] if route == :command && json["command"]
|
|
66
|
+
bridge(400, error: "unknown_command", detail: json["detail"], command: json["command"])
|
|
67
|
+
in [501, _] then bridge(404, error: "not_found", detail: json["detail"])
|
|
68
|
+
else BridgeClient::Response.new(status: reply.status, body: reply.body)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def bridge(status, **body) = BridgeClient::Response.new(status: status, body: JSON.generate(body.compact))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
attr_reader :session_id
|
|
76
|
+
# @return [String, nil] the worker followed: its Bridge's epoch (its
|
|
77
|
+
# start time for a worker from before the epoch in its sidecar)
|
|
78
|
+
attr_accessor :worker_key
|
|
79
|
+
|
|
80
|
+
# @param remote [Remote] the remote's chi web
|
|
81
|
+
# @param session_id [String]
|
|
82
|
+
# @param worker_key [String, nil] the live worker's epoch, from the wake
|
|
83
|
+
def initialize(remote:, session_id:, worker_key:)
|
|
84
|
+
@remote = remote
|
|
85
|
+
@session_id = session_id
|
|
86
|
+
@worker_key = worker_key
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def inspect = "#<#{self.class.name} #{@remote.entry.label} #{@session_id}>"
|
|
90
|
+
|
|
91
|
+
# No Bridge port here: the worker is reached through chi web.
|
|
92
|
+
def port = nil
|
|
93
|
+
|
|
94
|
+
# POST …/turn. history: false (chi web's prompt history is that
|
|
95
|
+
# machine's page's).
|
|
96
|
+
# @return [BridgeClient::Response]
|
|
97
|
+
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil)
|
|
98
|
+
body = { prompt: prompt, client_id: client_id, history: false }
|
|
99
|
+
body[:no_interrupt] = true if no_interrupt
|
|
100
|
+
body[:images] = images if images && !images.empty?
|
|
101
|
+
body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
|
|
102
|
+
post(:turn, "turn", body)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# POST …/command.
|
|
106
|
+
# @return [BridgeClient::Response]
|
|
107
|
+
def post_command(line:, client_id: nil, card: false)
|
|
108
|
+
body = { line: line, client_id: client_id, history: false }
|
|
109
|
+
body[:card] = true if card
|
|
110
|
+
post(:command, "command", body)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# POST …/answer (chi web passes client_id on only when it is chi
|
|
114
|
+
# answer's).
|
|
115
|
+
# @return [BridgeClient::Response]
|
|
116
|
+
def answer(id:, selected:, freeform: nil, client_id: nil)
|
|
117
|
+
post(:answer, "answer", { id: id, selected: selected, freeform: freeform, client_id: client_id }.compact,
|
|
118
|
+
read_timeout: ANSWER_READ_TIMEOUT)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# POST …/question/dismiss.
|
|
122
|
+
# @return [BridgeClient::Response]
|
|
123
|
+
def dismiss_question(id:)
|
|
124
|
+
post(:dismiss, "question/dismiss", { id: id }, read_timeout: ANSWER_READ_TIMEOUT)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# POST …/turn/:enqueued_id/dequeue.
|
|
128
|
+
# @return [BridgeClient::Response]
|
|
129
|
+
def dequeue(enqueued_id:, client_id: nil)
|
|
130
|
+
post(:dequeue, "turn/#{URI.encode_www_form_component(enqueued_id.to_s)}/dequeue", { client_id: client_id }.compact)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# POST …/cancel.
|
|
134
|
+
# @return [BridgeClient::Response]
|
|
135
|
+
def cancel(reason:)
|
|
136
|
+
post(:cancel, "cancel", { reason: reason })
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# POST …/exit: the worker asked to exit (chi web passes client_id on
|
|
140
|
+
# only when it is an attached TUI's).
|
|
141
|
+
# @return [BridgeClient::Response] 200 {status: exiting, discard}, 409
|
|
142
|
+
# held {reason, tasks}, 408 late, 404 an older worker's
|
|
143
|
+
def request_exit(client_id:, delete: false)
|
|
144
|
+
body = { client_id: client_id }
|
|
145
|
+
body[:delete] = true if delete
|
|
146
|
+
post(:exit, "exit", body)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# POST …/recap: the saved recap, and a new one asked for.
|
|
150
|
+
# @return [BridgeClient::Response] 200 {enabled, saved, request, min_user_turns, session_off}
|
|
151
|
+
def request_recap = post(:recap, "recap", {})
|
|
152
|
+
|
|
153
|
+
# GET …/stats: the worker's metrics (its state's, for an older
|
|
154
|
+
# worker), nil when it didn't give them.
|
|
155
|
+
# @return [Hash, nil]
|
|
156
|
+
# @raise [Unreachable]
|
|
157
|
+
def stats
|
|
158
|
+
response = get(:stats, "stats", read_timeout: READ_TIMEOUT)
|
|
159
|
+
response.status == 200 ? response.json&.dig("metrics") : nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# GET …/children/counts: the session's delegates' counts there.
|
|
163
|
+
# @return [Hash] {running, waiting, unreported}
|
|
164
|
+
# @raise [Unreachable] chi web couldn't answer (or the session is gone)
|
|
165
|
+
def children_counts
|
|
166
|
+
response = get(:children, "children/counts", read_timeout: JOIN_READ_TIMEOUT)
|
|
167
|
+
raise Unreachable, "children counts: chi web answered #{response.status}" unless response.status == 200
|
|
168
|
+
|
|
169
|
+
response.json
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# GET …/turns/:turn_id/records: a turn's saved tool records and turn
|
|
173
|
+
# record there.
|
|
174
|
+
# @return [Hash] {tool_records: [...], turn_record: {...} | nil}
|
|
175
|
+
# @raise [Unreachable]
|
|
176
|
+
def turn_records(turn_id)
|
|
177
|
+
response = get(:records, "turns/#{URI.encode_www_form_component(turn_id.to_s)}/records",
|
|
178
|
+
read_timeout: JOIN_READ_TIMEOUT)
|
|
179
|
+
raise Unreachable, "turn records: chi web answered #{response.status}" unless response.status == 200
|
|
180
|
+
|
|
181
|
+
response.json
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Follow the session's events through chi web (see EventStream): with
|
|
185
|
+
# epoch_watch, announce_retries and STREAM_IDLE_TIMEOUT.
|
|
186
|
+
# @return [BridgeClient::EventStream] started
|
|
187
|
+
def follow(snapshot: true, client_id: nil, reconnect_delays: BridgeClient::EventStream::DEFAULT_RECONNECT_DELAYS,
|
|
188
|
+
rediscover: nil, &on_event)
|
|
189
|
+
BridgeClient::EventStream.new(self, snapshot: snapshot, client_id: client_id, reconnect_delays: reconnect_delays,
|
|
190
|
+
rediscover: rediscover, epoch_watch: true, announce_retries: true,
|
|
191
|
+
idle_timeout: STREAM_IDLE_TIMEOUT, &on_event).start
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# GET /api/sessions/:id/stream through StreamRequest, with the token
|
|
195
|
+
# as Bearer. A chunked response (a proxy in between: no decoding in
|
|
196
|
+
# v1) and an https chi web (no TLS on the stream yet) are logged and
|
|
197
|
+
# read as unreachable.
|
|
198
|
+
# @return [Array(TCPSocket, Integer, Hash), nil] see StreamRequest.open
|
|
199
|
+
def connect_stream(query: "", last_event_id: nil, timeout: nil)
|
|
200
|
+
uri = URI(@remote.entry.url)
|
|
201
|
+
return refused("tls_unsupported") if uri.scheme == "https"
|
|
202
|
+
|
|
203
|
+
token = @remote.entry.token
|
|
204
|
+
opened = BridgeClient::StreamRequest.open(host: uri.host, port: uri.port,
|
|
205
|
+
path: "/api/sessions/#{@session_id}/stream#{query}",
|
|
206
|
+
last_event_id: last_event_id, timeout: timeout,
|
|
207
|
+
headers: token ? { "Authorization" => "Bearer #{token}" } : {})
|
|
208
|
+
return opened unless opened && opened[2]["transfer-encoding"].to_s.downcase.include?("chunked")
|
|
209
|
+
|
|
210
|
+
opened[0].close rescue nil
|
|
211
|
+
refused("chunked_unsupported")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def post(route, path, body, read_timeout: READ_TIMEOUT)
|
|
217
|
+
reply = @remote.route_request(:post, "/api/sessions/#{@session_id}/#{path}", body: body, read_timeout: read_timeout)
|
|
218
|
+
Mapping.response(route, reply, session_id: @session_id, remote: @remote.name)
|
|
219
|
+
rescue RemoteFailed => e
|
|
220
|
+
raise Unreachable, e.message
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def get(route, path, read_timeout:)
|
|
224
|
+
reply = @remote.route_request(:get, "/api/sessions/#{@session_id}/#{path}", read_timeout: read_timeout)
|
|
225
|
+
Mapping.response(route, reply, session_id: @session_id, remote: @remote.name)
|
|
226
|
+
rescue RemoteFailed => e
|
|
227
|
+
raise Unreachable, e.message
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def refused(reason)
|
|
231
|
+
Log.warn(:attached, "stream_refused", reason: reason, remote: @remote.name)
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The seam between chi's session commands (chi send, answer, note,
|
|
5
|
+
# session-scope context, sessions list/stop/restart/archive/unarchive/
|
|
6
|
+
# delete) and where the sessions live. One object per command run:
|
|
7
|
+
# Local reads and writes this machine's session files and workers; a
|
|
8
|
+
# Remote (chi --remote NAME, CHI_REMOTE) does the same over another
|
|
9
|
+
# machine's chi web, raising the same errors with the same words.
|
|
10
|
+
# The commands keep their wording, exit codes and checks; what they ask
|
|
11
|
+
# of a session goes through here. Session commands and flags that run on
|
|
12
|
+
# this machine only are listed in SessionAccess::Contract. See
|
|
13
|
+
# docs/architecture.md (Session access).
|
|
14
|
+
module SessionAccess
|
|
15
|
+
autoload :Local, File.expand_path("session_access/local", __dir__)
|
|
16
|
+
autoload :Contract, File.expand_path("session_access/contract", __dir__)
|
|
17
|
+
autoload :Remote, File.expand_path("session_access/remote", __dir__)
|
|
18
|
+
autoload :Dispatch, File.expand_path("session_access/dispatch", __dir__)
|
|
19
|
+
|
|
20
|
+
# A remote's chi web could not be asked, or answered what this chi can't
|
|
21
|
+
# use: the message names the remote and what failed ("remote studio
|
|
22
|
+
# (http://192.168.1.29:4567): connection refused"). Only a Remote raises
|
|
23
|
+
# it; a command prints it as it prints its other failures (exit 1).
|
|
24
|
+
class RemoteFailed < StandardError; end
|
|
25
|
+
|
|
26
|
+
# A remote's chi web is from before the routes this command needs
|
|
27
|
+
# ("remote studio runs chi 0.102.0, too old for this command through
|
|
28
|
+
# --remote: update chi there (chi update)").
|
|
29
|
+
class RemoteTooOld < RemoteFailed; end
|
|
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
|
+
|
|
72
|
+
# A remote's chi web refused a request for a reason this machine checks
|
|
73
|
+
# itself before sending (a parent's /continue yes, a loosening
|
|
74
|
+
# /parent-answers, images to a model that can't see them): +message+
|
|
75
|
+
# is the server's text, the words chi send uses on its own machine.
|
|
76
|
+
# +code+ is chi web's error code.
|
|
77
|
+
class Refused < StandardError
|
|
78
|
+
attr_reader :code
|
|
79
|
+
|
|
80
|
+
def initialize(message, code:)
|
|
81
|
+
@code = code
|
|
82
|
+
super(message)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A remote's chi web refused a request's values as a usage error would
|
|
87
|
+
# on this machine (a bad --set, --toolkit or --dir there): chi send
|
|
88
|
+
# exits 2 with its message.
|
|
89
|
+
class BadRequest < Refused; end
|
|
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
|
+
|
|
108
|
+
# A session to create (chi send --new): a new one, or with +continues+
|
|
109
|
+
# the next link of that session's chain (which brings its own folder,
|
|
110
|
+
# LLM context, thinking, toolkit and settings: the rest is nil then).
|
|
111
|
+
# +preview+ set: the session starts idle with it as its preview, and
|
|
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).
|
|
115
|
+
# +setup+ is a SessionSetup for Local, a SetupWords for Remote (the
|
|
116
|
+
# flags as typed: the server checks them against its own config and
|
|
117
|
+
# bundles). +client_id+ (chi send's, or chi answer's for a parent's
|
|
118
|
+
# /continue yes) and +images+ (images follow) are for a Remote's server,
|
|
119
|
+
# which checks the first line and the model as chi send checks them
|
|
120
|
+
# here (Local checks before it creates).
|
|
121
|
+
NewSession = Data.define(:prompt, :preview, :continues, :working_directory, :model_name, :setup, :client_id,
|
|
122
|
+
:images, :idle) do
|
|
123
|
+
def initialize(prompt:, preview: nil, continues: nil, working_directory: nil, model_name: nil, setup: nil,
|
|
124
|
+
client_id: nil, images: false, idle: false) = super
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# chi send --new's setup flags as typed, for a Remote: +llm_context+
|
|
128
|
+
# {strategy:, apply:, budget_tokens:} words, +thinking+ and
|
|
129
|
+
# +parent_answers+ level words, +toolkit+ a name, +tools+ and
|
|
130
|
+
# +no_tools+ names, +settings+ {"KEY" => "VALUE"} as --set gave them.
|
|
131
|
+
# The server reads each as its create route does (POST /api/sessions).
|
|
132
|
+
SetupWords = Data.define(:llm_context, :thinking, :toolkit, :tools, :no_tools, :settings, :parent_answers) do
|
|
133
|
+
def initialize(llm_context: nil, thinking: nil, toolkit: nil, tools: [], no_tools: [], settings: nil,
|
|
134
|
+
parent_answers: nil) = super
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A session as a session command reads it (Local: the Session itself;
|
|
138
|
+
# Remote: chi web's brief read). The fields the commands use, and
|
|
139
|
+
# Session's #display_title.
|
|
140
|
+
# @!attribute owner [OwnerLock::Owner, nil] its worker or chi REPL
|
|
141
|
+
# @!attribute pending_question [PendingQuestion, nil] the file's
|
|
142
|
+
SessionView = Data.define(:id, :status, :title, :display_title, :project_root, :working_directory, :model_name,
|
|
143
|
+
:model_typed, :parent_answers, :owner, :pending_question)
|
|
144
|
+
|
|
145
|
+
# What chi send --new created (Remote; Local gives the Session): its
|
|
146
|
+
# id, a model warning, the session it continues, the delegates it took
|
|
147
|
+
# over, and the server's word on a --parent-answers looser than its
|
|
148
|
+
# config.yml's (capped).
|
|
149
|
+
Created = Data.define(:id, :model_warning, :continues, :moved_children, :parent_answers_warning)
|
|
150
|
+
|
|
151
|
+
# A session's worker as an attach finds it (Local#worker, chi web's GET
|
|
152
|
+
# …/worker): +sidecar+ the live worker's WorkerSidecar (port, version,
|
|
153
|
+
# features, epoch; nil: none runs), +stopped+ its stop marker, +owner+
|
|
154
|
+
# its OwnerLock::Owner (a worker, a chi REPL, nil).
|
|
155
|
+
WorkerState = Data.define(:sidecar, :stopped, :owner) do
|
|
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?
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# What a wake for an attach did (Local#wake, chi web's POST …/wake):
|
|
164
|
+
# +woke+ it started the worker (false: one ran), +sidecar+ the live
|
|
165
|
+
# worker's (nil: its Bridge didn't come within the wait).
|
|
166
|
+
Woken = Data.define(:woke, :sidecar)
|
|
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
|
+
|
|
186
|
+
# What chi send --wait and chi answer read before anything is sent, so
|
|
187
|
+
# neither an older reply nor a turn that ends before the first look is
|
|
188
|
+
# taken for the answer (ReplyWait's cursor:, baseline:, turn:).
|
|
189
|
+
# @!attribute cursor [String, nil] the newest reply file already there
|
|
190
|
+
# @!attribute baseline [ReplyWait::Baseline] the session as it was
|
|
191
|
+
# @!attribute own_turn [String, nil] ReplyWait.own_turn: the turn
|
|
192
|
+
# running now, or the one whose continue offer stands
|
|
193
|
+
# @!attribute known_turn [Boolean, nil] with a turn asked for
|
|
194
|
+
# (--turn), ReplyWait.known_turn?; nil without one
|
|
195
|
+
# @!attribute running [Boolean] the session's status was running
|
|
196
|
+
# @!attribute live [Boolean] running, or a worker or a REPL owns it
|
|
197
|
+
# @!attribute settled [Boolean] idle, a turn ended and no question
|
|
198
|
+
# stands (a continue offer waits for its answer)
|
|
199
|
+
# @!attribute last_turn_id [String, nil] the last ended turn's
|
|
200
|
+
# enqueued_id
|
|
201
|
+
WaitStart = Data.define(:cursor, :baseline, :own_turn, :known_turn, :running, :live, :settled, :last_turn_id)
|
|
202
|
+
|
|
203
|
+
# One attached context source as a session (or a project) sees it:
|
|
204
|
+
# its Source and Snapshot (ContextSources), and for a session's list
|
|
205
|
+
# whether it mutes it, a session source shadows it, and its text is
|
|
206
|
+
# unread (nil for a project's, or where not asked).
|
|
207
|
+
ContextEntry = Data.define(:source, :snapshot, :muted, :shadowed, :unread) do
|
|
208
|
+
def initialize(source:, snapshot:, muted: nil, shadowed: nil, unread: nil) = super
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# chi context push's outcome: :pushed (+before+ the revision it had,
|
|
212
|
+
# +revision+ the new one), :missing (no such source) or :not_push (a
|
|
213
|
+
# command source, whose text is its command's).
|
|
214
|
+
ContextPush = Data.define(:status, :before, :revision) do
|
|
215
|
+
def initialize(status:, before: nil, revision: nil) = super
|
|
216
|
+
|
|
217
|
+
# Why a push to +name+ was refused as :not_push: a command source's
|
|
218
|
+
# text is its command's, and a push between two fetches would read
|
|
219
|
+
# as a change and then vanish.
|
|
220
|
+
def self.not_push_message(name) = "#{name} runs a command, which writes its text: push goes to a --push source"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "session_manager"
|
|
5
5
|
require_relative "cli/command"
|
|
6
|
+
require_relative "session_access/local"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# `chi sessions archive|unarchive`: hide sessions from every list and keep
|
|
@@ -29,12 +30,15 @@ module Samagotchi
|
|
|
29
30
|
|
|
30
31
|
# @param action [String] "archive" or "unarchive"
|
|
31
32
|
# @param argv [Array<String>] the arguments after "sessions <action>"
|
|
32
|
-
|
|
33
|
+
# @param access [SessionAccess::Local, nil] where the sessions are;
|
|
34
|
+
# default this machine's, in +state_dir+
|
|
35
|
+
def initialize(action, argv, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil)
|
|
33
36
|
@action = action
|
|
34
37
|
@argv = argv.dup
|
|
35
38
|
@stdout = stdout
|
|
36
39
|
@stderr = stderr
|
|
37
40
|
@state_dir = state_dir || Session.default_state_dir
|
|
41
|
+
@access = access || SessionAccess::Local.new(state_dir: @state_dir)
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
# @return [Integer] exit status: 0 all done, 1 any refused or unknown, 2 usage
|
|
@@ -57,9 +61,9 @@ module Samagotchi
|
|
|
57
61
|
private
|
|
58
62
|
|
|
59
63
|
def archive(given)
|
|
60
|
-
id =
|
|
64
|
+
id = @access.resolve_id(given)
|
|
61
65
|
short = id[0, 8]
|
|
62
|
-
result =
|
|
66
|
+
result = @access.archive(id)
|
|
63
67
|
if result[:archived].include?(id)
|
|
64
68
|
notes = []
|
|
65
69
|
notes << "stopped its worker" if result[:stopped].include?(id)
|
|
@@ -86,9 +90,9 @@ module Samagotchi
|
|
|
86
90
|
end
|
|
87
91
|
|
|
88
92
|
def unarchive(given)
|
|
89
|
-
id =
|
|
93
|
+
id = @access.resolve_id(given)
|
|
90
94
|
short = id[0, 8]
|
|
91
|
-
result =
|
|
95
|
+
result = @access.unarchive(id)
|
|
92
96
|
@stdout.puts(result[:unarchived].empty? ? "#{short} not archived" : "#{short} unarchived")
|
|
93
97
|
true
|
|
94
98
|
rescue ArgumentError => e
|
|
@@ -113,9 +113,9 @@ module Samagotchi
|
|
|
113
113
|
registry.register(TOOLKIT_COMMAND, "show or change the tools this session declares (NAME, add TOOL, remove TOOL)",
|
|
114
114
|
id: :toolkit, mid_turn: show_mid_turn(TOOLKIT_COMMAND),
|
|
115
115
|
match: ->(text) { text.match?(%r{\A/toolkit(?:\s+.*)?\z}) }) { |text| toolkit(text) }
|
|
116
|
-
# The list reads only: mid-turn too;
|
|
116
|
+
# The list reads only: mid-turn too; an op waits for the turn's end
|
|
117
117
|
# (the next turn's first request is the first with it).
|
|
118
|
-
registry.register(WORKING_SET_COMMAND, "list the working set; drop or
|
|
118
|
+
registry.register(WORKING_SET_COMMAND, "list the working set; drop, undrop, pin or unpin an item (drop KEY…)",
|
|
119
119
|
id: :working_set, mid_turn: show_mid_turn(WORKING_SET_COMMAND),
|
|
120
120
|
match: ->(text) { text.match?(%r{\A/working-set(?:\s+.*)?\z}) }) { |text| working_set(text) }
|
|
121
121
|
# Tighten-only from anyone; loosening is a user's at a UI (#run's user_ui:).
|
|
@@ -205,12 +205,14 @@ module Samagotchi
|
|
|
205
205
|
end
|
|
206
206
|
|
|
207
207
|
# Why a UI-only command isn't run by +runner+ ("chi send"): /stats points
|
|
208
|
-
# at its model-free reader (naming the session +id+ when there is one)
|
|
208
|
+
# at its model-free reader (naming the session +id+ when there is one),
|
|
209
|
+
# which reads the session's machine: under chi --remote NAME (+remote+)
|
|
210
|
+
# it runs there.
|
|
209
211
|
# @return [String]
|
|
210
|
-
def self.ui_only_refusal(name, id, runner:)
|
|
212
|
+
def self.ui_only_refusal(name, id, runner:, remote: nil)
|
|
211
213
|
return "#{name} is a terminal and web command; #{runner} doesn't run it" unless name == "/stats"
|
|
212
214
|
|
|
213
|
-
"#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
|
|
215
|
+
"#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}#{" on #{remote}" if remote}"
|
|
214
216
|
end
|
|
215
217
|
|
|
216
218
|
# The name of this chi's own session command +line+ is (one a worker
|
|
@@ -501,6 +503,9 @@ module Samagotchi
|
|
|
501
503
|
def llm_context(text)
|
|
502
504
|
output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
|
|
503
505
|
.run(text.delete_prefix(LLM_CONTEXT_COMMAND))
|
|
506
|
+
# What the model has of each item is the next turn's strategy's: the
|
|
507
|
+
# working set goes out again (sent only when it differs).
|
|
508
|
+
@engine.announce_working_set if changed
|
|
504
509
|
reply(output, changed: changed ? [:llm_context] : [])
|
|
505
510
|
rescue ArgumentError => e
|
|
506
511
|
reply(e.message, status: :error)
|
|
@@ -545,18 +550,18 @@ module Samagotchi
|
|
|
545
550
|
reply(output, status: status, changed: changed ? [:parent_answers] : [])
|
|
546
551
|
end
|
|
547
552
|
|
|
548
|
-
# /working-set (WorkingSetCommand): an op changes the conversation
|
|
549
|
-
# (changed :messages: the worker saves it, the web reads it again)
|
|
550
|
-
# a rollback past it would drop it
|
|
551
|
-
#
|
|
553
|
+
# /working-set (WorkingSetCommand): an op that changes the conversation
|
|
554
|
+
# (changed :messages: the worker saves it, the web reads it again)
|
|
555
|
+
# closes the rollback window (a rollback past it would drop it
|
|
556
|
+
# silently, as !cmd); a pin changes the session's working_set_pins.
|
|
557
|
+
# Either way the working set goes out again (held after the command's
|
|
558
|
+
# command_ran).
|
|
552
559
|
def working_set(text)
|
|
553
560
|
output, changed, status = WorkingSetCommand.new(engine: @engine, save: ->(_session) { save_session })
|
|
554
561
|
.run(text.delete_prefix(WORKING_SET_COMMAND))
|
|
555
|
-
if changed
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
end
|
|
559
|
-
reply(output, status: status, changed: changed ? [:messages] : [])
|
|
562
|
+
@turn_flow.note_conversation_changed if changed.include?(:messages)
|
|
563
|
+
@engine.announce_working_set unless changed.empty?
|
|
564
|
+
reply(output, status: status, changed: changed)
|
|
560
565
|
rescue ArgumentError => e
|
|
561
566
|
reply(e.message, status: :error)
|
|
562
567
|
end
|
|
@@ -565,7 +570,7 @@ module Samagotchi
|
|
|
565
570
|
# cleared its name. A plain save: no setting moved, and it may run on
|
|
566
571
|
# an anytime thread beside a turn.
|
|
567
572
|
def title(text)
|
|
568
|
-
output, changed, status = TitleCommand.new(engine: @engine
|
|
573
|
+
output, changed, status = TitleCommand.new(engine: @engine).run(text.delete_prefix(TITLE_COMMAND))
|
|
569
574
|
reply(output, status: status, changed: changed ? [:title] : [])
|
|
570
575
|
end
|
|
571
576
|
|
|
@@ -170,7 +170,7 @@ module Samagotchi
|
|
|
170
170
|
private_class_method def self.discard_failed_link(id, state_dir)
|
|
171
171
|
orphan = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
172
172
|
if SessionManager.refuse_tui!(orphan, state_dir: state_dir) &&
|
|
173
|
-
!SessionManager.stop_session(orphan, state_dir: state_dir, wait:
|
|
173
|
+
!SessionManager.stop_session(orphan, state_dir: state_dir, wait: SessionManager::STOP_WAIT)
|
|
174
174
|
raise SessionManager::DeleteRefused.new(orphan, :still_stopping)
|
|
175
175
|
end
|
|
176
176
|
|
|
@@ -4,6 +4,7 @@ require_relative "session"
|
|
|
4
4
|
require_relative "session_manager"
|
|
5
5
|
require_relative "cli/command"
|
|
6
6
|
require_relative "cli/flags"
|
|
7
|
+
require_relative "session_access/local"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
# `chi sessions delete`: remove sessions for good (SessionManager.delete_session),
|
|
@@ -24,11 +25,14 @@ module Samagotchi
|
|
|
24
25
|
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) { |f| f.switch "-f", "--force" }
|
|
25
26
|
|
|
26
27
|
# @param argv [Array<String>] the arguments after "sessions delete"
|
|
27
|
-
|
|
28
|
+
# @param access [SessionAccess::Local, nil] where the sessions are;
|
|
29
|
+
# default this machine's, in +state_dir+
|
|
30
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil)
|
|
28
31
|
@argv = argv.dup
|
|
29
32
|
@stdout = stdout
|
|
30
33
|
@stderr = stderr
|
|
31
34
|
@state_dir = state_dir || Session.default_state_dir
|
|
35
|
+
@access = access || SessionAccess::Local.new(state_dir: @state_dir)
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
# @return [Integer] exit status: 0 all deleted, 1 any refused or
|
|
@@ -56,10 +60,10 @@ module Samagotchi
|
|
|
56
60
|
|
|
57
61
|
# @return [Boolean] whether the session is gone
|
|
58
62
|
def delete(given, force:)
|
|
59
|
-
id =
|
|
63
|
+
id = @access.resolve_id(given)
|
|
60
64
|
short = id[0, 8]
|
|
61
65
|
preview = preview_of(id)
|
|
62
|
-
result =
|
|
66
|
+
result = @access.delete(id, force: force)
|
|
63
67
|
@stdout.puts(["#{short} deleted#{" (stopped its worker)" if result[:stopped]}", preview].compact.join(" "))
|
|
64
68
|
true
|
|
65
69
|
rescue SessionManager::OwnedByTUI
|
|
@@ -80,7 +84,7 @@ module Samagotchi
|
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
def preview_of(id)
|
|
83
|
-
text =
|
|
87
|
+
text = @access.show(id).display_title.gsub(/\s+/, " ").strip
|
|
84
88
|
return nil if text.empty?
|
|
85
89
|
|
|
86
90
|
text.length > PREVIEW_LIMIT ? "#{text[0, PREVIEW_LIMIT - 1]}…" : text
|