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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/bin/chi +31 -196
  4. data/docs/architecture.md +62 -6
  5. data/docs/cli.md +154 -7
  6. data/docs/configuration.md +32 -5
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +1 -1
  10. data/docs/internals/llm-context-forget.md +25 -1
  11. data/docs/internals/prompt-caching.md +8 -1
  12. data/docs/internals/remote-sessions.md +303 -0
  13. data/docs/internals/working-set.md +174 -15
  14. data/docs/plugins.md +32 -5
  15. data/docs/sessions.md +38 -14
  16. data/docs/sub-agent.md +8 -0
  17. data/docs/testing.md +7 -0
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  20. data/lib/samagotchi/bridge.rb +33 -7
  21. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  22. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  23. data/lib/samagotchi/bridge_client.rb +22 -38
  24. data/lib/samagotchi/bundle_command/check.rb +5 -1
  25. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  27. data/lib/samagotchi/bundle_command.rb +4 -4
  28. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  30. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  32. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  33. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  34. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  35. data/lib/samagotchi/chi_invocation.rb +43 -0
  36. data/lib/samagotchi/cli/command.rb +3 -2
  37. data/lib/samagotchi/cli/flags.rb +5 -0
  38. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  39. data/lib/samagotchi/commands/registry.rb +12 -1
  40. data/lib/samagotchi/config.rb +89 -53
  41. data/lib/samagotchi/context_command.rb +80 -73
  42. data/lib/samagotchi/engine.rb +138 -15
  43. data/lib/samagotchi/idle_recap.rb +7 -1
  44. data/lib/samagotchi/idle_scheduler.rb +16 -1
  45. data/lib/samagotchi/kernel_loop.rb +13 -1
  46. data/lib/samagotchi/launch_mode.rb +22 -0
  47. data/lib/samagotchi/llm_context_edit.rb +32 -10
  48. data/lib/samagotchi/llm_context_forget.rb +24 -8
  49. data/lib/samagotchi/llm_context_view.rb +19 -7
  50. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  51. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  52. data/lib/samagotchi/message_parts.rb +8 -3
  53. data/lib/samagotchi/note_command.rb +8 -7
  54. data/lib/samagotchi/parent_report.rb +55 -43
  55. data/lib/samagotchi/plugin/context.rb +2 -2
  56. data/lib/samagotchi/prompt.rb +1 -1
  57. data/lib/samagotchi/recap_command.rb +68 -0
  58. data/lib/samagotchi/recap_setup.rb +27 -14
  59. data/lib/samagotchi/remote_command.rb +170 -0
  60. data/lib/samagotchi/remotes.rb +130 -0
  61. data/lib/samagotchi/run_options.rb +233 -0
  62. data/lib/samagotchi/send_command/busy.rb +58 -0
  63. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  64. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  65. data/lib/samagotchi/send_command.rb +186 -118
  66. data/lib/samagotchi/session.rb +48 -16
  67. data/lib/samagotchi/session_access/contract.rb +129 -0
  68. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  69. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  70. data/lib/samagotchi/session_access/local.rb +349 -0
  71. data/lib/samagotchi/session_access/remote.rb +638 -0
  72. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  73. data/lib/samagotchi/session_access.rb +141 -0
  74. data/lib/samagotchi/session_archive_command.rb +9 -5
  75. data/lib/samagotchi/session_commands.rb +53 -6
  76. data/lib/samagotchi/session_config.rb +411 -0
  77. data/lib/samagotchi/session_delete_command.rb +8 -4
  78. data/lib/samagotchi/session_manager.rb +7 -2
  79. data/lib/samagotchi/session_setup.rb +15 -10
  80. data/lib/samagotchi/sessions_command.rb +42 -16
  81. data/lib/samagotchi/terminal_ui/attach_launcher.rb +54 -18
  82. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +82 -76
  84. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  85. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  86. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  87. data/lib/samagotchi/terminal_ui.rb +8 -6
  88. data/lib/samagotchi/testing/bundle.rb +3 -2
  89. data/lib/samagotchi/title_command.rb +9 -11
  90. data/lib/samagotchi/version.rb +1 -1
  91. data/lib/samagotchi/web/app.rb +387 -79
  92. data/lib/samagotchi/web/public/app.js +2 -0
  93. data/lib/samagotchi/web/public/commands.js +8 -2
  94. data/lib/samagotchi/web/public/data.js +6 -4
  95. data/lib/samagotchi/web/public/index.html +37 -3
  96. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  97. data/lib/samagotchi/web/public/stage_view.js +3 -2
  98. data/lib/samagotchi/web/public/stream_handlers.js +7 -1
  99. data/lib/samagotchi/web/public/turn_html.js +5 -0
  100. data/lib/samagotchi/web/public/working_set.js +247 -24
  101. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  102. data/lib/samagotchi/web/public/working_set_view.js +198 -25
  103. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  104. data/lib/samagotchi/worker.rb +9 -6
  105. data/lib/samagotchi/worker_sidecar.rb +12 -5
  106. data/lib/samagotchi/working_set.rb +132 -20
  107. data/lib/samagotchi/working_set_command.rb +222 -0
  108. data/lib/samagotchi/working_set_pin.rb +34 -0
  109. data/lib/samagotchi/working_set_steer.rb +414 -0
  110. metadata +23 -2
  111. 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 @state_dir, and may override the constants
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 = run_parsed(options)
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
- id = Session.resolve_id(given, state_dir: @state_dir)
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 = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
74
- owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL, turn: turn,
75
- queued: queued, ended_before: ended_before)
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 own.
80
- strategy = ParentReport.strategy_for(id, state_dir: @state_dir)
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: chi --attach #{id}")
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" if @entries.any? { |entry| entry.name == name }
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) }
@@ -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
- Entry.new(key: "recap.enabled", yaml_path: %w[recap enabled], type: :bool, default: nil, expose: %i[env config], lifecycle: :worker_start),
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, Engine#read_bundle_settings.
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
- # Coercion helpers
346
- def coerce(entry, raw)
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
- # For string, preserve as-is (including trailing spaces like "Please ")
350
- if entry.type == :string
351
- # A YAML list for a "|"-separated setting (web.annotate_presets).
352
- str = raw.is_a?(Array) ? raw.join("|") : raw.to_s
353
- return nil if str.empty? && entry.type != :string
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
- return str
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
- str = raw.to_s.strip
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
- Integer(str, exception: false).tap do |v|
365
- if v.nil?
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
- val = Float(str, exception: false)
372
- if val.nil?
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
- case str.downcase
379
- when "1", "true", "yes", "on" then true
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
- str
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
- def resolve_with_origin(key, file_data: nil, env: ENV, cli_overrides: {})
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
- def get(key)
527
- get_with_origin(key).first
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: inside_session ? "agent" : "cli", created_at: Time.now.utc.iso8601
138
+ scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601
131
139
  )
132
140
  each_target(options, args) do |target|
133
- location = location_of(target)
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: inside_session ? "agent" : "cli", created_at: Time.now.utc.iso8601, provider: resolved.bundle
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
- location = location_of(target)
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
- attached = find(target, name) or next false
177
- # A command source's text is its command's (R2): a push between
178
- # two fetches would read as a change and then vanish.
179
- unless attached.source.push?
180
- error_line("#{command_name}: #{name} runs a command, which writes its text: push goes to a --push source")
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
- before, written = attached.location.locked(name) do
185
- [attached.location.snapshot(name).revision, attached.location.record_text(name, fetched)]
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| rows_for(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
- attached = find(targets.first, name) or return CLI::Exit::FAILED
216
- snapshot = attached.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" => attached.source.to_h, "snapshot" => snapshot.to_h }))
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
- location = location_of(target)
275
- if location.remove(name)
297
+ case @access.context_remove(target, name)
298
+ when :removed
276
299
  @stdout.puts("#{target.label} detached #{name}")
277
300
  next true
278
- end
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
- error_line("#{command_name}: give session ids (or unique prefixes), or --project; " \
333
- "inside a chi session it's that session")
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 = Session.resolve_id(given, state_dir: @state_dir)
358
- session = Session.load(id, state_dir: @state_dir)
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 project's).
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
- ContextSources.check_name!(name)
386
- found = if target.project?
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
- def attached_to(target, shadowed: false)
398
- ContextSources.attached(target.session_id, project_root: target.project_root, state_dir: @state_dir,
399
- shadowed: shadowed)
400
- end
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
- def row(target, source, snapshot, muted: nil, shadowed: nil, unread: nil)
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:)