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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. 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
- def initialize(action, argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
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 = Session.resolve_id(given, state_dir: @state_dir)
64
+ id = @access.resolve_id(given)
61
65
  short = id[0, 8]
62
- result = SessionManager.archive_session(id, state_dir: @state_dir)
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 = Session.resolve_id(given, state_dir: @state_dir)
93
+ id = @access.resolve_id(given)
90
94
  short = id[0, 8]
91
- result = SessionManager.unarchive_session(id, state_dir: @state_dir)
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; a drop waits for the turn's end
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 undrop an item (drop KEY, undrop KEY)",
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), so
550
- # a rollback past it would drop it silently (as !cmd), and the working
551
- # set goes out again (held after the command's command_ran).
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
- @turn_flow.note_conversation_changed
557
- @engine.announce_working_set
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, save: @save).run(text.delete_prefix(TITLE_COMMAND))
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: 10)
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
- def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
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 = Session.resolve_id(given, state_dir: @state_dir)
63
+ id = @access.resolve_id(given)
60
64
  short = id[0, 8]
61
65
  preview = preview_of(id)
62
- result = SessionManager.delete_session(id, state_dir: @state_dir, stop: force)
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 = Session.load(id, state_dir: @state_dir).display_title.gsub(/\s+/, " ").strip
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