samagotchi 0.101.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 +67 -1
- data/bin/chi +31 -196
- data/docs/architecture.md +62 -6
- data/docs/cli.md +154 -7
- data/docs/configuration.md +32 -5
- data/docs/guardrails.md +2 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +25 -1
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +174 -15
- data/docs/plugins.md +32 -5
- data/docs/sessions.md +38 -14
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +7 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +33 -7
- 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/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- 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/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +89 -53
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/engine.rb +138 -15
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +24 -8
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- 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 +186 -118
- data/lib/samagotchi/session.rb +48 -16
- 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 +53 -6
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +15 -10
- data/lib/samagotchi/sessions_command.rb +42 -16
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +54 -18
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +82 -76
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- 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 +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +387 -79
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +6 -4
- data/lib/samagotchi/web/public/index.html +37 -3
- 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 +7 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/working_set.js +247 -24
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +198 -25
- data/lib/samagotchi/web/session_access_routes.rb +343 -0
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +132 -20
- data/lib/samagotchi/working_set_command.rb +222 -0
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +414 -0
- metadata +23 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
|
@@ -4,6 +4,7 @@ require_relative "../session"
|
|
|
4
4
|
require_relative "../reply_wait"
|
|
5
5
|
require_relative "../parent_report"
|
|
6
6
|
require_relative "exit"
|
|
7
|
+
require_relative "../session_access"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
module CLI
|
|
@@ -12,8 +13,8 @@ module Samagotchi
|
|
|
12
13
|
# for it, and the wait for what comes next, reported the ParentReport
|
|
13
14
|
# way (Ctrl-C included). The including class (a CLI::Command) defines
|
|
14
15
|
# #parse (options, or an exit status) and #run_parsed(options), sets
|
|
15
|
-
# @stdout, @stderr and @
|
|
16
|
-
# (specs stub them on the class).
|
|
16
|
+
# @stdout, @stderr and @access (a SessionAccess backend), and may
|
|
17
|
+
# override the constants (specs stub them on the class).
|
|
17
18
|
module ParentWait
|
|
18
19
|
POLL_INTERVAL = ReplyWait::POLL_INTERVAL
|
|
19
20
|
# No live worker this long while waiting: it died before it could mark
|
|
@@ -30,7 +31,14 @@ module Samagotchi
|
|
|
30
31
|
# With --format json stdout is one JSON object, whatever the end: a
|
|
31
32
|
# failure before the wait is reported from stderr's last line.
|
|
32
33
|
@stderr = ParentReport::LastLine.new(@stderr) if @json
|
|
33
|
-
status =
|
|
34
|
+
status = begin
|
|
35
|
+
run_parsed(options)
|
|
36
|
+
rescue SessionAccess::RemoteFailed => e
|
|
37
|
+
# A remote that went away mid-run: the command's line, and with
|
|
38
|
+
# --format json its object.
|
|
39
|
+
error_line("#{command_name}: #{e.message}")
|
|
40
|
+
Exit::FAILED
|
|
41
|
+
end
|
|
34
42
|
if @json && !@reported && status == 1
|
|
35
43
|
detail = @stderr.last.to_s.delete_prefix("#{command_name}: ")
|
|
36
44
|
@stdout.puts(ParentReport.error_line(detail, session_id: @session_id))
|
|
@@ -45,9 +53,7 @@ module Samagotchi
|
|
|
45
53
|
# error line; nil after the error line.
|
|
46
54
|
# @return [String, nil]
|
|
47
55
|
def resolve(given)
|
|
48
|
-
|
|
49
|
-
Session.load(id, state_dir: @state_dir)
|
|
50
|
-
@session_id = id
|
|
56
|
+
@session_id = @access.resolve(given)
|
|
51
57
|
rescue ArgumentError => e
|
|
52
58
|
error_line("#{command_name}: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
|
|
53
59
|
nil
|
|
@@ -70,16 +76,19 @@ module Samagotchi
|
|
|
70
76
|
# @return [Integer] the exit status
|
|
71
77
|
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
|
|
72
78
|
queued: false, ended_before: false, report_turn_id: turn)
|
|
73
|
-
result =
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
result = @access.wait_reply(id, cursor: cursor, timeout: timeout, baseline: baseline, owner_grace: owner_grace,
|
|
80
|
+
poll_interval: self.class::POLL_INTERVAL, turn: turn, queued: queued,
|
|
81
|
+
ended_before: ended_before)
|
|
76
82
|
result.turn_id = report_turn_id if report_turn_id && !report_turn_id.to_s.empty?
|
|
77
83
|
result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
78
84
|
@reported = true
|
|
79
|
-
# What a parent may pick there: config.yml tightened by the session's
|
|
80
|
-
|
|
85
|
+
# What a parent may pick there: config.yml tightened by the session's
|
|
86
|
+
# own. Machine-local (this machine's config.yml): remotely the
|
|
87
|
+
# server's (P2's wait route).
|
|
88
|
+
strategy = @access.parent_strategy(id)
|
|
81
89
|
ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
|
|
82
|
-
json: @json, timeout: timeout, extra: report_extra, strategy: strategy
|
|
90
|
+
json: @json, timeout: timeout, extra: report_extra, strategy: strategy,
|
|
91
|
+
via: @access.invocation)
|
|
83
92
|
rescue Interrupt
|
|
84
93
|
if @json
|
|
85
94
|
@stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled, turn_id: report_turn_id), session_id: id,
|
|
@@ -87,7 +96,7 @@ module Samagotchi
|
|
|
87
96
|
@stdout.flush
|
|
88
97
|
@reported = true
|
|
89
98
|
end
|
|
90
|
-
error_line("#{command_name}: still running:
|
|
99
|
+
error_line("#{command_name}: still running: #{@access.invocation.shows(id)}")
|
|
91
100
|
Exit::INTERRUPTED
|
|
92
101
|
rescue ArgumentError
|
|
93
102
|
error_line("#{command_name}: the session is gone (deleted while waiting)")
|
|
@@ -96,6 +96,10 @@ module Samagotchi
|
|
|
96
96
|
end
|
|
97
97
|
private_class_method :listed_mid_turn
|
|
98
98
|
|
|
99
|
+
# A name is taken once, with one exception: core may give a name two
|
|
100
|
+
# entries, one the UIs run (local) and one the session runs, each with
|
|
101
|
+
# its own match (/recap shows in a UI, /recap off runs in the worker).
|
|
102
|
+
# #listing then has the name twice, /help and Tab once.
|
|
99
103
|
# @param anytime [Boolean] mid_turn: :anytime (a bundle's command)
|
|
100
104
|
# @param mid_turn [Symbol, #call, nil] see Entry; nil takes +anytime+
|
|
101
105
|
# @param match [#call, nil] the default matches the name alone or the
|
|
@@ -104,7 +108,7 @@ module Samagotchi
|
|
|
104
108
|
# @return [Entry]
|
|
105
109
|
def register(name, description, id: nil, anytime: false, mid_turn: nil, local: false, uis: nil, match: nil,
|
|
106
110
|
source: "core", annotations: [], &handler)
|
|
107
|
-
raise ArgumentError, "command #{name} is already registered"
|
|
111
|
+
raise ArgumentError, "command #{name} is already registered" unless name_free?(name, local: local, source: source)
|
|
108
112
|
|
|
109
113
|
entry = Entry.new(id: id || name.delete_prefix("/").to_sym, name: name, description: description,
|
|
110
114
|
mid_turn: mid_turn || (anytime ? :anytime : :refuse), local: local, uis: uis, match: match || default_match(name),
|
|
@@ -203,6 +207,13 @@ module Samagotchi
|
|
|
203
207
|
@entries << entry
|
|
204
208
|
end
|
|
205
209
|
|
|
210
|
+
def name_free?(name, local:, source:)
|
|
211
|
+
taken = @entries.select { |entry| entry.name == name }
|
|
212
|
+
return true if taken.empty?
|
|
213
|
+
|
|
214
|
+
source == "core" && taken.size == 1 && taken.first.source == "core" && !taken.first.local != !local
|
|
215
|
+
end
|
|
216
|
+
|
|
206
217
|
def default_match(name)
|
|
207
218
|
after = /\A#{Regexp.escape(name)}\s/
|
|
208
219
|
->(text) { text == name || after.match?(text) }
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -22,6 +22,10 @@ module Samagotchi
|
|
|
22
22
|
autoload :ModelSetting, File.expand_path("model_settings", __dir__)
|
|
23
23
|
# The per-model/host seconds a cut waits (like Thinking for the level).
|
|
24
24
|
autoload :SteerCutAfter, File.expand_path("steer_cut_after", __dir__)
|
|
25
|
+
# A refused --set (Config.coerce! raises its Error), and recap.sentences'
|
|
26
|
+
# check (IdleRecap::RecapPrompt): both load on first use.
|
|
27
|
+
autoload :SessionConfig, File.expand_path("session_config", __dir__)
|
|
28
|
+
autoload :IdleRecap, File.expand_path("idle_recap", __dir__)
|
|
25
29
|
|
|
26
30
|
# Unified configuration registry implementing the implicit convention:
|
|
27
31
|
# ENV SAMAGOTCHI_ATTR (UPPER + prefix + _ = nesting)
|
|
@@ -39,8 +43,12 @@ module Samagotchi
|
|
|
39
43
|
module Config
|
|
40
44
|
# invalid: what an enum's invalid value falls to, when not the default
|
|
41
45
|
# (guardrails.parent_answers: the strictest).
|
|
46
|
+
# session: true for a key a session may set for itself (chi --set KEY=VALUE,
|
|
47
|
+
# SessionConfig; its value is read through Config.get(key, session:)),
|
|
48
|
+
# check: a lambda that takes a typed value and returns nil, or the
|
|
49
|
+
# "takes ..." phrase a refused --set names (recap.sentences' N or N-M).
|
|
42
50
|
Entry = Struct.new(:key, :yaml_path, :type, :default, :expose, :enum_values, :yaml_aliases, :lifecycle, :invalid,
|
|
43
|
-
keyword_init: true) do
|
|
51
|
+
:session, :check, keyword_init: true) do
|
|
44
52
|
def env_key
|
|
45
53
|
"SAMAGOTCHI_" + yaml_path.map { |p| p.upcase }.join("_")
|
|
46
54
|
end
|
|
@@ -64,6 +72,9 @@ module Samagotchi
|
|
|
64
72
|
def config_exposed?
|
|
65
73
|
expose.include?(:config)
|
|
66
74
|
end
|
|
75
|
+
|
|
76
|
+
# Whether a session may set the key for itself (chi --set).
|
|
77
|
+
def session? = session == true
|
|
67
78
|
end
|
|
68
79
|
|
|
69
80
|
# Section names: lower alnum only, no _ or -.
|
|
@@ -93,7 +104,9 @@ module Samagotchi
|
|
|
93
104
|
# off on local ones. hosts.<name>.first_token_timeout wins.
|
|
94
105
|
Entry.new(key: "server.first_token_timeout", yaml_path: %w[server first_token_timeout], type: :integer, default: nil, expose: %i[env config], lifecycle: :worker_start),
|
|
95
106
|
|
|
96
|
-
|
|
107
|
+
# Both session-settable (chi --set recap.enabled=false): RecapSetup reads them through the session's layer.
|
|
108
|
+
Entry.new(key: "recap.enabled", yaml_path: %w[recap enabled], type: :bool, default: nil, expose: %i[env config], lifecycle: :worker_start,
|
|
109
|
+
session: true),
|
|
97
110
|
Entry.new(key: "recap.model", yaml_path: %w[recap model], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
|
|
98
111
|
Entry.new(key: "recap.base_url", yaml_path: %w[recap base_url], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
|
|
99
112
|
Entry.new(key: "recap.host_ref", yaml_path: %w[recap host_ref], type: :string, default: nil, expose: %i[env config cli], yaml_aliases: %w[host], lifecycle: :worker_start),
|
|
@@ -101,7 +114,9 @@ module Samagotchi
|
|
|
101
114
|
Entry.new(key: "recap.timeout", yaml_path: %w[recap timeout], type: :float, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
|
|
102
115
|
Entry.new(key: "recap.min_user_turns", yaml_path: %w[recap min_user_turns], type: :integer, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
|
|
103
116
|
# "N-M" or "N" sentences (1-10); unset = 2-4. Parsed by RecapPrompt.sentences_range.
|
|
104
|
-
Entry.new(key: "recap.sentences", yaml_path: %w[recap sentences], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start
|
|
117
|
+
Entry.new(key: "recap.sentences", yaml_path: %w[recap sentences], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start,
|
|
118
|
+
session: true,
|
|
119
|
+
check: ->(value) { "N or N-M (1-#{IdleRecap::RecapPrompt::MAX_SENTENCES})" unless IdleRecap::RecapPrompt.sentences_range(value) }),
|
|
105
120
|
|
|
106
121
|
# chi broadcast (Broadcast::Recipients, Broadcast::Tags): a session that ended no turn in this many hours (and
|
|
107
122
|
# no worker or REPL runs) gets no broadcast; a ticket id in the note and in a session's branch or prompts
|
|
@@ -308,7 +323,7 @@ module Samagotchi
|
|
|
308
323
|
|
|
309
324
|
# Top-level maps and lists read by their own code, whose entry names and
|
|
310
325
|
# contents are the user's: ConfigFile.model_aliases / preloaded_memories /
|
|
311
|
-
# appended_prompt_files, Hooks::Loader,
|
|
326
|
+
# appended_prompt_files, Hooks::Loader, ConfigFile.bundle_settings.
|
|
312
327
|
FREE_FORM_MAPS = %w[model_aliases hooks bundles memories append_system_prompt].freeze
|
|
313
328
|
# Maps of named entries and the keys an entry may hold
|
|
314
329
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
@@ -324,6 +339,10 @@ module Samagotchi
|
|
|
324
339
|
# And toolkit's kits (Toolkit.kits).
|
|
325
340
|
SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze, "toolkit" => %w[kits].freeze }.freeze
|
|
326
341
|
|
|
342
|
+
# What a :bool value reads as (Config.coerce!), YAML 1.1's words and 1/0.
|
|
343
|
+
BOOL_WORDS = { "1" => true, "true" => true, "yes" => true, "on" => true,
|
|
344
|
+
"0" => false, "false" => false, "no" => false, "off" => false }.freeze
|
|
345
|
+
|
|
327
346
|
# Fast lookup maps
|
|
328
347
|
BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
|
|
329
348
|
|
|
@@ -342,58 +361,70 @@ module Samagotchi
|
|
|
342
361
|
ENTRIES.select(&:cli_exposed?)
|
|
343
362
|
end
|
|
344
363
|
|
|
345
|
-
#
|
|
346
|
-
def
|
|
364
|
+
# The entries a session may set for itself (Entry#session?).
|
|
365
|
+
def session_entries
|
|
366
|
+
ENTRIES.select(&:session?)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# +raw+ as +entry+'s type, strictly: a value of another shape raises.
|
|
370
|
+
# :string as given (a YAML list joined with |), :integer, :float,
|
|
371
|
+
# :bool (true/yes/on/1, false/no/off/0/""), :enum in its canonical
|
|
372
|
+
# case; nil for nil, and for a blank value of any other type than
|
|
373
|
+
# :string and :bool. #coerce is its lenient twin (config.yml, env).
|
|
374
|
+
# @raise [SessionConfig::Error] "KEY takes WHAT, not RAW"
|
|
375
|
+
def coerce!(entry, raw)
|
|
347
376
|
return nil if raw.nil?
|
|
377
|
+
# A YAML list for a "|"-separated setting (web.annotate_presets).
|
|
378
|
+
# As given, trailing spaces included ("Please ").
|
|
379
|
+
return raw.is_a?(Array) ? raw.join("|") : raw.to_s if entry.type == :string
|
|
348
380
|
|
|
349
|
-
|
|
350
|
-
if entry.type == :
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
381
|
+
str = raw.to_s.strip
|
|
382
|
+
return false if entry.type == :bool && str.empty?
|
|
383
|
+
return nil if str.empty?
|
|
384
|
+
|
|
385
|
+
value = case entry.type
|
|
386
|
+
when :integer then Integer(str, exception: false)
|
|
387
|
+
when :float then Float(str, exception: false)
|
|
388
|
+
when :bool then BOOL_WORDS[str.downcase]
|
|
389
|
+
when :enum then entry.enum_values.find { |v| v.downcase == str.downcase }
|
|
390
|
+
else str
|
|
391
|
+
end
|
|
392
|
+
raise SessionConfig::Error, "#{entry.key} takes #{takes(entry)}, not #{raw.to_s.inspect}" if value.nil?
|
|
393
|
+
|
|
394
|
+
value
|
|
395
|
+
end
|
|
354
396
|
|
|
355
|
-
|
|
397
|
+
# What +entry+'s type takes, as a refusal names it.
|
|
398
|
+
def takes(entry)
|
|
399
|
+
case entry.type
|
|
400
|
+
when :integer then "an integer"
|
|
401
|
+
when :float then "a number"
|
|
402
|
+
when :bool then "true or false"
|
|
403
|
+
when :enum then "#{entry.enum_values[0..-2].join(", ")} or #{entry.enum_values.last}"
|
|
404
|
+
else "text"
|
|
356
405
|
end
|
|
357
|
-
|
|
358
|
-
return nil if str.empty? && entry.type != :string
|
|
406
|
+
end
|
|
359
407
|
|
|
408
|
+
# +raw+ as +entry+'s type (#coerce!), leniently: a value of another
|
|
409
|
+
# shape warns and falls back (the default; false for a bool; an
|
|
410
|
+
# enum's +invalid+ when it has one).
|
|
411
|
+
def coerce(entry, raw)
|
|
412
|
+
coerce!(entry, raw)
|
|
413
|
+
rescue SessionConfig::Error
|
|
360
414
|
case entry.type
|
|
361
|
-
when :string
|
|
362
|
-
str
|
|
363
415
|
when :integer
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
Log.warn(:config, "invalid_value", echo: "Warning: invalid integer for #{entry.key} (#{entry.env_key}): #{raw.inspect} — using default", key: entry.key)
|
|
367
|
-
return entry.default
|
|
368
|
-
end
|
|
369
|
-
end
|
|
416
|
+
Log.warn(:config, "invalid_value", echo: "Warning: invalid integer for #{entry.key} (#{entry.env_key}): #{raw.inspect} — using default", key: entry.key)
|
|
417
|
+
entry.default
|
|
370
418
|
when :float
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
Log.warn(:config, "invalid_value", echo: "Warning: invalid float for #{entry.key}: #{raw.inspect}", key: entry.key)
|
|
374
|
-
return entry.default
|
|
375
|
-
end
|
|
376
|
-
val
|
|
419
|
+
Log.warn(:config, "invalid_value", echo: "Warning: invalid float for #{entry.key}: #{raw.inspect}", key: entry.key)
|
|
420
|
+
entry.default
|
|
377
421
|
when :bool
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
when "0", "false", "no", "off", "" then false
|
|
381
|
-
else
|
|
382
|
-
Log.warn(:config, "invalid_value", echo: "Warning: invalid bool for #{entry.key}: #{raw.inspect} — treating as false", key: entry.key)
|
|
383
|
-
false
|
|
384
|
-
end
|
|
385
|
-
when :enum
|
|
386
|
-
lowered = str.downcase
|
|
387
|
-
allowed = entry.enum_values.map(&:downcase)
|
|
388
|
-
unless allowed.include?(lowered)
|
|
389
|
-
using = entry.invalid ? "using #{entry.invalid}" : "using default"
|
|
390
|
-
Log.warn(:config, "invalid_value", echo: "Warning: invalid value for #{entry.key}: #{raw.inspect} (allowed: #{entry.enum_values.join(", ")}) — #{using}", key: entry.key)
|
|
391
|
-
return entry.invalid || entry.default
|
|
392
|
-
end
|
|
393
|
-
# return canonical casing from enum_values
|
|
394
|
-
entry.enum_values.find { |v| v.downcase == lowered }
|
|
422
|
+
Log.warn(:config, "invalid_value", echo: "Warning: invalid bool for #{entry.key}: #{raw.inspect} — treating as false", key: entry.key)
|
|
423
|
+
false
|
|
395
424
|
else
|
|
396
|
-
|
|
425
|
+
using = entry.invalid ? "using #{entry.invalid}" : "using default"
|
|
426
|
+
Log.warn(:config, "invalid_value", echo: "Warning: invalid value for #{entry.key}: #{raw.inspect} (allowed: #{entry.enum_values.join(", ")}) — #{using}", key: entry.key)
|
|
427
|
+
entry.invalid || entry.default
|
|
397
428
|
end
|
|
398
429
|
end
|
|
399
430
|
|
|
@@ -406,10 +437,14 @@ module Samagotchi
|
|
|
406
437
|
end
|
|
407
438
|
|
|
408
439
|
# Same as #resolve, plus the layer the value came from:
|
|
409
|
-
# :cli, :env, :file or :default.
|
|
410
|
-
|
|
440
|
+
# :session, :cli, :env, :file or :default. +session+ is a session's
|
|
441
|
+
# own values (SessionConfig::Settings#core, typed): a session-settable
|
|
442
|
+
# key's value there goes first, over the CLI (a worker gets its CLI
|
|
443
|
+
# as env, and the session's value was given for this session).
|
|
444
|
+
def resolve_with_origin(key, file_data: nil, env: ENV, cli_overrides: {}, session: nil)
|
|
411
445
|
entry = find_by_key(key)
|
|
412
446
|
raise ArgumentError, "unknown config key: #{key}" unless entry
|
|
447
|
+
return [session[entry.key], :session] if entry.session? && session&.key?(entry.key)
|
|
413
448
|
|
|
414
449
|
# CLI wins
|
|
415
450
|
if cli_overrides.key?(entry.key)
|
|
@@ -523,8 +558,9 @@ module Samagotchi
|
|
|
523
558
|
end
|
|
524
559
|
end
|
|
525
560
|
|
|
526
|
-
|
|
527
|
-
|
|
561
|
+
# The live value of +key+; +session+ as #resolve_with_origin takes it.
|
|
562
|
+
def get(key, session: nil)
|
|
563
|
+
get_with_origin(key, session: session).first
|
|
528
564
|
end
|
|
529
565
|
|
|
530
566
|
# default.max_tokens: the most tokens one generation may produce, or
|
|
@@ -545,8 +581,8 @@ module Samagotchi
|
|
|
545
581
|
end
|
|
546
582
|
|
|
547
583
|
# [value, origin] for the live value #get returns; origin is one of
|
|
548
|
-
# :cli, :env, :file, :default.
|
|
549
|
-
def get_with_origin(key)
|
|
584
|
+
# :session, :cli, :env, :file, :default.
|
|
585
|
+
def get_with_origin(key, session: nil)
|
|
550
586
|
entry = find_by_key(key)
|
|
551
587
|
raise ArgumentError, "unknown config key: #{key}" unless entry
|
|
552
588
|
|
|
@@ -555,7 +591,7 @@ module Samagotchi
|
|
|
555
591
|
# overrides captured via reload!
|
|
556
592
|
path = Samagotchi::ConfigFile.global_path rescue nil
|
|
557
593
|
file_data = Samagotchi::ConfigFile.read_yaml(path: path) if path
|
|
558
|
-
resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides)
|
|
594
|
+
resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides, session: session)
|
|
559
595
|
end
|
|
560
596
|
|
|
561
597
|
# Problems with the keys of a parsed config.yml, one message per key the
|
|
@@ -11,6 +11,7 @@ require_relative "cli/command"
|
|
|
11
11
|
require_relative "cli/exit"
|
|
12
12
|
require_relative "cli/flags"
|
|
13
13
|
require_relative "redacted_inspect"
|
|
14
|
+
require_relative "session_access/local"
|
|
14
15
|
|
|
15
16
|
module Samagotchi
|
|
16
17
|
# `chi context`: attach live external text to sessions (ContextSources).
|
|
@@ -76,10 +77,16 @@ module Samagotchi
|
|
|
76
77
|
def project? = session_id.nil?
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
# Why chi context rm NAME ID (and chi web's DELETE …/context/NAME?own=1)
|
|
81
|
+
# leaves a source the session's project has.
|
|
82
|
+
def self.project_source_message(name) = "#{name} is the project's: chi context rm #{name} --project, or mute it for this session"
|
|
83
|
+
|
|
79
84
|
# @param argv [Array<String>] the arguments after "context"
|
|
80
85
|
# @param clock [#call] now, for ls's ages ("4s ago")
|
|
86
|
+
# @param access [SessionAccess::Local, nil] where the sessions are;
|
|
87
|
+
# default this machine's, in +state_dir+
|
|
81
88
|
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, env: ENV, cwd: Dir.pwd,
|
|
82
|
-
clock: -> { Time.now })
|
|
89
|
+
clock: -> { Time.now }, access: nil)
|
|
83
90
|
@argv = argv.dup
|
|
84
91
|
@stdin = stdin
|
|
85
92
|
@stdout = stdout
|
|
@@ -88,6 +95,7 @@ module Samagotchi
|
|
|
88
95
|
@env = env
|
|
89
96
|
@cwd = cwd
|
|
90
97
|
@clock = clock
|
|
98
|
+
@access = access || SessionAccess::Local.new(state_dir: @state_dir)
|
|
91
99
|
end
|
|
92
100
|
|
|
93
101
|
# @return [Integer] exit status: 0 done, 1 refused or failed, 2 usage
|
|
@@ -127,11 +135,10 @@ module Samagotchi
|
|
|
127
135
|
name: name, cmd: options[:cmd], every_seconds: ContextSources.check_every!(options[:every]),
|
|
128
136
|
why: ContextSources.one_line(options[:why], ContextSources::LINE_MAX_CHARS),
|
|
129
137
|
hint: ContextSources.one_line(options[:hint], ContextSources::LINE_MAX_CHARS),
|
|
130
|
-
scope: nil, added_by:
|
|
138
|
+
scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601
|
|
131
139
|
)
|
|
132
140
|
each_target(options, args) do |target|
|
|
133
|
-
|
|
134
|
-
location.add(source.with(scope: location.scope))
|
|
141
|
+
@access.context_add(target, source)
|
|
135
142
|
@stdout.puts("#{target.label} attached #{name}")
|
|
136
143
|
true
|
|
137
144
|
end
|
|
@@ -144,6 +151,7 @@ module Samagotchi
|
|
|
144
151
|
if options[:cmd] || options[:push] || options[:every]
|
|
145
152
|
return usage_error("a URL's command comes from its provider: give no --cmd, --push or --every")
|
|
146
153
|
end
|
|
154
|
+
return add_remote_url(url, options, args) if @access.remote?
|
|
147
155
|
|
|
148
156
|
resolved = ContextProviders.resolve(url)
|
|
149
157
|
return fail_line("no installed bundle resolves #{url} (chi bundle install github-pr for GitHub PRs)") unless resolved
|
|
@@ -152,11 +160,10 @@ module Samagotchi
|
|
|
152
160
|
name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds,
|
|
153
161
|
why: ContextSources.one_line(options[:why] || resolved.why, ContextSources::LINE_MAX_CHARS),
|
|
154
162
|
hint: ContextSources.one_line(options[:hint] || resolved.hint, ContextSources::LINE_MAX_CHARS),
|
|
155
|
-
scope: nil, added_by:
|
|
163
|
+
scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601, provider: resolved.bundle
|
|
156
164
|
)
|
|
157
165
|
each_target(options, args) do |target|
|
|
158
|
-
|
|
159
|
-
location.add(source.with(scope: location.scope))
|
|
166
|
+
@access.context_add(target, source)
|
|
160
167
|
@stdout.puts("#{target.label} attached #{source.name}")
|
|
161
168
|
true
|
|
162
169
|
end
|
|
@@ -164,6 +171,19 @@ module Samagotchi
|
|
|
164
171
|
fail_line(e.message)
|
|
165
172
|
end
|
|
166
173
|
|
|
174
|
+
# A URL on a remote: its installed bundles' providers resolve it there.
|
|
175
|
+
def add_remote_url(url, options, args)
|
|
176
|
+
why = ContextSources.one_line(options[:why], ContextSources::LINE_MAX_CHARS)
|
|
177
|
+
hint = ContextSources.one_line(options[:hint], ContextSources::LINE_MAX_CHARS)
|
|
178
|
+
each_target(options, args) do |target|
|
|
179
|
+
name = @access.context_add_url(target, url, why: why, hint: hint, added_by: added_by)
|
|
180
|
+
@stdout.puts("#{target.label} attached #{name}")
|
|
181
|
+
true
|
|
182
|
+
end
|
|
183
|
+
rescue ContextProviders::Invalid => e
|
|
184
|
+
fail_line(e.message)
|
|
185
|
+
end
|
|
186
|
+
|
|
167
187
|
def run_push(options, args)
|
|
168
188
|
name = args.shift
|
|
169
189
|
return usage_error("give the source's NAME") unless name
|
|
@@ -173,18 +193,20 @@ module Samagotchi
|
|
|
173
193
|
|
|
174
194
|
fetched = ContextSources.parse_output(text)
|
|
175
195
|
each_target(options, args) do |target|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
196
|
+
pushed = @access.context_push(target, name, fetched)
|
|
197
|
+
case pushed.status
|
|
198
|
+
when :missing
|
|
199
|
+
no_source(target, name)
|
|
200
|
+
next false
|
|
201
|
+
when :not_push
|
|
202
|
+
# A command source's text is its command's (R2): a push between
|
|
203
|
+
# two fetches would read as a change and then vanish.
|
|
204
|
+
error_line("#{command_name}: #{SessionAccess::ContextPush.not_push_message(name)}")
|
|
181
205
|
next false
|
|
182
206
|
end
|
|
183
207
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
end
|
|
187
|
-
@stdout.puts("#{target.label} #{name}: #{written.revision == before ? "unchanged" : "new text"} (#{written.revision[0, 12]})")
|
|
208
|
+
@stdout.puts("#{target.label} #{name}: #{pushed.revision == pushed.before ? "unchanged" : "new text"} " \
|
|
209
|
+
"(#{pushed.revision[0, 12]})")
|
|
188
210
|
true
|
|
189
211
|
end
|
|
190
212
|
end
|
|
@@ -194,7 +216,7 @@ module Samagotchi
|
|
|
194
216
|
return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
|
|
195
217
|
|
|
196
218
|
targets = targets(options, args) or return CLI::Exit::FAILED
|
|
197
|
-
rows = targets.flat_map { |target|
|
|
219
|
+
rows = targets.flat_map { |target| @access.context_entries(target).map { |entry| row(target, entry) } }
|
|
198
220
|
if format == "json"
|
|
199
221
|
@stdout.puts(JSON.pretty_generate(rows))
|
|
200
222
|
elsif rows.empty?
|
|
@@ -212,10 +234,10 @@ module Samagotchi
|
|
|
212
234
|
targets = targets(options, args) or return CLI::Exit::FAILED
|
|
213
235
|
return usage_error("show takes one target") if targets.size > 1
|
|
214
236
|
|
|
215
|
-
|
|
216
|
-
snapshot =
|
|
237
|
+
entry = @access.context_entry(targets.first, name) or return no_source(targets.first, name)
|
|
238
|
+
snapshot = entry.snapshot
|
|
217
239
|
if options[:json]
|
|
218
|
-
@stdout.puts(JSON.pretty_generate({ "source" =>
|
|
240
|
+
@stdout.puts(JSON.pretty_generate({ "source" => entry.source.to_h, "snapshot" => snapshot.to_h }))
|
|
219
241
|
return 0
|
|
220
242
|
end
|
|
221
243
|
unless snapshot.text?
|
|
@@ -229,7 +251,8 @@ module Samagotchi
|
|
|
229
251
|
|
|
230
252
|
# Runs the command in this process, not the worker's: the result shows
|
|
231
253
|
# here, and the worker sees the new snapshot on its next loop. The
|
|
232
|
-
# source's lock keeps the two from running it at once.
|
|
254
|
+
# source's lock keeps the two from running it at once. Local only
|
|
255
|
+
# (SessionAccess::Contract): it runs the command on this machine.
|
|
233
256
|
def run_refresh(options, args)
|
|
234
257
|
name = args.shift
|
|
235
258
|
return usage_error("give the source's NAME") unless name
|
|
@@ -271,15 +294,12 @@ module Samagotchi
|
|
|
271
294
|
|
|
272
295
|
ContextSources.check_name!(name)
|
|
273
296
|
each_target(options, args) do |target|
|
|
274
|
-
|
|
275
|
-
|
|
297
|
+
case @access.context_remove(target, name)
|
|
298
|
+
when :removed
|
|
276
299
|
@stdout.puts("#{target.label} detached #{name}")
|
|
277
300
|
next true
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
project = !target.project? && ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
|
|
281
|
-
if project&.source(name)
|
|
282
|
-
error_line("#{command_name}: #{name} is the project's: chi context rm #{name} --project, or mute it for this session")
|
|
301
|
+
when :project
|
|
302
|
+
error_line("#{command_name}: #{self.class.project_source_message(name)}")
|
|
283
303
|
else
|
|
284
304
|
error_line("#{command_name}: no source #{name} for #{target.label}")
|
|
285
305
|
end
|
|
@@ -287,6 +307,7 @@ module Samagotchi
|
|
|
287
307
|
end
|
|
288
308
|
end
|
|
289
309
|
|
|
310
|
+
# Local only (SessionAccess::Contract): the session's muted/ files here.
|
|
290
311
|
def run_mute(options, args) = mute_or_unmute(options, args, mute: true)
|
|
291
312
|
def run_unmute(options, args) = mute_or_unmute(options, args, mute: false)
|
|
292
313
|
|
|
@@ -327,10 +348,11 @@ module Samagotchi
|
|
|
327
348
|
return [target]
|
|
328
349
|
end
|
|
329
350
|
if args.empty?
|
|
330
|
-
own = inside_session
|
|
351
|
+
own = inside_session unless @access.remote?
|
|
331
352
|
unless own
|
|
332
|
-
|
|
333
|
-
|
|
353
|
+
# Remotely no default: the session chi runs in is this machine's.
|
|
354
|
+
error_line("#{command_name}: give session ids (or unique prefixes)" \
|
|
355
|
+
"#{", or --project; inside a chi session it's that session" unless @access.remote?}")
|
|
334
356
|
return nil
|
|
335
357
|
end
|
|
336
358
|
args = [own]
|
|
@@ -340,6 +362,8 @@ module Samagotchi
|
|
|
340
362
|
resolved.all? ? resolved.uniq : nil
|
|
341
363
|
end
|
|
342
364
|
|
|
365
|
+
# A project's sources: local only (SessionAccess::Contract), this
|
|
366
|
+
# machine's checkout.
|
|
343
367
|
def project_target
|
|
344
368
|
unless MemoryPaths.in_repo?(@cwd)
|
|
345
369
|
error_line("#{command_name}: --project needs a git repository, and #{@cwd} is in none")
|
|
@@ -354,8 +378,8 @@ module Samagotchi
|
|
|
354
378
|
end
|
|
355
379
|
|
|
356
380
|
def session_target(given)
|
|
357
|
-
id =
|
|
358
|
-
session =
|
|
381
|
+
id = @access.resolve_id(given)
|
|
382
|
+
session = @access.show(id)
|
|
359
383
|
Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory, label: id[0, 8])
|
|
360
384
|
rescue ArgumentError => e
|
|
361
385
|
message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
|
|
@@ -363,64 +387,47 @@ module Samagotchi
|
|
|
363
387
|
nil
|
|
364
388
|
end
|
|
365
389
|
|
|
390
|
+
# Who adds a source: an agent (chi's execute runs this in a session)
|
|
391
|
+
# or the user's cli.
|
|
392
|
+
def added_by = inside_session ? "agent" : "cli"
|
|
393
|
+
|
|
366
394
|
# The session chi's execute runs this in (SAMAGOTCHI_PARENT_SESSION;
|
|
367
|
-
# "chi" is a run with no session).
|
|
395
|
+
# "chi" is a run with no session). Machine-local (a session on this
|
|
396
|
+
# machine): remotely it is no default target (#targets), the ids are
|
|
397
|
+
# given.
|
|
368
398
|
def inside_session
|
|
369
399
|
id = @env.fetch("SAMAGOTCHI_PARENT_SESSION", "").to_s.strip
|
|
370
400
|
id.empty? || id == "chi" ? nil : id
|
|
371
401
|
end
|
|
372
402
|
|
|
373
|
-
# The folder a target's own sources live in.
|
|
374
|
-
def location_of(target)
|
|
375
|
-
if target.project?
|
|
376
|
-
ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
|
|
377
|
-
else
|
|
378
|
-
ContextSources.session_location(target.session_id, state_dir: @state_dir)
|
|
379
|
-
end
|
|
380
|
-
end
|
|
403
|
+
# The folder a target's own sources live in (refresh, mute: local only).
|
|
404
|
+
def location_of(target) = @access.context_location(target)
|
|
381
405
|
|
|
382
|
-
# A source as the target sees it (a session: its own, then its
|
|
406
|
+
# A source as the target sees it (a session: its own, then its
|
|
407
|
+
# project's), for refresh and mute (local only).
|
|
383
408
|
# @return [ContextSources::Attached, nil] nil after an error line
|
|
384
409
|
def find(target, name)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
location = location_of(target)
|
|
388
|
-
source = location.source(name)
|
|
389
|
-
source && ContextSources::Attached.new(source: source, location: location, shadowed: false)
|
|
390
|
-
else
|
|
391
|
-
attached_to(target).find { |a| a.name == name }
|
|
392
|
-
end
|
|
393
|
-
error_line("#{command_name}: no source #{name} for #{target.label}") unless found
|
|
410
|
+
found = @access.context_attached(target, name)
|
|
411
|
+
no_source(target, name) unless found
|
|
394
412
|
found
|
|
395
413
|
end
|
|
396
414
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def rows_for(target)
|
|
403
|
-
if target.project?
|
|
404
|
-
location = location_of(target)
|
|
405
|
-
return location.sources.map { |source| row(target, source, location.snapshot(source.name)) }
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
own = ContextSources.session_location(target.session_id, state_dir: @state_dir)
|
|
409
|
-
subs = own.subscriptions
|
|
410
|
-
attached_to(target, shadowed: true).map do |a|
|
|
411
|
-
snapshot = a.snapshot
|
|
412
|
-
read = subs[a.name]&.read
|
|
413
|
-
row(target, a.source, snapshot, muted: own.muted?(a.name), shadowed: a.shadowed,
|
|
414
|
-
unread: snapshot.text? && read != snapshot.revision)
|
|
415
|
-
end
|
|
415
|
+
# @return [Integer] 1, after the line
|
|
416
|
+
def no_source(target, name)
|
|
417
|
+
error_line("#{command_name}: no source #{name} for #{target.label}")
|
|
418
|
+
CLI::Exit::FAILED
|
|
416
419
|
end
|
|
417
420
|
|
|
418
|
-
|
|
421
|
+
# One line of ls (its --format json object).
|
|
422
|
+
# @param entry [SessionAccess::ContextEntry]
|
|
423
|
+
def row(target, entry)
|
|
424
|
+
source = entry.source
|
|
425
|
+
snapshot = entry.snapshot
|
|
419
426
|
{ "target" => target.label, "session_id" => target.session_id, "name" => source.name, "scope" => source.scope,
|
|
420
427
|
"cmd" => source.cmd, "every_seconds" => source.every_seconds, "why" => source.why, "hint" => source.hint,
|
|
421
428
|
"added_by" => source.added_by, "created_at" => source.created_at, "fetched_at" => snapshot.fetched_at,
|
|
422
429
|
"revision" => snapshot.revision, "summary" => snapshot.summary, "error" => snapshot.error,
|
|
423
|
-
"muted" => muted, "shadowed" => shadowed, "unread" => unread }.compact
|
|
430
|
+
"muted" => entry.muted, "shadowed" => entry.shadowed, "unread" => entry.unread }.compact
|
|
424
431
|
end
|
|
425
432
|
|
|
426
433
|
def print_rows(rows, labels:)
|