samagotchi 0.103.0 → 0.104.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -24
- data/docs/cli.md +86 -12
- data/docs/guardrails.md +2 -1
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +4 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +153 -27
- data/docs/plugins.md +7 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/bridge.rb +7 -3
- data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +13 -16
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +16 -6
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +8 -1
- data/lib/samagotchi/launch_mode.rb +4 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/run_options.rb +14 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +8 -4
- data/lib/samagotchi/session_access/contract.rb +42 -4
- data/lib/samagotchi/session_access/dispatch.rb +29 -10
- data/lib/samagotchi/session_access/local.rb +52 -1
- data/lib/samagotchi/session_access/remote.rb +112 -16
- data/lib/samagotchi/session_access/web_bridge_client.rb +56 -1
- data/lib/samagotchi/session_access.rb +85 -3
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +21 -5
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
- data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +83 -20
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
- data/lib/samagotchi/terminal_ui.rb +4 -1
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -74
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +120 -4
- data/lib/samagotchi/worker.rb +26 -5
- metadata +10 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "session_access"
|
|
5
|
+
require_relative "session_access/remote"
|
|
6
|
+
require_relative "launch_mode"
|
|
7
|
+
require_relative "model_profile"
|
|
8
|
+
require_relative "cli/exit"
|
|
9
|
+
require_relative "terminal_ui"
|
|
10
|
+
require_relative "terminal_ui/attach_launcher"
|
|
11
|
+
require_relative "terminal_ui/remote_connector"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT]: the
|
|
15
|
+
# attached TUI on a session of another machine, through its chi web
|
|
16
|
+
# (bin/chi branches here after RunOptions.parse, before its own checks,
|
|
17
|
+
# which are this machine's). The run flags come parsed; the ones that
|
|
18
|
+
# run this machine's REPL or read its files were refused already
|
|
19
|
+
# (SessionAccess::Contract::RUN). The setup flags go as typed: that
|
|
20
|
+
# machine checks them against its own config and bundles.
|
|
21
|
+
class RemoteAttachCommand
|
|
22
|
+
# @param options [Hash] RunOptions.parse's
|
|
23
|
+
# @param access [SessionAccess::Remote]
|
|
24
|
+
def initialize(options, access:, stderr: $stderr, launcher: TerminalUI::AttachLauncher,
|
|
25
|
+
connector: TerminalUI::RemoteConnector)
|
|
26
|
+
@options = options
|
|
27
|
+
@access = access
|
|
28
|
+
@stderr = stderr
|
|
29
|
+
@launcher = launcher
|
|
30
|
+
@connector = connector
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Integer] the exit status: AttachLauncher.exit_status's, 1 a
|
|
34
|
+
# launch that failed, 2 a usage error, 130 a Ctrl-C before the TUI
|
|
35
|
+
# ran (the wake, a wait for a leaving worker there)
|
|
36
|
+
def run
|
|
37
|
+
if (conflict = LaunchMode.attached_conflict(@options))
|
|
38
|
+
return failed(conflict)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
existing = @options[:attach] || @options[:resume]
|
|
42
|
+
return usage(new_needs_dir) if !existing && !absolute_dir?
|
|
43
|
+
|
|
44
|
+
@access.require_feature!(SessionAccess::Remote::ATTACH_FEATURE)
|
|
45
|
+
id = existing ? @access.resolve(existing) : create
|
|
46
|
+
return id if id.is_a?(Integer)
|
|
47
|
+
|
|
48
|
+
ignored(id) if existing
|
|
49
|
+
launch(id, existing: existing)
|
|
50
|
+
rescue TerminalUI::AttachLauncher::Error, SessionAccess::RemoteFailed, SessionAccess::Refused,
|
|
51
|
+
SessionManager::OwnedByTUI, ModelProfile::MissingModel, ArgumentError => e
|
|
52
|
+
e.is_a?(SessionAccess::BadRequest) ? usage(e.message) : failed(e.message)
|
|
53
|
+
rescue Interrupt
|
|
54
|
+
CLI::Exit::INTERRUPTED
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def launch(id, existing:)
|
|
60
|
+
model = @options[:model]
|
|
61
|
+
ended = @launcher.run(attach: (id if @options[:attach]), resume: (id if @options[:resume]), prompt: @options[:prompt],
|
|
62
|
+
model: (model if existing), no_interrupt: !!@options[:no_interrupt],
|
|
63
|
+
default_input: !@options[:no_default_input],
|
|
64
|
+
llm_context: (existing && @options[:llm_context]) || {},
|
|
65
|
+
thinking: (@options[:thinking] if existing),
|
|
66
|
+
connector: @connector.new(@access, session_id: id))
|
|
67
|
+
@launcher.exit_status(ended)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A new session there, idle (its -p goes in once attached), as chi send
|
|
71
|
+
# --new makes one: the create's warnings printed as it prints them.
|
|
72
|
+
# @return [String, Integer] its id, or the exit status of a refusal
|
|
73
|
+
def create
|
|
74
|
+
settings = Array(@options[:sets]).to_h do |pair|
|
|
75
|
+
key, sep, value = pair.partition("=")
|
|
76
|
+
return usage("--set takes KEY=VALUE, not #{pair}") if sep.empty? || key.strip.empty?
|
|
77
|
+
|
|
78
|
+
[key.strip, value]
|
|
79
|
+
end
|
|
80
|
+
llm = @options[:llm_context] || {}
|
|
81
|
+
words = SessionAccess::SetupWords.new(
|
|
82
|
+
llm_context: (llm unless llm.empty?), thinking: @options[:thinking]&.to_s, toolkit: @options[:toolkit],
|
|
83
|
+
tools: Array(@options[:tools]), no_tools: Array(@options[:no_tools]),
|
|
84
|
+
settings: (settings unless settings.empty?), parent_answers: @options[:parent_answers]
|
|
85
|
+
)
|
|
86
|
+
created = @access.create(SessionAccess::NewSession.new(prompt: nil, idle: true, working_directory: @options[:dir],
|
|
87
|
+
model_name: @options[:model], setup: words,
|
|
88
|
+
client_id: @connector.client_id))
|
|
89
|
+
@stderr.puts("chi: warning: #{created.model_warning}") if created.model_warning
|
|
90
|
+
@stderr.puts("chi: warning: --parent-answers #{created.parent_answers_warning}") if created.parent_answers_warning
|
|
91
|
+
created.id
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# An existing session keeps its own setup: the line says which flags
|
|
95
|
+
# went unused, as chi --attach here says it.
|
|
96
|
+
def ignored(id)
|
|
97
|
+
flags = [("--dir" if @options[:dir]), ("--toolkit" if @options[:toolkit]), ("--tool" if @options[:tools]),
|
|
98
|
+
("--no-tool" if @options[:no_tools]), ("--set" if @options[:sets]),
|
|
99
|
+
("--parent-answers" if @options[:parent_answers])].compact
|
|
100
|
+
TerminalUI::AttachLauncher.warn_memory_flags_ignored(id, [], [], @stderr, toolkit_flags: flags)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def absolute_dir? = @options[:dir].to_s.start_with?("/")
|
|
104
|
+
|
|
105
|
+
def new_needs_dir
|
|
106
|
+
"a new session on #{@access.name} needs --dir, that machine's absolute path (or --attach ID / --resume ID)"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def failed(message)
|
|
110
|
+
@stderr.puts("Error: #{message}")
|
|
111
|
+
CLI::Exit::FAILED
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def usage(message)
|
|
115
|
+
@stderr.puts("Error: #{message}")
|
|
116
|
+
CLI::Exit::USAGE
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -14,7 +14,7 @@ module Samagotchi
|
|
|
14
14
|
module RunOptions
|
|
15
15
|
# The flags an underscored spelling is refused for (--recap_base_url),
|
|
16
16
|
# with the config registry's.
|
|
17
|
-
KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token].freeze
|
|
17
|
+
KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token].freeze
|
|
18
18
|
|
|
19
19
|
module_function
|
|
20
20
|
|
|
@@ -107,6 +107,14 @@ module Samagotchi
|
|
|
107
107
|
[options, cli_overrides]
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# The switches that take a value (--model NAME, -p TEXT), long and
|
|
111
|
+
# short: a word after one is its value, not a flag.
|
|
112
|
+
# @return [Array<String>]
|
|
113
|
+
def value_switches
|
|
114
|
+
parser_switches(build_parser({}, {})).select { |switch| switch.is_a?(OptionParser::Switch::RequiredArgument) }
|
|
115
|
+
.flat_map { |switch| switch.long + switch.short }.push("--no-tool").uniq
|
|
116
|
+
end
|
|
117
|
+
|
|
110
118
|
# Every switch the parser takes, long and short, a --[no-]x as --x and
|
|
111
119
|
# --no-x (--no-tool too, read before the parse), for what classifies the
|
|
112
120
|
# run flags.
|
|
@@ -138,12 +146,14 @@ module Samagotchi
|
|
|
138
146
|
chi context <add|push|ls|show|refresh|rm|mute|unmute> live external text attached to sessions
|
|
139
147
|
chi send [-m TEXT] ID...|--new a message into sessions, as if typed there
|
|
140
148
|
chi answer ID --question QID --option N answer a question a session waits on
|
|
149
|
+
chi share ID --name NAME | ls [ID] | revoke ID [P]|--all participants who review a session
|
|
141
150
|
chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
|
|
142
151
|
chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
|
|
143
152
|
chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
|
|
144
153
|
chi models [--format json] [TEXT] the models --model takes, from every host
|
|
145
154
|
chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
|
|
146
|
-
chi --remote NAME <send|answer|note|context|sessions> … those commands on that machine (or CHI_REMOTE=NAME)
|
|
155
|
+
chi --remote NAME <send|answer|note|context|sessions|share> … those commands on that machine (or CHI_REMOTE=NAME)
|
|
156
|
+
chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT] a session on that machine, in this terminal
|
|
147
157
|
chi self version, paths, model and bundles
|
|
148
158
|
chi update [--dry-run] update this chi, its bundles and the desktop helper
|
|
149
159
|
(each subcommand takes --help)
|
|
@@ -152,6 +162,8 @@ module Samagotchi
|
|
|
152
162
|
opts.on("-p", "--prompt PROMPT", "Run PROMPT as the first turn, then stay in the REPL") { |p| options[:prompt] = p }
|
|
153
163
|
opts.on("--resume SESSION_ID", "Resume a previous session by its ID (or a unique prefix of it)") { |s| options[:resume] = s }
|
|
154
164
|
opts.on("--attach SESSION_ID", "Attach to a session's worker, waking one if it has exited; ID or a unique prefix") { |s| options[:attach] = s }
|
|
165
|
+
opts.on("--dir DIR", "With --remote: a new session's folder on that machine, its absolute path (required there; " \
|
|
166
|
+
"here cd instead)") { |d| options[:dir] = d }
|
|
155
167
|
opts.on("--[no-]shared", "Run the session in a background worker and attach to it, so `chi web` can share it (the default, config session.shared; with --resume: that session); --no-shared runs a plain in-process REPL") { |v| options[:shared] = v }
|
|
156
168
|
opts.on("--model NAME", "Model for this run, not saved (overrides --resume's; config default.model) [env SAMAGOTCHI_DEFAULT_MODEL]") { |m| options[:model] = m; cli_overrides["default.model"] = m }
|
|
157
169
|
opts.on("--profile NAME", Samagotchi::ModelProfile::NAMES,
|
|
@@ -646,7 +646,7 @@ module Samagotchi
|
|
|
646
646
|
# its preview; the user can attach and send it again.
|
|
647
647
|
unless result
|
|
648
648
|
attach = @access.invocation.attach(session.id)
|
|
649
|
-
@info.puts("#{session.id} failed: its worker did not start; the session is kept
|
|
649
|
+
@info.puts("#{session.id} failed: its worker did not start; the session is kept (#{attach})")
|
|
650
650
|
return false
|
|
651
651
|
end
|
|
652
652
|
unless result.accepted?
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -395,10 +395,12 @@ module Samagotchi
|
|
|
395
395
|
# @param messages [Array<Hash>] a conversation to start from (a fork's
|
|
396
396
|
# seed); [] by default
|
|
397
397
|
# @param setup [SessionSetup] the session's own settings (none by default)
|
|
398
|
+
# @param now [Time] its created_at and updated_at (a spec's clock)
|
|
398
399
|
def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
|
|
399
400
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
|
|
400
|
-
scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil
|
|
401
|
-
|
|
401
|
+
scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil,
|
|
402
|
+
now: Time.now)
|
|
403
|
+
now = now.iso8601(3)
|
|
402
404
|
resolved_test = if test_run.nil?
|
|
403
405
|
test_session_env?
|
|
404
406
|
else
|
|
@@ -614,9 +616,11 @@ module Samagotchi
|
|
|
614
616
|
# Never call it from a trap handler (Mutex#lock raises ThreadError
|
|
615
617
|
# there; WorkerSignals saves on a thread of its own) or from inside a
|
|
616
618
|
# save (the lock isn't re-entrant).
|
|
617
|
-
|
|
619
|
+
# @param now [Time, nil] the updated_at to stamp (a spec's clock); nil,
|
|
620
|
+
# the time the lock is taken
|
|
621
|
+
def save(state_dir: self.class.default_state_dir, now: nil)
|
|
618
622
|
@save_lock.synchronize do
|
|
619
|
-
@updated_at = Time.now.iso8601(3)
|
|
623
|
+
@updated_at = (now || Time.now).iso8601(3)
|
|
620
624
|
# Persist with current metadata version so new flag is written
|
|
621
625
|
@metadata_version = METADATA_VERSION
|
|
622
626
|
FileUtils.mkdir_p(state_dir)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module SessionAccess
|
|
5
7
|
# Which session commands and flags run against another machine's
|
|
@@ -7,13 +9,16 @@ module Samagotchi
|
|
|
7
9
|
# and which run on this machine only (refused under a remote, exit 2:
|
|
8
10
|
# "chi <cmd> runs on this machine only: run it on <remote>"). The v1 scope is the remote-sessions plan's
|
|
9
11
|
# D3: send, answer, note, a session's context (add, push, ls, show, rm)
|
|
10
|
-
# and sessions list/stop/restart/archive/unarchive/delete
|
|
12
|
+
# and sessions list/stop/restart/archive/unarchive/delete, and chi share
|
|
13
|
+
# (the shared-sessions plan's P2); the rest
|
|
11
14
|
# stays local. bin/chi's subcommands that act on the machine, not on
|
|
12
|
-
# sessions, are MACHINE. The run flags (--attach, -p, --resume
|
|
13
|
-
#
|
|
15
|
+
# sessions, are MACHINE. The run flags (chi --attach, -p, --resume: a
|
|
16
|
+
# session in this terminal, a remote attach there) are RUN's.
|
|
14
17
|
#
|
|
15
18
|
# spec/session_access_contract_spec.rb fails when a subcommand, one of
|
|
16
|
-
# its subcommands or one of a supported one's flags is in neither
|
|
19
|
+
# its subcommands or one of a supported one's flags is in neither, or
|
|
20
|
+
# a run flag (RunOptions.switches) is in neither RUN list and isn't a
|
|
21
|
+
# config registry flag.
|
|
17
22
|
module Contract
|
|
18
23
|
SUPPORTED = :supported
|
|
19
24
|
LOCAL_ONLY = :local_only
|
|
@@ -111,11 +116,44 @@ module Samagotchi
|
|
|
111
116
|
"sessions clean" => LOCAL_ONLY,
|
|
112
117
|
"sessions stats" => LOCAL_ONLY,
|
|
113
118
|
|
|
119
|
+
# Participants (docs/internals/participants.md): the remote's link,
|
|
120
|
+
# through its chi web's participants routes.
|
|
121
|
+
"share" => SUPPORTED,
|
|
122
|
+
"share --name" => SUPPORTED,
|
|
123
|
+
"share --agent" => SUPPORTED,
|
|
124
|
+
"share --expires" => SUPPORTED,
|
|
125
|
+
"share ls" => SUPPORTED,
|
|
126
|
+
"share ls --format" => SUPPORTED,
|
|
127
|
+
"share revoke" => SUPPORTED,
|
|
128
|
+
"share revoke --all" => SUPPORTED,
|
|
129
|
+
|
|
114
130
|
"broadcast" => LOCAL_ONLY
|
|
115
131
|
}.freeze
|
|
116
132
|
|
|
133
|
+
# bin/chi's run flags under a remote (chi --remote NAME --attach ID,
|
|
134
|
+
# RemoteAttachCommand): what a remote attach takes, and what runs
|
|
135
|
+
# this machine's REPL or reads this machine's files (P4-D2..D4). A
|
|
136
|
+
# config registry flag (--recap-base-url …) is this machine's
|
|
137
|
+
# config: LOCAL_ONLY without being listed.
|
|
138
|
+
RUN = {
|
|
139
|
+
SUPPORTED => %w[--attach --resume -p --prompt --shared --model --thinking --llm-context --llm-context-apply
|
|
140
|
+
--llm-context-budget --toolkit --tool --no-tool --set --parent-answers --no-interrupt
|
|
141
|
+
--no-default-input --dir --version -h --help],
|
|
142
|
+
LOCAL_ONLY => %w[--no-shared --non-interactive -v --verbose --append-system-prompt --memory --mute --profile
|
|
143
|
+
--port --open --new-token --scope --web-markdown --no-web-markdown]
|
|
144
|
+
}.freeze
|
|
145
|
+
# A short run flag's name in a refusal.
|
|
146
|
+
RUN_WORDS = { "-v" => "--verbose" }.freeze
|
|
147
|
+
|
|
117
148
|
module_function
|
|
118
149
|
|
|
150
|
+
# @param flag [String] a run flag as RunOptions.switches lists it
|
|
151
|
+
# @return [Symbol, nil] SUPPORTED or LOCAL_ONLY; nil: not a run flag
|
|
152
|
+
def run_status(flag)
|
|
153
|
+
RUN.each { |status, flags| return status if flags.include?(flag) }
|
|
154
|
+
LOCAL_ONLY if Config.cli_entries.any? { |entry| [entry.cli_flag, entry.cli_flag.sub("--", "--no-")].include?(flag) }
|
|
155
|
+
end
|
|
156
|
+
|
|
119
157
|
# @param words [Array<String>] a command, maybe its subcommand, maybe
|
|
120
158
|
# a flag's long name: status("context", "add", "--cmd")
|
|
121
159
|
# @return [Symbol, nil] SUPPORTED, LOCAL_ONLY, or nil when not listed
|
|
@@ -21,10 +21,8 @@ module Samagotchi
|
|
|
21
21
|
# bin/chi's own words besides its SUBCOMMANDS (a refusal names them).
|
|
22
22
|
OWN_WORDS = %w[web scratch self].freeze
|
|
23
23
|
HELP = %w[--version -h --help].freeze
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
RUN_FLAGS = { "--attach" => "--attach", "--resume" => "--resume", "-p" => "-p", "--prompt" => "-p",
|
|
27
|
-
"--shared" => "--shared", "--no-shared" => "--no-shared" }.freeze
|
|
24
|
+
# --non-interactive's refusal: what runs one turn there instead.
|
|
25
|
+
ONE_TURN = "(%s send --new --dir D -m TEXT --wait runs one turn there)"
|
|
28
26
|
|
|
29
27
|
# A usage error in the flag itself (--remote with no name, a bad one).
|
|
30
28
|
class Usage < StandardError; end
|
|
@@ -54,6 +52,14 @@ module Samagotchi
|
|
|
54
52
|
|
|
55
53
|
def remote? = !@remote.nil?
|
|
56
54
|
|
|
55
|
+
# Whether the arguments are run flags, no subcommand: a session in
|
|
56
|
+
# this terminal (bin/chi's RunOptions parse them; under a remote,
|
|
57
|
+
# RemoteAttachCommand runs them).
|
|
58
|
+
def run_flags?
|
|
59
|
+
command = @argv.first
|
|
60
|
+
command.nil? || command.start_with?("-")
|
|
61
|
+
end
|
|
62
|
+
|
|
57
63
|
# Whether the arguments run here as they would without a remote: chi
|
|
58
64
|
# remote, --version, --help.
|
|
59
65
|
def here?
|
|
@@ -67,7 +73,8 @@ module Samagotchi
|
|
|
67
73
|
def refusal
|
|
68
74
|
words = local_words or return nil
|
|
69
75
|
|
|
70
|
-
"chi #{words} runs on this machine only: run it on #{@remote}"
|
|
76
|
+
line = "chi #{words} runs on this machine only: run it on #{@remote}"
|
|
77
|
+
words == "--non-interactive" ? "#{line} #{format(ONE_TURN, "chi --remote #{@remote}")}" : line
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
# The session command against the remote: refused (exit 2), or run
|
|
@@ -101,11 +108,11 @@ module Samagotchi
|
|
|
101
108
|
private
|
|
102
109
|
|
|
103
110
|
# The words that run here only: the command (and its subcommand, a
|
|
104
|
-
# flag) the Contract lists local_only, a machine command, or
|
|
105
|
-
#
|
|
111
|
+
# flag) the Contract lists local_only, a machine command, or a run
|
|
112
|
+
# flag Contract::RUN lists local_only. nil: supported.
|
|
106
113
|
def local_words
|
|
107
114
|
command = @argv.first
|
|
108
|
-
return run_words if
|
|
115
|
+
return run_words if run_flags?
|
|
109
116
|
return command if Contract::MACHINE.include?(command) || Contract.status(command).nil?
|
|
110
117
|
return command if Contract.status(command) == Contract::LOCAL_ONLY
|
|
111
118
|
|
|
@@ -124,9 +131,21 @@ module Samagotchi
|
|
|
124
131
|
@argv.drop(1).take_while { |arg| arg != "--" }.select { |arg| arg.start_with?("--") }.map { |arg| arg.split("=", 2).first }
|
|
125
132
|
end
|
|
126
133
|
|
|
134
|
+
# The first local-only run flag, a value (-p TEXT, --model NAME)
|
|
135
|
+
# never read as one. An unknown flag is the parser's to refuse.
|
|
127
136
|
def run_words
|
|
128
|
-
|
|
129
|
-
|
|
137
|
+
require_relative "../run_options"
|
|
138
|
+
values = RunOptions.value_switches
|
|
139
|
+
words = @argv.take_while { |arg| arg != "--" }
|
|
140
|
+
index = 0
|
|
141
|
+
while index < words.size
|
|
142
|
+
arg = words[index]
|
|
143
|
+
flag = arg.start_with?("--") ? arg.split("=", 2).first : arg[0, 2]
|
|
144
|
+
return Contract::RUN_WORDS.fetch(flag, flag) if arg.start_with?("-") && Contract.run_status(flag) == Contract::LOCAL_ONLY
|
|
145
|
+
|
|
146
|
+
index += values.include?(arg) ? 2 : 1
|
|
147
|
+
end
|
|
148
|
+
nil
|
|
130
149
|
end
|
|
131
150
|
end
|
|
132
151
|
end
|
|
@@ -4,6 +4,7 @@ require_relative "../session"
|
|
|
4
4
|
require_relative "../session_access"
|
|
5
5
|
require_relative "../reply_wait"
|
|
6
6
|
require_relative "../bridge_client"
|
|
7
|
+
require_relative "../owner_lock"
|
|
7
8
|
require_relative "../parent_report"
|
|
8
9
|
require_relative "../note_delivery"
|
|
9
10
|
require_relative "../image_store"
|
|
@@ -12,6 +13,10 @@ require_relative "../send_command/busy"
|
|
|
12
13
|
require_relative "../chi_invocation"
|
|
13
14
|
require_relative "../worker_sidecar"
|
|
14
15
|
require_relative "../launch_mode"
|
|
16
|
+
require_relative "../participants"
|
|
17
|
+
require_relative "../config"
|
|
18
|
+
require_relative "../web/grants"
|
|
19
|
+
require_relative "../web/info_probe"
|
|
15
20
|
|
|
16
21
|
module Samagotchi
|
|
17
22
|
# Loaded on first use: SessionManager and its ~80 files only when a
|
|
@@ -69,7 +74,10 @@ module Samagotchi
|
|
|
69
74
|
|
|
70
75
|
# The session's worker for an attach: the live one as is, else
|
|
71
76
|
# resumed and waited for up to +wait+ seconds (chi --attach on this
|
|
72
|
-
# machine; chi web's POST …/wake for a remote one).
|
|
77
|
+
# machine; chi web's POST …/wake for a remote one). A worker still
|
|
78
|
+
# leaving (its Bridge closed, its recap on leave being written) is
|
|
79
|
+
# waited for first (OwnerLock.await_release: its recap's timeout and
|
|
80
|
+
# a margin), so the resume finds the session free.
|
|
73
81
|
# @return [Woken] its sidecar nil when the Bridge didn't come in time
|
|
74
82
|
# @raise [ArgumentError] no such session
|
|
75
83
|
# @raise [Refused] code scratch: a leftover chi scratch session
|
|
@@ -81,6 +89,7 @@ module Samagotchi
|
|
|
81
89
|
live = WorkerSidecar.live(session_dir(id), unlink: true)
|
|
82
90
|
return Woken.new(woke: false, sidecar: live) if live
|
|
83
91
|
|
|
92
|
+
OwnerLock.await_release(session_dir(id))
|
|
84
93
|
SessionManager.resume_session(id, **dir)
|
|
85
94
|
found = BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: wait)
|
|
86
95
|
Woken.new(woke: true, sidecar: found && WorkerSidecar.read(session_dir(id)))
|
|
@@ -322,8 +331,50 @@ module Samagotchi
|
|
|
322
331
|
end
|
|
323
332
|
end
|
|
324
333
|
|
|
334
|
+
# --- participants (chi share)
|
|
335
|
+
|
|
336
|
+
# Remote's: through this machine's grants store, as chi web would.
|
|
337
|
+
# @return [Array<ParticipantView>]
|
|
338
|
+
def participants(id = nil)
|
|
339
|
+
grants.list(session_id: id).map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# The link names this machine's chi web: its LAN address when one
|
|
343
|
+
# runs on the LAN (web.port), else 127.0.0.1 (+lan+ false).
|
|
344
|
+
# @return [Invitation]
|
|
345
|
+
# @raise [ArgumentError] no such session
|
|
346
|
+
# @raise [BadRequest] a bad name, kind or ttl, an archived session
|
|
347
|
+
def invite(id, name:, kind:, ttl: nil)
|
|
348
|
+
issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, state_dir: state_dir!)
|
|
349
|
+
ip, port = local_web
|
|
350
|
+
Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
|
|
351
|
+
link: "http://#{ip || "127.0.0.1"}:#{port}/?token=#{issued.token}#s=#{id}", lan: !ip.nil?)
|
|
352
|
+
rescue Web::Grants::Invalid => e
|
|
353
|
+
raise BadRequest.new(e.message, code: "invalid_participant")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# @return [Array<ParticipantView>] the ones it revoked
|
|
357
|
+
def revoke_participants(id = nil, participant: nil)
|
|
358
|
+
Participants.revoke(grants, session_id: id, id: participant, state_dir: state_dir!)
|
|
359
|
+
.map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
|
|
360
|
+
end
|
|
361
|
+
|
|
325
362
|
private
|
|
326
363
|
|
|
364
|
+
def grants = @grants ||= Web::Grants.new(Web::Grants.path(state_dir: state_dir!))
|
|
365
|
+
|
|
366
|
+
# Web::Server::DEFAULT_PORT, without loading chi web.
|
|
367
|
+
WEB_PORT = 4567
|
|
368
|
+
LOCAL_PROBE_TIMEOUT = 1.0
|
|
369
|
+
|
|
370
|
+
# [LAN ip or nil, port] of this machine's chi web (GET /api/info).
|
|
371
|
+
def local_web
|
|
372
|
+
port = Config.get("web.port").to_s.to_i
|
|
373
|
+
port = WEB_PORT unless port.positive?
|
|
374
|
+
info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
|
|
375
|
+
[info.is_a?(Hash) ? info["lan"] : nil, port]
|
|
376
|
+
end
|
|
377
|
+
|
|
327
378
|
def dir = @state_dir ? { state_dir: @state_dir } : {}
|
|
328
379
|
def state_dir! = @state_dir || Session.default_state_dir
|
|
329
380
|
def session_dir(id) = Session.session_dir(id, state_dir: state_dir!)
|
|
@@ -74,8 +74,8 @@ module Samagotchi
|
|
|
74
74
|
# add checks the same).
|
|
75
75
|
# @return [RemoteFailed, nil]
|
|
76
76
|
def self.info_problem(entry, reply)
|
|
77
|
-
|
|
78
|
-
return
|
|
77
|
+
refused = access_error(entry, reply)
|
|
78
|
+
return refused if refused
|
|
79
79
|
unless reply.status == 200 && reply.json.is_a?(Hash) && reply.json["app"] == "chi-web"
|
|
80
80
|
return RemoteFailed.new("#{entry.label}: not chi web (GET /api/info answered #{reply.status})")
|
|
81
81
|
end
|
|
@@ -91,20 +91,31 @@ module Samagotchi
|
|
|
91
91
|
"update chi there (chi update)"
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
# The
|
|
95
|
-
#
|
|
96
|
-
|
|
94
|
+
# The errors any route of +entry+'s chi web can answer, each its one
|
|
95
|
+
# message: the token refused (401 unauthorized) or ended (401
|
|
96
|
+
# token_revoked / token_expired), the LAN IP only (403 forbidden), a
|
|
97
|
+
# participant's token on an owner's route (403 not_permitted) or over
|
|
98
|
+
# its limits (429 too_many_turns / cap_reached). nil for any other
|
|
99
|
+
# answer (chi remote add says the same).
|
|
100
|
+
# @return [RemoteFailed, nil]
|
|
101
|
+
def self.access_error(entry, reply)
|
|
102
|
+
error = reply.error
|
|
103
|
+
known = ACCESS_ERRORS.find { |klass| error == klass::CODE }
|
|
104
|
+
return known.new(entry.label) if known
|
|
105
|
+
|
|
97
106
|
case reply.status
|
|
98
|
-
when 401 then "#{entry.label}: the token was refused (chi web's LAN link carries the current one: " \
|
|
99
|
-
|
|
107
|
+
when 401 then AuthRefused.new("#{entry.label}: the token was refused (chi web's LAN link carries the current one: " \
|
|
108
|
+
"chi remote add #{entry.name} #{entry.url} --token-file FILE)")
|
|
100
109
|
when 403
|
|
101
|
-
return nil unless
|
|
110
|
+
return nil unless error == "forbidden"
|
|
102
111
|
|
|
103
112
|
ip = reply.json["detail"].to_s[/ and (\S+) are answered/, 1]
|
|
104
|
-
"#{entry.label}: chi web answers its LAN IP only: use the LAN IP#{" #{ip}" if ip}, not a hostname"
|
|
113
|
+
AuthRefused.new("#{entry.label}: chi web answers its LAN IP only: use the LAN IP#{" #{ip}" if ip}, not a hostname")
|
|
105
114
|
end
|
|
106
115
|
end
|
|
107
116
|
|
|
117
|
+
ACCESS_ERRORS = [TokenRevoked, TokenExpired, NotPermitted, TooManyTurns, CapReached].freeze
|
|
118
|
+
|
|
108
119
|
# --- ids and sessions
|
|
109
120
|
|
|
110
121
|
# @raise [Session::AmbiguousId]
|
|
@@ -220,6 +231,40 @@ module Samagotchi
|
|
|
220
231
|
|
|
221
232
|
# @return [NoteDelivery::Result]
|
|
222
233
|
# @raise [SessionInbox::NoteRejected]
|
|
234
|
+
# --- participants (chi share)
|
|
235
|
+
|
|
236
|
+
# @param id [String, nil] one session's, or every session's
|
|
237
|
+
# @return [Array<ParticipantView>]
|
|
238
|
+
def participants(id = nil)
|
|
239
|
+
reply = request(:get, id ? "#{session_path(id)}/participants" : "/api/participants")
|
|
240
|
+
raise_refusal(reply) unless reply.status == 200
|
|
241
|
+
|
|
242
|
+
Array(reply.json["participants"]).map { |row| ParticipantView.from_h(row) }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @param ttl [Integer, nil] seconds; nil: the grant's default
|
|
246
|
+
# @return [Invitation]
|
|
247
|
+
# @raise [BadRequest] a bad name, kind or ttl, an archived session
|
|
248
|
+
def invite(id, name:, kind:, ttl: nil)
|
|
249
|
+
reply = request(:post, "#{session_path(id)}/participants", body: { name: name, kind: kind, ttl: ttl }.compact)
|
|
250
|
+
raise_refusal(reply) unless reply.status == 201
|
|
251
|
+
|
|
252
|
+
json = reply.json
|
|
253
|
+
Invitation.new(participant: ParticipantView.from_h(json.fetch("participant")), token: json.fetch("token"),
|
|
254
|
+
link: json.fetch("link"), lan: json["lan"] == true)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Revokes one participant (+participant+), a session's, or with no
|
|
258
|
+
# +id+ every session's.
|
|
259
|
+
# @return [Array<ParticipantView>] the ones it revoked
|
|
260
|
+
def revoke_participants(id = nil, participant: nil)
|
|
261
|
+
reply = request(:delete, id ? "#{session_path(id)}/participants" : "/api/participants",
|
|
262
|
+
query: { id: participant }.compact)
|
|
263
|
+
raise_refusal(reply) unless reply.status == 200
|
|
264
|
+
|
|
265
|
+
Array(reply.json["revoked"]).map { |row| ParticipantView.from_h(row) }
|
|
266
|
+
end
|
|
267
|
+
|
|
223
268
|
def note(id, text:, source:)
|
|
224
269
|
reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
|
|
225
270
|
json = reply.json.is_a?(Hash) ? reply.json : {}
|
|
@@ -317,13 +362,13 @@ module Samagotchi
|
|
|
317
362
|
|
|
318
363
|
# --- sessions
|
|
319
364
|
|
|
320
|
-
# chi web stops it and waits
|
|
321
|
-
#
|
|
365
|
+
# chi web stops it and waits up to +wait+ seconds (capped there at
|
|
366
|
+
# SessionManager::STOP_WAIT; a chi web before the field waits its
|
|
367
|
+
# Web::App::STOP_WAIT_SECONDS).
|
|
322
368
|
# @return [Boolean] whether the worker had released it by then, as
|
|
323
369
|
# chi web's released says (absent: true)
|
|
324
370
|
def stop(id, wait:)
|
|
325
|
-
|
|
326
|
-
reply = request(:post, "#{session_path(id)}/stop")
|
|
371
|
+
reply = request(:post, "#{session_path(id)}/stop", body: { wait: wait })
|
|
327
372
|
raise_refusal(reply) unless reply.status == 200
|
|
328
373
|
|
|
329
374
|
reply.json["released"] != false
|
|
@@ -454,6 +499,50 @@ module Samagotchi
|
|
|
454
499
|
WebBridgeClient.new(remote: self, session_id: id, worker_key: worker["epoch"] || worker["started_at"])
|
|
455
500
|
end
|
|
456
501
|
|
|
502
|
+
# This chi web has +feature+ (GET /api/info's), else RemoteTooOld in
|
|
503
|
+
# Remote.too_old_message's words.
|
|
504
|
+
# @raise [RemoteFailed]
|
|
505
|
+
def require_feature!(feature)
|
|
506
|
+
check_server!
|
|
507
|
+
raise RemoteTooOld, too_old(@version) unless Array(@features).include?(feature)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# POST …/wake: the session's live worker for a remote attach, started
|
|
511
|
+
# when none runs, waited for up to +wait+ seconds there (a leaving
|
|
512
|
+
# worker first: +read_timeout+ covers that wait too).
|
|
513
|
+
# @return [WakeReply]
|
|
514
|
+
# @raise [SessionManager::OwnedByTUI] a chi REPL has it there
|
|
515
|
+
# @raise [Refused] a leftover scratch session (code scratch), or its
|
|
516
|
+
# Bridge didn't come in time (code worker_timeout): chi web's words,
|
|
517
|
+
# LaunchMode's constants
|
|
518
|
+
# @raise [ArgumentError] no such session
|
|
519
|
+
def attach_wake(id, wait:, read_timeout: nil)
|
|
520
|
+
reply = request(:post, "#{session_path(id)}/wake", body: { wait: wait }, read_timeout: read_timeout)
|
|
521
|
+
if reply.status != 200 && %w[scratch worker_timeout].include?(reply.error)
|
|
522
|
+
raise Refused.new((reply.json["message"] || reply.json["detail"]).to_s, code: reply.error)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
raise_refusal(reply) unless reply.status == 200
|
|
526
|
+
|
|
527
|
+
json = reply.json
|
|
528
|
+
WakeReply.new(woke: json["woke"] == true, worker: json["worker"], default_model: json["default_model"],
|
|
529
|
+
strategy: strategy_or_hard(json["parent_strategy"]), installed: json["installed"])
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# GET …/worker: the session's worker now (never started).
|
|
533
|
+
# @return [WorkerReply]
|
|
534
|
+
# @raise [ArgumentError] no such session
|
|
535
|
+
def attach_worker(id)
|
|
536
|
+
reply = request(:get, "#{session_path(id)}/worker")
|
|
537
|
+
raise_refusal(reply) unless reply.status == 200
|
|
538
|
+
|
|
539
|
+
json = reply.json
|
|
540
|
+
seconds = json["leave_seconds"]
|
|
541
|
+
WorkerReply.new(live: json["live"] == true, stopped: json["stopped"] == true, worker: json["worker"],
|
|
542
|
+
strategy: strategy_or_hard(json["parent_strategy"]), default_model: json["default_model"],
|
|
543
|
+
leaving: json["leaving"] == true, leave_seconds: (seconds.to_f if seconds.is_a?(Numeric)))
|
|
544
|
+
end
|
|
545
|
+
|
|
457
546
|
# One request to a route, as the session commands make them (the
|
|
458
547
|
# server check first; a refused token or a missing route raise
|
|
459
548
|
# RemoteFailed): WebBridgeClient's, which maps the reply itself.
|
|
@@ -476,7 +565,8 @@ module Samagotchi
|
|
|
476
565
|
def request(method, path, **)
|
|
477
566
|
check_server!
|
|
478
567
|
reply = @transport.call(method, path, **)
|
|
479
|
-
|
|
568
|
+
refused = Remote.access_error(@entry, reply)
|
|
569
|
+
raise refused if refused
|
|
480
570
|
raise RemoteTooOld, too_old(@version) if missing_route?(reply)
|
|
481
571
|
|
|
482
572
|
reply
|
|
@@ -486,7 +576,10 @@ module Samagotchi
|
|
|
486
576
|
return if @checked
|
|
487
577
|
|
|
488
578
|
reply = @transport.call(:get, "/api/info")
|
|
489
|
-
|
|
579
|
+
if reply.json.is_a?(Hash)
|
|
580
|
+
@version = reply.json["version"]
|
|
581
|
+
@features = Array(reply.json["features"])
|
|
582
|
+
end
|
|
490
583
|
problem = Remote.info_problem(@entry, reply)
|
|
491
584
|
raise problem if problem
|
|
492
585
|
|
|
@@ -495,6 +588,8 @@ module Samagotchi
|
|
|
495
588
|
|
|
496
589
|
def too_old(version) = Remote.too_old_message(@entry, version)
|
|
497
590
|
|
|
591
|
+
def strategy_or_hard(json) = strategy_of(json) || Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
|
|
592
|
+
|
|
498
593
|
# chi web's 404 for a path no route takes (#serve_static's), not a
|
|
499
594
|
# route's own "no such session".
|
|
500
595
|
def missing_route?(reply)
|
|
@@ -527,6 +622,7 @@ module Samagotchi
|
|
|
527
622
|
# spawn_session raises here (chi send: could not start a session).
|
|
528
623
|
when "invalid_model" then raise ModelProfile::MissingModel, message.to_s
|
|
529
624
|
when /\Ainvalid_/, "missing_fields", "bad_images"
|
|
625
|
+
# invalid_participant: chi share's name, kind or ttl, an archived session
|
|
530
626
|
raise BadRequest.new(message.to_s, code: error)
|
|
531
627
|
end
|
|
532
628
|
raise RemoteFailed, "#{@entry.label} answered #{reply.status}#{" #{error}" unless error.empty?}" \
|
|
@@ -535,7 +631,7 @@ module Samagotchi
|
|
|
535
631
|
|
|
536
632
|
def create_body(spec)
|
|
537
633
|
body = { model: spec.model_name, client_id: spec.client_id, history: false }
|
|
538
|
-
if spec.preview
|
|
634
|
+
if spec.preview || spec.idle
|
|
539
635
|
body.merge!(idle: true, preview: spec.preview)
|
|
540
636
|
else
|
|
541
637
|
body[:prompt] = spec.prompt
|