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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/bin/chi +23 -191
  4. data/docs/architecture.md +54 -0
  5. data/docs/cli.md +114 -7
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +11 -1
  10. data/docs/internals/remote-sessions.md +303 -0
  11. data/docs/internals/working-set.md +95 -11
  12. data/docs/plugins.md +26 -2
  13. data/docs/sessions.md +12 -5
  14. data/docs/sub-agent.md +8 -0
  15. data/lib/samagotchi/answer_command.rb +27 -25
  16. data/lib/samagotchi/bridge.rb +24 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  18. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  19. data/lib/samagotchi/bridge_client.rb +22 -38
  20. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  22. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  24. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  25. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/chi_invocation.rb +43 -0
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/flags.rb +5 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  31. data/lib/samagotchi/context_command.rb +80 -73
  32. data/lib/samagotchi/engine.rb +12 -8
  33. data/lib/samagotchi/idle_recap.rb +7 -1
  34. data/lib/samagotchi/kernel_loop.rb +13 -1
  35. data/lib/samagotchi/launch_mode.rb +22 -0
  36. data/lib/samagotchi/llm_context_forget.rb +13 -6
  37. data/lib/samagotchi/note_command.rb +8 -7
  38. data/lib/samagotchi/parent_report.rb +55 -43
  39. data/lib/samagotchi/remote_command.rb +170 -0
  40. data/lib/samagotchi/remotes.rb +130 -0
  41. data/lib/samagotchi/run_options.rb +233 -0
  42. data/lib/samagotchi/send_command/busy.rb +58 -0
  43. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  44. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  45. data/lib/samagotchi/send_command.rb +173 -108
  46. data/lib/samagotchi/session.rb +30 -5
  47. data/lib/samagotchi/session_access/contract.rb +129 -0
  48. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  49. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  50. data/lib/samagotchi/session_access/local.rb +349 -0
  51. data/lib/samagotchi/session_access/remote.rb +638 -0
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  53. data/lib/samagotchi/session_access.rb +141 -0
  54. data/lib/samagotchi/session_archive_command.rb +9 -5
  55. data/lib/samagotchi/session_commands.rb +20 -15
  56. data/lib/samagotchi/session_delete_command.rb +8 -4
  57. data/lib/samagotchi/session_manager.rb +7 -2
  58. data/lib/samagotchi/session_setup.rb +11 -6
  59. data/lib/samagotchi/sessions_command.rb +36 -14
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
  61. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
  63. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  64. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  65. data/lib/samagotchi/terminal_ui.rb +2 -2
  66. data/lib/samagotchi/title_command.rb +9 -11
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +360 -69
  69. data/lib/samagotchi/web/public/data.js +3 -2
  70. data/lib/samagotchi/web/public/index.html +20 -4
  71. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  72. data/lib/samagotchi/web/public/stage_view.js +3 -2
  73. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  74. data/lib/samagotchi/web/public/working_set.js +159 -28
  75. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  76. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  77. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  78. data/lib/samagotchi/worker_sidecar.rb +12 -5
  79. data/lib/samagotchi/working_set.rb +88 -14
  80. data/lib/samagotchi/working_set_command.rb +133 -30
  81. data/lib/samagotchi/working_set_pin.rb +34 -0
  82. data/lib/samagotchi/working_set_steer.rb +191 -27
  83. metadata +19 -1
@@ -0,0 +1,181 @@
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
+ # chi web answers no live worker for the session (Mapping).
40
+ NO_WORKER = "no live worker for session %s on %s"
41
+
42
+ # chi web's answers as the session's Bridge would have given them.
43
+ module Mapping
44
+ module_function
45
+
46
+ # @param route [Symbol] :turn, :command, :answer, :dismiss,
47
+ # :dequeue, :cancel
48
+ # @param reply [HTTPTransport::Reply]
49
+ # @param session_id [String]
50
+ # @param remote [String] the remote's name
51
+ # @return [BridgeClient::Response] 504 as the Bridge's 408
52
+ # deadline_passed; 501 as its 404 not_found (an older worker),
53
+ # and /command's for one builtin as its 400 unknown_command;
54
+ # the rest unchanged (202 acks, 409s, the route's own 404s,
55
+ # 403 parent_answer_refused, 502 bridge_error)
56
+ # @raise [Unreachable] 503 not_live, or 404 not_found (the session)
57
+ def response(route, reply, session_id:, remote:)
58
+ json = reply.json.is_a?(Hash) ? reply.json : {}
59
+ case [reply.status, json["error"]]
60
+ in [503, "not_live"] | [404, "not_found"] then raise Unreachable, format(NO_WORKER, session_id, remote)
61
+ in [504, _] then bridge(408, error: "deadline_passed", detail: json["detail"])
62
+ in [501, _] if route == :command && json["command"]
63
+ bridge(400, error: "unknown_command", detail: json["detail"], command: json["command"])
64
+ in [501, _] then bridge(404, error: "not_found", detail: json["detail"])
65
+ else BridgeClient::Response.new(status: reply.status, body: reply.body)
66
+ end
67
+ end
68
+
69
+ def bridge(status, **body) = BridgeClient::Response.new(status: status, body: JSON.generate(body.compact))
70
+ end
71
+
72
+ attr_reader :session_id
73
+ # @return [String, nil] the worker followed: its Bridge's epoch (its
74
+ # start time for a worker from before the epoch in its sidecar)
75
+ attr_accessor :worker_key
76
+
77
+ # @param remote [Remote] the remote's chi web
78
+ # @param session_id [String]
79
+ # @param worker_key [String, nil] the live worker's epoch, from the wake
80
+ def initialize(remote:, session_id:, worker_key:)
81
+ @remote = remote
82
+ @session_id = session_id
83
+ @worker_key = worker_key
84
+ end
85
+
86
+ def inspect = "#<#{self.class.name} #{@remote.entry.label} #{@session_id}>"
87
+
88
+ # No Bridge port here: the worker is reached through chi web.
89
+ def port = nil
90
+
91
+ # POST …/turn. history: false (chi web's prompt history is that
92
+ # machine's page's).
93
+ # @return [BridgeClient::Response]
94
+ def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil)
95
+ body = { prompt: prompt, client_id: client_id, history: false }
96
+ body[:no_interrupt] = true if no_interrupt
97
+ body[:images] = images if images && !images.empty?
98
+ body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
99
+ post(:turn, "turn", body)
100
+ end
101
+
102
+ # POST …/command.
103
+ # @return [BridgeClient::Response]
104
+ def post_command(line:, client_id: nil, card: false)
105
+ body = { line: line, client_id: client_id, history: false }
106
+ body[:card] = true if card
107
+ post(:command, "command", body)
108
+ end
109
+
110
+ # POST …/answer (chi web passes client_id on only when it is chi
111
+ # answer's).
112
+ # @return [BridgeClient::Response]
113
+ def answer(id:, selected:, freeform: nil, client_id: nil)
114
+ post(:answer, "answer", { id: id, selected: selected, freeform: freeform, client_id: client_id }.compact,
115
+ read_timeout: ANSWER_READ_TIMEOUT)
116
+ end
117
+
118
+ # POST …/question/dismiss.
119
+ # @return [BridgeClient::Response]
120
+ def dismiss_question(id:)
121
+ post(:dismiss, "question/dismiss", { id: id }, read_timeout: ANSWER_READ_TIMEOUT)
122
+ end
123
+
124
+ # POST …/turn/:enqueued_id/dequeue.
125
+ # @return [BridgeClient::Response]
126
+ def dequeue(enqueued_id:, client_id: nil)
127
+ post(:dequeue, "turn/#{URI.encode_www_form_component(enqueued_id.to_s)}/dequeue", { client_id: client_id }.compact)
128
+ end
129
+
130
+ # POST …/cancel.
131
+ # @return [BridgeClient::Response]
132
+ def cancel(reason:)
133
+ post(:cancel, "cancel", { reason: reason })
134
+ end
135
+
136
+ # Follow the session's events through chi web (see EventStream): with
137
+ # epoch_watch, announce_retries and STREAM_IDLE_TIMEOUT.
138
+ # @return [BridgeClient::EventStream] started
139
+ def follow(snapshot: true, client_id: nil, reconnect_delays: BridgeClient::EventStream::DEFAULT_RECONNECT_DELAYS,
140
+ rediscover: nil, &on_event)
141
+ BridgeClient::EventStream.new(self, snapshot: snapshot, client_id: client_id, reconnect_delays: reconnect_delays,
142
+ rediscover: rediscover, epoch_watch: true, announce_retries: true,
143
+ idle_timeout: STREAM_IDLE_TIMEOUT, &on_event).start
144
+ end
145
+
146
+ # GET /api/sessions/:id/stream through StreamRequest, with the token
147
+ # as Bearer. A chunked response (a proxy in between: no decoding in
148
+ # v1) and an https chi web (no TLS on the stream yet) are logged and
149
+ # read as unreachable.
150
+ # @return [Array(TCPSocket, Integer, Hash), nil] see StreamRequest.open
151
+ def connect_stream(query: "", last_event_id: nil, timeout: nil)
152
+ uri = URI(@remote.entry.url)
153
+ return refused("tls_unsupported") if uri.scheme == "https"
154
+
155
+ token = @remote.entry.token
156
+ opened = BridgeClient::StreamRequest.open(host: uri.host, port: uri.port,
157
+ path: "/api/sessions/#{@session_id}/stream#{query}",
158
+ last_event_id: last_event_id, timeout: timeout,
159
+ headers: token ? { "Authorization" => "Bearer #{token}" } : {})
160
+ return opened unless opened && opened[2]["transfer-encoding"].to_s.downcase.include?("chunked")
161
+
162
+ opened[0].close rescue nil
163
+ refused("chunked_unsupported")
164
+ end
165
+
166
+ private
167
+
168
+ def post(route, path, body, read_timeout: READ_TIMEOUT)
169
+ reply = @remote.route_request(:post, "/api/sessions/#{@session_id}/#{path}", body: body, read_timeout: read_timeout)
170
+ Mapping.response(route, reply, session_id: @session_id, remote: @remote.name)
171
+ rescue RemoteFailed => e
172
+ raise Unreachable, e.message
173
+ end
174
+
175
+ def refused(reason)
176
+ Log.warn(:attached, "stream_refused", reason: reason, remote: @remote.name)
177
+ nil
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,141 @@
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 a request for a reason this machine checks
32
+ # itself before sending (a parent's /continue yes, a loosening
33
+ # /parent-answers, images to a model that can't see them): +message+
34
+ # is the server's text, the words chi send uses on its own machine.
35
+ # +code+ is chi web's error code.
36
+ class Refused < StandardError
37
+ attr_reader :code
38
+
39
+ def initialize(message, code:)
40
+ @code = code
41
+ super(message)
42
+ end
43
+ end
44
+
45
+ # A remote's chi web refused a request's values as a usage error would
46
+ # on this machine (a bad --set, --toolkit or --dir there): chi send
47
+ # exits 2 with its message.
48
+ class BadRequest < Refused; end
49
+
50
+ # A session to create (chi send --new): a new one, or with +continues+
51
+ # the next link of that session's chain (which brings its own folder,
52
+ # LLM context, thinking, toolkit and settings: the rest is nil then).
53
+ # +preview+ set: the session starts idle with it as its preview, and
54
+ # +prompt+ (nil) goes in later as a turn (images, a session command).
55
+ # +setup+ is a SessionSetup for Local, a SetupWords for Remote (the
56
+ # flags as typed: the server checks them against its own config and
57
+ # bundles). +client_id+ (chi send's, or chi answer's for a parent's
58
+ # /continue yes) and +images+ (images follow) are for a Remote's server,
59
+ # which checks the first line and the model as chi send checks them
60
+ # here (Local checks before it creates).
61
+ NewSession = Data.define(:prompt, :preview, :continues, :working_directory, :model_name, :setup, :client_id,
62
+ :images) do
63
+ def initialize(prompt:, preview: nil, continues: nil, working_directory: nil, model_name: nil, setup: nil,
64
+ client_id: nil, images: false) = super
65
+ end
66
+
67
+ # chi send --new's setup flags as typed, for a Remote: +llm_context+
68
+ # {strategy:, apply:, budget_tokens:} words, +thinking+ and
69
+ # +parent_answers+ level words, +toolkit+ a name, +tools+ and
70
+ # +no_tools+ names, +settings+ {"KEY" => "VALUE"} as --set gave them.
71
+ # The server reads each as its create route does (POST /api/sessions).
72
+ SetupWords = Data.define(:llm_context, :thinking, :toolkit, :tools, :no_tools, :settings, :parent_answers) do
73
+ def initialize(llm_context: nil, thinking: nil, toolkit: nil, tools: [], no_tools: [], settings: nil,
74
+ parent_answers: nil) = super
75
+ end
76
+
77
+ # A session as a session command reads it (Local: the Session itself;
78
+ # Remote: chi web's brief read). The fields the commands use, and
79
+ # Session's #display_title.
80
+ # @!attribute owner [OwnerLock::Owner, nil] its worker or chi REPL
81
+ # @!attribute pending_question [PendingQuestion, nil] the file's
82
+ SessionView = Data.define(:id, :status, :title, :display_title, :project_root, :working_directory, :model_name,
83
+ :model_typed, :parent_answers, :owner, :pending_question)
84
+
85
+ # What chi send --new created (Remote; Local gives the Session): its
86
+ # id, a model warning, the session it continues, the delegates it took
87
+ # over, and the server's word on a --parent-answers looser than its
88
+ # config.yml's (capped).
89
+ Created = Data.define(:id, :model_warning, :continues, :moved_children, :parent_answers_warning)
90
+
91
+ # A session's worker as an attach finds it (Local#worker, chi web's GET
92
+ # …/worker): +sidecar+ the live worker's WorkerSidecar (port, version,
93
+ # features, epoch; nil: none runs), +stopped+ its stop marker, +owner+
94
+ # its OwnerLock::Owner (a worker, a chi REPL, nil).
95
+ WorkerState = Data.define(:sidecar, :stopped, :owner) do
96
+ def live? = !sidecar.nil?
97
+ end
98
+
99
+ # What a wake for an attach did (Local#wake, chi web's POST …/wake):
100
+ # +woke+ it started the worker (false: one ran), +sidecar+ the live
101
+ # worker's (nil: its Bridge didn't come within the wait).
102
+ Woken = Data.define(:woke, :sidecar)
103
+
104
+ # What chi send --wait and chi answer read before anything is sent, so
105
+ # neither an older reply nor a turn that ends before the first look is
106
+ # taken for the answer (ReplyWait's cursor:, baseline:, turn:).
107
+ # @!attribute cursor [String, nil] the newest reply file already there
108
+ # @!attribute baseline [ReplyWait::Baseline] the session as it was
109
+ # @!attribute own_turn [String, nil] ReplyWait.own_turn: the turn
110
+ # running now, or the one whose continue offer stands
111
+ # @!attribute known_turn [Boolean, nil] with a turn asked for
112
+ # (--turn), ReplyWait.known_turn?; nil without one
113
+ # @!attribute running [Boolean] the session's status was running
114
+ # @!attribute live [Boolean] running, or a worker or a REPL owns it
115
+ # @!attribute settled [Boolean] idle, a turn ended and no question
116
+ # stands (a continue offer waits for its answer)
117
+ # @!attribute last_turn_id [String, nil] the last ended turn's
118
+ # enqueued_id
119
+ WaitStart = Data.define(:cursor, :baseline, :own_turn, :known_turn, :running, :live, :settled, :last_turn_id)
120
+
121
+ # One attached context source as a session (or a project) sees it:
122
+ # its Source and Snapshot (ContextSources), and for a session's list
123
+ # whether it mutes it, a session source shadows it, and its text is
124
+ # unread (nil for a project's, or where not asked).
125
+ ContextEntry = Data.define(:source, :snapshot, :muted, :shadowed, :unread) do
126
+ def initialize(source:, snapshot:, muted: nil, shadowed: nil, unread: nil) = super
127
+ end
128
+
129
+ # chi context push's outcome: :pushed (+before+ the revision it had,
130
+ # +revision+ the new one), :missing (no such source) or :not_push (a
131
+ # command source, whose text is its command's).
132
+ ContextPush = Data.define(:status, :before, :revision) do
133
+ def initialize(status:, before: nil, revision: nil) = super
134
+
135
+ # Why a push to +name+ was refused as :not_push: a command source's
136
+ # text is its command's, and a push between two fetches would read
137
+ # as a change and then vanish.
138
+ def self.not_push_message(name) = "#{name} runs a command, which writes its text: push goes to a --push source"
139
+ end
140
+ end
141
+ 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
 
@@ -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
@@ -1199,6 +1199,9 @@ module Samagotchi
1199
1199
  # @param images [Array<Hash>] refs ({file:, name:}) already in images/
1200
1200
  # @param delivery [String, nil] what the message asks the running turn
1201
1201
  # for: "next_step" (default), "cut" or "queue"
1202
+ # @param no_interrupt [Boolean] the turn runs with the raised iteration
1203
+ # limit (an attached TUI's --no-interrupt), through the Bridge or the
1204
+ # file; sent only when true, so older callees see no new field
1202
1205
  # @param manager [#resume_session, #write_turn_input] this class, or a
1203
1206
  # stand-in (the web's specs); #session_owner is optional
1204
1207
  # @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
@@ -1211,8 +1214,8 @@ module Samagotchi
1211
1214
  # :failed when the input file couldn't be written
1212
1215
  # @raise [OwnedByTUI] a chi REPL owns the session
1213
1216
  # @raise [ArgumentError] no such session
1214
- def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
1215
- bridge: nil)
1217
+ def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
1218
+ state_dir: nil, manager: self, bridge: nil)
1216
1219
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1217
1220
  bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
1218
1221
  manager.resume_session(session_id, state_dir: state_dir)
@@ -1226,6 +1229,7 @@ module Samagotchi
1226
1229
  options[:images] = images unless images.empty?
1227
1230
  # An old worker ignores the field: it cuts anyway (its own default).
1228
1231
  options[:delivery] = delivery unless Delivery.next_step?(delivery)
1232
+ options[:no_interrupt] = true if no_interrupt
1229
1233
  reply = client.post_turn(**options)
1230
1234
  ack = reply.json
1231
1235
  return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
@@ -1252,6 +1256,7 @@ module Samagotchi
1252
1256
  input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
1253
1257
  input[:images] = images unless images.empty?
1254
1258
  input[:delivery] = delivery unless Delivery.next_step?(delivery)
1259
+ input[:no_interrupt] = true if no_interrupt
1255
1260
  path = manager.write_turn_input(session_id, **input)
1256
1261
  return DeliverResult.new(status: :failed) unless path
1257
1262
 
@@ -5,6 +5,7 @@ require_relative "reasoning_history"
5
5
  require_relative "thinking"
6
6
  require_relative "toolkit"
7
7
  require_relative "session_config"
8
+ require_relative "working_set_pin"
8
9
 
9
10
  module Samagotchi
10
11
  # A session's own settings that come before its model's, carried as one
@@ -20,24 +21,27 @@ module Samagotchi
20
21
  # settings chi --set gave (SessionConfig::Settings; its bundles part is
21
22
  # what Plugin::Context#settings lays over config.yml's),
22
23
  # +parent_answers+ the session's own guardrails.parent_answers (a level
23
- # String, tighten-only: Guardrails::ParentPolicy.effective) or nil.
24
+ # String, tighten-only: Guardrails::ParentPolicy.effective) or nil,
25
+ # +working_set_pins+ the items the user pinned in the working set
26
+ # (WorkingSetPin; [] with none).
24
27
  #
25
28
  # It doesn't save itself: the session keeps each setting in its own field
26
29
  # and file key, and resolves it on its own. A delegate child starts
27
30
  # without one (but the toolkit its delegate call named, and its parent's
28
31
  # parent_answers); a continue and a fork copy their session's (.of).
29
32
  SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
30
- :settings, :parent_answers) do
33
+ :settings, :parent_answers, :working_set_pins) do
31
34
  def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
32
- settings: nil, parent_answers: nil)
35
+ settings: nil, parent_answers: nil, working_set_pins: [])
33
36
  super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
34
37
  appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
35
- settings: SessionConfig::Settings.of(settings), parent_answers: parent_answers&.to_s)
38
+ settings: SessionConfig::Settings.of(settings), parent_answers: parent_answers&.to_s,
39
+ working_set_pins: WorkingSetPin.list(working_set_pins))
36
40
  end
37
41
 
38
42
  def empty?
39
43
  llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
40
- settings.empty? && parent_answers.nil?
44
+ settings.empty? && parent_answers.nil? && working_set_pins.empty?
41
45
  end
42
46
 
43
47
  # A session's own setup, for a session that starts from it. Its
@@ -50,7 +54,8 @@ module Samagotchi
50
54
 
51
55
  new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
52
56
  appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
53
- settings: session.settings, parent_answers: session.parent_answers)
57
+ settings: session.settings, parent_answers: session.parent_answers,
58
+ working_set_pins: session.working_set_pins)
54
59
  end
55
60
  end
56
61
  end