samagotchi 0.102.0 → 0.103.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/bin/chi +23 -191
- data/docs/architecture.md +54 -0
- data/docs/cli.md +114 -7
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +2 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +26 -2
- data/docs/sessions.md +12 -5
- data/docs/sub-agent.md +8 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +24 -4
- data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +22 -38
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +43 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/engine.rb +12 -8
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +233 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +173 -108
- data/lib/samagotchi/session.rb +30 -5
- data/lib/samagotchi/session_access/contract.rb +129 -0
- data/lib/samagotchi/session_access/dispatch.rb +133 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +349 -0
- data/lib/samagotchi/session_access/remote.rb +638 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
- data/lib/samagotchi/session_access.rb +141 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
- data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
- data/lib/samagotchi/terminal_ui.rb +2 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +360 -69
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -4
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/public/working_set.js +159 -28
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +91 -33
- data/lib/samagotchi/web/session_access_routes.rb +343 -0
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +88 -14
- data/lib/samagotchi/working_set_command.rb +133 -30
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +191 -27
- metadata +19 -1
|
@@ -0,0 +1,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
|
-
|
|
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
|
|
|
@@ -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
|
|
@@ -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,
|
|
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
|