samagotchi 0.9.0 → 0.10.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 +56 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +3 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +34 -12
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. metadata +12 -1
@@ -3,6 +3,8 @@
3
3
  require_relative "session"
4
4
  require_relative "session_inbox"
5
5
  require_relative "session_manager"
6
+ require_relative "cli/command"
7
+ require_relative "cli/flags"
6
8
 
7
9
  module Samagotchi
8
10
  # `chi note`: push text into sessions as a context note (background the
@@ -10,6 +12,8 @@ module Samagotchi
10
12
  # script, e.g. an Automator action:
11
13
  # pbpaste | chi note --source slack $(chi sessions list --live --scope=all --format tsv | cut -f1)
12
14
  class NoteCommand
15
+ include CLI::Command
16
+
13
17
  USAGE = <<~TEXT
14
18
  Usage: chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all
15
19
  Adds TEXT (or stdin) to each session as a context note: background
@@ -20,6 +24,12 @@ module Samagotchi
20
24
  Find ids with: chi sessions list --live [--scope=all] [--format tsv]
21
25
  TEXT
22
26
 
27
+ FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
28
+ f.switch "--all"
29
+ f.value "--source"
30
+ f.value "-m", "--message", key: :text
31
+ end
32
+
23
33
  # @param argv [Array<String>] the arguments after "note"
24
34
  def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil)
25
35
  @argv = argv.dup
@@ -32,14 +42,12 @@ module Samagotchi
32
42
  # @return [Integer] exit status: 0 all queued, 1 any refused or failed,
33
43
  # 2 usage
34
44
  def run
35
- options = parse or return 2
36
- return 0 if options[:help]
45
+ options = parse
46
+ return options if options.is_a?(Integer)
37
47
 
38
48
  text = utf8(options[:text] || read_stdin)
39
- unless text
40
- usage_error("no note text: pass -m TEXT or pipe it in")
41
- return 2
42
- end
49
+ return usage_error("no note text: pass -m TEXT or pipe it in") unless text
50
+
43
51
  begin
44
52
  text = SessionInbox.checked_text(text)
45
53
  rescue SessionInbox::NoteRejected => e
@@ -64,24 +72,15 @@ module Samagotchi
64
72
 
65
73
  private
66
74
 
75
+ def command_name = "chi note"
76
+
77
+ # @return [Hash, Integer] the options, or the exit status after the
78
+ # help or a usage error
67
79
  def parse
68
- options = { source: "cli", ids: [] }
69
- until @argv.empty?
70
- arg = @argv.shift
71
- case arg
72
- when "-h", "--help", "help"
73
- @stdout.puts(USAGE)
74
- return { help: true }
75
- when "--all" then options[:all] = true
76
- when "--source", "-m", "--message"
77
- value = @argv.shift or return usage_error("#{arg} needs a value")
78
- options[arg == "--source" ? :source : :text] = value
79
- when /\A--source=(.*)\z/m then options[:source] = Regexp.last_match(1)
80
- when /\A--message=(.*)\z/m then options[:text] = Regexp.last_match(1)
81
- when /\A-/ then return usage_error("unknown option #{arg}")
82
- else options[:ids] << arg
83
- end
84
- end
80
+ parsed = parse_flags(FLAGS, @argv, source: "cli")
81
+ return parsed if parsed.is_a?(Integer)
82
+
83
+ options = parsed.options.merge(ids: parsed.args)
85
84
  return usage_error("give session ids or --all") if options[:ids].empty? && !options[:all]
86
85
  return usage_error("--all takes no ids") if options[:all] && options[:ids].any?
87
86
 
@@ -127,7 +126,7 @@ module Samagotchi
127
126
  def deliver(id, text, source)
128
127
  short = id[0, 8]
129
128
  owner = SessionManager.session_owner(id, state_dir: @state_dir)
130
- if owner && owner["kind"] == "tui"
129
+ if owner&.tui?
131
130
  @stdout.puts("#{short} refused: it is open in a chi REPL; notes need attached mode")
132
131
  return false
133
132
  end
@@ -148,18 +147,5 @@ module Samagotchi
148
147
  def utf8(text)
149
148
  text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
150
149
  end
151
-
152
- # stderr isn't buffered, stdout is when it's a pipe: flush the lines
153
- # already printed, so the output keeps the order of the ids given.
154
- def error_line(text)
155
- @stdout.flush
156
- @stderr.puts(text)
157
- end
158
-
159
- def usage_error(message)
160
- error_line("chi note: #{message}")
161
- @stderr.puts(USAGE)
162
- nil
163
- end
164
150
  end
165
151
  end
@@ -20,6 +20,18 @@ module Samagotchi
20
20
  RETRY_INTERVAL = 0.05
21
21
  OWNER_READ_ATTEMPTS = 10
22
22
 
23
+ # Who holds the lock, as .owner reads it from the lock file. Its fields
24
+ # may be nil for a moment right after acquisition.
25
+ # kind: "worker" or "tui"; pid as written (an Integer).
26
+ Owner = Data.define(:pid, :kind, :started_at) do
27
+ def initialize(pid: nil, kind: nil, started_at: nil) = super
28
+
29
+ # The interactive TUI's: it reads no input files and takes no notes.
30
+ def tui? = kind == "tui"
31
+
32
+ def worker? = kind == "worker"
33
+ end
34
+
23
35
  # Take the lock, retrying for up to +wait+ seconds (another process's
24
36
  # #owner probe holds it for a moment). On success the owner's pid and kind
25
37
  # are written into the lock file for #owner to report.
@@ -42,8 +54,7 @@ module Samagotchi
42
54
 
43
55
  # The current owner, read without disturbing it.
44
56
  # @param session_dir [String]
45
- # @return [Hash, nil] {"pid", "kind", "started_at"} while held (values may
46
- # be missing for a moment right after acquisition), nil when free
57
+ # @return [Owner, nil] while held, nil when free
47
58
  def self.owner(session_dir)
48
59
  File.open(path(session_dir), File::RDONLY) do |file|
49
60
  if file.flock(File::LOCK_SH | File::LOCK_NB)
@@ -59,7 +70,7 @@ module Samagotchi
59
70
 
60
71
  sleep(RETRY_INTERVAL / 5)
61
72
  end
62
- data
73
+ Owner.new(pid: data["pid"], kind: data["kind"], started_at: data["started_at"])
63
74
  end
64
75
  rescue Errno::ENOENT
65
76
  nil
@@ -135,9 +135,10 @@ module Samagotchi
135
135
 
136
136
  # Run the block on a hook event (docs/hooks.md: :before_turn,
137
137
  # :after_turn, :before_tool_call, …), like a bundle's hooks/*.rb:
138
- # the block gets the event hash, with event[:notify] and the other
139
- # helpers, and the Context (a block may take the event alone). A
140
- # block that raises is logged (not shown) and skipped.
138
+ # the block gets the event hash and the Context (a block may take the
139
+ # event alone); while it runs, ctx.notify and the other helpers act
140
+ # as this event's (Context#with_event). A block that raises is
141
+ # logged (not shown) and skipped.
141
142
  # @param priority [Integer] lower runs first among bundle hooks
142
143
  def on(event, priority: 100, &block)
143
144
  raise ArgumentError, "on(#{event.inspect}) needs a block" unless block
@@ -219,22 +220,15 @@ module Samagotchi
219
220
  end
220
221
  @tools.each { |tool| @registries.tools.register(tool[:name], source: @bundle, **Api.entry_fields(tool, context)) }
221
222
  @hooks.each do |hook|
222
- bundle = @bundle
223
- label = @label
224
223
  block = hook[:block]
225
- context = @context
226
- # A stream hook fires ~once a second: a broken one logs once a minute.
227
- log_failure = Hooks.failure_log_gate(hook[:event])
228
- @registries.hooks.register_bundle(bundle, hook[:event], hook_name: label.split(" ").first,
229
- priority: hook[:priority]) do |event|
230
- block.call(event, context)
231
- rescue StandardError => e
232
- next unless log_failure.call
233
-
234
- # Logged only: a turn's live region is on screen.
235
- Log.warn(:plugins, "plugin_hook_failed", bundle: bundle, event: hook[:event].to_s, error: e.class.name,
236
- msg: "#{label} #{hook[:event]} hook failed: #{e.message}")
224
+ # Logged only (echo: false): a turn's live region is on screen.
225
+ handler = Hooks.wrap(label: "#{@label} #{hook[:event]} hook", event: hook[:event], policy: :log,
226
+ log: [:plugins, "plugin_hook_failed"], echo: false, fields: { bundle: @bundle }) do |event|
227
+ # ctx's helpers act as this event's while the block runs.
228
+ context ? context.with_event(event) { block.call(event, context) } : block.call(event, context)
237
229
  end
230
+ @registries.hooks.register_bundle(@bundle, hook[:event], hook_name: @label.split(" ").first,
231
+ priority: hook[:priority], &handler)
238
232
  end
239
233
  @inits.each do |init|
240
234
  block = init[:block]
@@ -19,7 +19,7 @@ module Samagotchi
19
19
  # the answer text.
20
20
  # +messages_partial+ says whether +messages+ leaves out a running turn;
21
21
  # +model_name+ and +state_dir+ are what ctx.sessions forks with.
22
- # +steer+ takes (text, source), +stop_turn+ and +stop_generation+
22
+ # +steer+ takes (text, label), +stop_turn+ and +stop_generation+
23
23
  # (reason, label), each true when it acted on a running turn.
24
24
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
25
25
  :ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, :stop_generation, keyword_init: true)
@@ -35,7 +35,17 @@ module Samagotchi
35
35
  # they run for, the bundle's settings and storage, a log, and the
36
36
  # user-facing helpers hooks have. One per plugin, for the Engine's life:
37
37
  # each read is of the session now.
38
+ #
39
+ # The helpers (notify, ask_user, steer, stop_turn, stop_generation)
40
+ # are a plugin's one surface for them. Inside one of the plugin's event
41
+ # handlers (#with_event, on the handler's thread) they are that fire's
42
+ # event[:x]: stop_turn in before_tool_call also denies the pending
43
+ # call, and steer, stop_turn and stop_generation do nothing once the
44
+ # turn is over. Anywhere else (a command, an init task, a thread the
45
+ # handler started) they act on the session now, through the Host.
38
46
  class Context
47
+ # Thread.current key: {Context => the event its handler runs for}.
48
+ CURRENT_EVENTS = :samagotchi_plugin_current_events
39
49
  # Debug-log records tagged plugins, with bundle=<bundle> (tags are a
40
50
  # closed list, LogLine::TAGS).
41
51
  class Logger
@@ -104,11 +114,34 @@ module Samagotchi
104
114
  # a plugin can say what its answer is about.
105
115
  def messages_partial? = !!@host.messages_partial&.call
106
116
 
107
- # One line to the user, labelled by the plugin, like a hook's
108
- # event[:notify]. Every UI shows it, during a turn or between turns.
117
+ # Run +block+ as this plugin's handler for +event+: until it returns,
118
+ # the helpers called on this thread act as the event's own. Nests (a
119
+ # fire inside a handler gets its own event; the outer one is back
120
+ # after it); threads the block starts don't inherit it.
121
+ # @param event [Hash] the fire's event
122
+ def with_event(event)
123
+ events = (Thread.current[CURRENT_EVENTS] ||= {}.compare_by_identity)
124
+ had = events.key?(self)
125
+ previous = events[self]
126
+ events[self] = event
127
+ yield
128
+ ensure
129
+ if had
130
+ events[self] = previous
131
+ else
132
+ events&.delete(self)
133
+ end
134
+ end
135
+
136
+ # One line to the user, labelled by the plugin (in every UI by its
137
+ # bundle's name). Every UI shows it, during a turn or between turns.
109
138
  # @param level [Symbol] :info or :warn
110
139
  def notify(text, level: :info)
111
- @host.notify.call(text.to_s, level, @label)
140
+ if (helper = event_helper(:notify))
141
+ helper.call(text.to_s, level: level)
142
+ else
143
+ @host.notify.call(text.to_s, level, @label)
144
+ end
112
145
  nil
113
146
  end
114
147
 
@@ -167,11 +200,15 @@ module Samagotchi
167
200
  @sessions ||= Sessions.new(@host)
168
201
  end
169
202
 
170
- # A single-select question through the question flow, like a hook's
171
- # event[:ask_user].
203
+ # A single-select question through the question flow. Inside a
204
+ # stream hook's handler (generation_progress) it asks no one (nil).
172
205
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
173
206
  # (no one to ask, cancelled, bad options)
174
207
  def ask_user(question:, options:, header: nil, allow_freeform: false)
208
+ if (helper = event_helper(:ask_user))
209
+ return helper.call(question: question, options: options, header: header, allow_freeform: allow_freeform)
210
+ end
211
+
175
212
  @host.ask_user.call(question: question, options: options, header: header, allow_freeform: allow_freeform,
176
213
  hook: @label)
177
214
  end
@@ -184,31 +221,49 @@ module Samagotchi
184
221
  # conversation as its own user message, shown in every UI as a nudge
185
222
  # from this bundle. It never starts a turn. True means queued: if the
186
223
  # model answers first, or the turn ends, it is dropped (logged).
187
- # Callable from a hook, a command (an anytime one runs beside the
188
- # turn) or your own thread.
224
+ # Callable from a handler (false from after_turn / session_end), a
225
+ # command (an anytime one runs beside the turn) or your own thread.
189
226
  # @return [Boolean] whether a turn was running and the text queued
190
227
  def steer(text)
191
- !!@host.steer&.call(text.to_s, @bundle)
228
+ helper = event_helper(:steer)
229
+ return !!helper.call(text.to_s) if helper
230
+
231
+ !!@host.steer&.call(text.to_s, @label)
192
232
  end
193
233
 
194
- # Stop the running turn, after a notice with +reason+, as a hook's
195
- # event[:stop_turn] does (for commands and threads).
234
+ # Stop the running turn, after a notice with +reason+. From a
235
+ # before_tool_call handler it also denies the pending call; from
236
+ # after_turn / session_end it does nothing (false).
196
237
  # @return [Boolean] whether a running turn was stopped now
197
238
  def stop_turn(reason)
239
+ helper = event_helper(:stop_turn)
240
+ return !!helper.call(reason.to_s) if helper
241
+
198
242
  !!@host.stop_turn&.call(reason.to_s, @label)
199
243
  end
200
244
 
201
- # Cut the generation that is streaming, as a hook's
202
- # event[:stop_generation] does: the turn goes on and the model is
203
- # asked again (retry.empty_answer), or with no retry left the turn
204
- # ends as cancelled (hook). Shows nothing: post your own notice.
245
+ # Cut the generation that is streaming: the turn goes on and the
246
+ # model is asked again (retry.empty_answer), or with no retry left
247
+ # the turn ends as cancelled (hook). Shows nothing: post your own
248
+ # notice. From after_turn / session_end it does nothing (false).
205
249
  # @return [Boolean] whether a streaming generation was cut now
206
250
  def stop_generation(reason)
251
+ helper = event_helper(:stop_generation)
252
+ return !!helper.call(reason.to_s) if helper
253
+
207
254
  !!@host.stop_generation&.call(reason.to_s, @label)
208
255
  end
209
256
 
210
257
  private
211
258
 
259
+ # The event[:x] helper of the event this plugin's handler runs for on
260
+ # this thread, or nil (not in a handler).
261
+ def event_helper(name)
262
+ event = Thread.current[CURRENT_EVENTS]&.[](self)
263
+ helper = event[name] if event.is_a?(Hash)
264
+ helper.respond_to?(:call) ? helper : nil
265
+ end
266
+
212
267
  def deep_freeze(value)
213
268
  case value
214
269
  when Hash then value.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }.freeze
@@ -98,10 +98,9 @@ module Samagotchi
98
98
  def instantiate(bundle_name, file, settings)
99
99
  namespace = fresh_namespace(bundle_name)
100
100
  namespace.module_eval(File.read(file), file, 1)
101
- class_name = File.basename(file, ".rb").split("_").map(&:capitalize).join
102
- klass = namespace.const_get(class_name, false)
101
+ klass = Hooks.class_for(file, namespace)
103
102
  plugin = Hooks.build_plugin(klass, settings)
104
- raise ArgumentError, "#{class_name} does not respond to #register" unless plugin.respond_to?(:register)
103
+ raise ArgumentError, "#{klass.name.split("::").last} does not respond to #register" unless plugin.respond_to?(:register)
105
104
 
106
105
  plugin
107
106
  end
@@ -12,17 +12,16 @@ module Samagotchi
12
12
  EMPTY = {}.freeze
13
13
 
14
14
  # @param target [HostRegistry::ModelTarget]
15
- # @param names [Array<String>, nil] the model as typed, alias-resolved,
16
- # bare (the Engine's lookup names); default: the target's model and bare model
15
+ # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
17
16
  # @param models [Hash, nil] ConfigFile.model_settings (specs)
18
17
  # @return [Hash] frozen, symbol keys; may be empty
19
- def self.for(target, names: nil, models: nil)
18
+ def self.for(target, names:, models: nil)
20
19
  resolve(target, names, models).first
21
20
  end
22
21
 
23
22
  # "temperature=0.6 presence_penalty=1.5 (hosts.work, models: qwen)" for
24
23
  # /model, or nil when nothing is configured.
25
- def self.summary(target, names: nil, models: nil)
24
+ def self.summary(target, names:, models: nil)
26
25
  params, sources = resolve(target, names, models)
27
26
  return nil if params.empty?
28
27
 
@@ -44,7 +43,6 @@ module Samagotchi
44
43
  rescue StandardError
45
44
  {}
46
45
  end
47
- names ||= [target.model, target.bare_model]
48
46
  host = target.entry.sampling || EMPTY
49
47
  key, model = ConfigFile.model_setting(names, :sampling, models: models)
50
48
  sources = []
@@ -97,11 +97,7 @@ module Samagotchi
97
97
  File.exist?(path) ? path : "#{path} (missing)"
98
98
  end
99
99
 
100
- def hooks_dir(env)
101
- data = ConfigFile.read_yaml(path: ConfigFile.global_path(env: env))
102
- configured = data.is_a?(Hash) && data["hooks"].is_a?(Hash) ? data["hooks"]["hooks_dir"] : nil
103
- Hooks::Loader.expand_path(configured || Hooks::Loader.default_hooks_dir(env), env)
104
- end
100
+ def hooks_dir(env) = Hooks::Loader.hooks_dir(env)
105
101
 
106
102
  # Offline, so only the fallback: the running server's n_ctx wins at runtime.
107
103
  def context_window(env)
@@ -153,8 +149,7 @@ module Samagotchi
153
149
  entry, bare = registry.host_for_model(model)
154
150
  return "name-based (chat API: only strips thoughts)" if entry&.chat?
155
151
 
156
- # As typed, the part after a host prefix (maybe an alias), alias-resolved, bare.
157
- names = [model, registry.parse_qualified_model(model).last, ConfigFile.resolve_model_alias(model, env: env), bare]
152
+ names = registry.lookup_names(model)
158
153
  override = Config.resolve_with_origin("model.profile", env: env, cli_overrides: Config.cli_overrides)
159
154
  result = ModelProfile.resolve(names: names, entry: entry, client: nil, bare_model: bare, override: override,
160
155
  models: ConfigFile.model_settings(env: env))
@@ -172,8 +167,10 @@ module Samagotchi
172
167
 
173
168
  # The model's thinking level and where it came from (Thinking.resolve).
174
169
  def thinking_for(model, env)
175
- target = HostRegistry.new(env: env).resolve(model)
176
- level, source = Thinking.resolve(target, models: ConfigFile.model_settings(env: env))
170
+ registry = HostRegistry.new(env: env)
171
+ target = registry.resolve(model)
172
+ level, source = Thinking.resolve(target, names: registry.lookup_names(model, target: target),
173
+ models: ConfigFile.model_settings(env: env))
177
174
  source ? "#{level} (#{source})" : level.to_s
178
175
  rescue StandardError => e
179
176
  "(unknown: #{e.message})"
@@ -9,6 +9,8 @@ require_relative "image_store"
9
9
  require_relative "host_registry"
10
10
  require_relative "model_profile"
11
11
  require_relative "vision_support"
12
+ require_relative "cli/command"
13
+ require_relative "cli/flags"
12
14
 
13
15
  module Samagotchi
14
16
  # `chi send`: put text into sessions as the user's message, the same as
@@ -19,13 +21,15 @@ module Samagotchi
19
21
  # --new starts a session instead, and --wait blocks for the reply and
20
22
  # prints it: an agent's one-shot the user can watch in the web.
21
23
  class SendCommand
24
+ include CLI::Command
25
+
22
26
  CLIENT_ID = "cli:send"
23
27
  POLL_INTERVAL = ReplyWait::POLL_INTERVAL
24
28
  # No live worker this long while waiting: it died before it could mark
25
29
  # the session (a worker takes well under a second to start).
26
30
  WORKER_GONE_AFTER = 5
27
- # The Bridge's cap on one turn's images (Bridge::MAX_TURN_IMAGES).
28
- MAX_IMAGES = 20
31
+ # The cap on one turn's images the Bridge checks.
32
+ MAX_IMAGES = ImageStore::MAX_TURN_REFS
29
33
 
30
34
  USAGE = <<~TEXT
31
35
  Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
@@ -59,6 +63,19 @@ module Samagotchi
59
63
  Find ids with: chi sessions list --live [--scope=all] [--format tsv]
60
64
  TEXT
61
65
 
66
+ FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
67
+ f.value "-m", "--message"
68
+ f.switch "--new"
69
+ f.value "--dir"
70
+ f.value "--model"
71
+ f.value "--image", key: :images, repeat: true
72
+ f.switch "--wait"
73
+ f.value "--timeout"
74
+ # Starting a turn in every live session at once is too easy to do
75
+ # by accident.
76
+ f.refuse "--all", "there is no --all: name the sessions"
77
+ end
78
+
62
79
  # Whether +model_name+ (a session's; blank: the configured default)
63
80
  # takes images, decided as its worker decides before a turn with images
64
81
  # (Engine#turn_vision: VisionSupport, with models.<name>.vision under
@@ -72,7 +89,7 @@ module Samagotchi
72
89
  name = ModelProfile.required_model_name(model_name)
73
90
  aliased = ConfigFile.resolve_model_alias(name)
74
91
  target = registry.resolve(aliased)
75
- names = [name, aliased, registry.parse_qualified_model(name).last, target.bare_model].compact.uniq
92
+ names = registry.lookup_names(name, target: target)
76
93
  entry = target.entry
77
94
  return VisionSupport.for(target, adapter: registry.adapter_for(entry), names: names) if entry.chat?
78
95
 
@@ -106,22 +123,17 @@ module Samagotchi
106
123
  # 2 usage; with --wait 0 answered, 3 waiting for an answer, 130
107
124
  # Ctrl-C (the turn goes on)
108
125
  def run
109
- options = parse or return 2
110
- return 0 if options[:help]
126
+ options = parse
127
+ return options if options.is_a?(Integer)
111
128
 
112
129
  # With --wait stdout is the answer alone.
113
130
  @info = options[:wait] ? @stderr : @stdout
114
131
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
115
132
  # Before the wait-only case, which would drop the images.
116
- if prompt.nil? && !options[:images].empty?
117
- usage_error("--image needs a message: pass -m TEXT or pipe it in")
118
- return 2
119
- end
133
+ return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
120
134
  return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
121
- unless prompt
122
- usage_error("no message: pass -m TEXT or pipe it in")
123
- return 2
124
- end
135
+ return usage_error("no message: pass -m TEXT or pipe it in") unless prompt
136
+
125
137
  begin
126
138
  prompt = SessionInbox.checked_text(prompt, noun: "message")
127
139
  rescue SessionInbox::NoteRejected => e
@@ -150,33 +162,15 @@ module Samagotchi
150
162
 
151
163
  private
152
164
 
165
+ def command_name = "chi send"
166
+
167
+ # @return [Hash, Integer] the options, or the exit status after the
168
+ # help or a usage error
153
169
  def parse
154
- options = { ids: [], images: [] }
155
- until @argv.empty?
156
- arg = @argv.shift
157
- case arg
158
- when "-h", "--help", "help"
159
- @stdout.puts(USAGE)
160
- return { help: true }
161
- when "-m", "--message"
162
- options[:message] = @argv.shift or return usage_error("#{arg} needs a value")
163
- when /\A--message=(.*)\z/m then options[:message] = Regexp.last_match(1)
164
- when "--new" then options[:new] = true
165
- when "--dir", "--model"
166
- options[arg.delete_prefix("--").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
167
- when /\A--(dir|model)=(.*)\z/m then options[Regexp.last_match(1).to_sym] = Regexp.last_match(2)
168
- when "--image" then options[:images] << (@argv.shift or return usage_error("#{arg} needs a value"))
169
- when /\A--image=(.*)\z/m then options[:images] << Regexp.last_match(1)
170
- when "--wait" then options[:wait] = true
171
- when "--timeout" then options[:timeout] = @argv.shift or return usage_error("#{arg} needs a value")
172
- when /\A--timeout=(.*)\z/ then options[:timeout] = Regexp.last_match(1)
173
- # Starting a turn in every live session at once is too easy to do
174
- # by accident.
175
- when "--all" then return usage_error("there is no --all: name the sessions")
176
- when /\A-/ then return usage_error("unknown option #{arg}")
177
- else options[:ids] << arg
178
- end
179
- end
170
+ parsed = parse_flags(FLAGS, @argv, images: [])
171
+ return parsed if parsed.is_a?(Integer)
172
+
173
+ options = parsed.options.merge(ids: parsed.args)
180
174
  if options[:timeout]
181
175
  return usage_error("--timeout needs --wait") unless options[:wait]
182
176
 
@@ -470,18 +464,5 @@ module Samagotchi
470
464
  def utf8(text)
471
465
  text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
472
466
  end
473
-
474
- # stderr isn't buffered, stdout is when it's a pipe: flush the lines
475
- # already printed, so the output keeps the order of the ids given.
476
- def error_line(text)
477
- @stdout.flush
478
- @stderr.puts(text)
479
- end
480
-
481
- def usage_error(message)
482
- error_line("chi send: #{message}")
483
- @stderr.puts(USAGE)
484
- nil
485
- end
486
467
  end
487
468
  end