samagotchi 0.3.0 → 0.4.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +16 -0
  4. data/bin/chi +32 -31
  5. data/docs/cli.md +77 -5
  6. data/docs/configuration.md +104 -2
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +88 -6
  10. data/docs/releasing.md +6 -6
  11. data/docs/sessions.md +19 -17
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +4 -1
  14. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  15. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  16. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  18. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  19. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  20. data/lib/samagotchi/client.rb +9 -6
  21. data/lib/samagotchi/commands/registry.rb +8 -0
  22. data/lib/samagotchi/config.rb +64 -20
  23. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  25. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  26. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  27. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  28. data/lib/samagotchi/desktop/macos.rb +59 -8
  29. data/lib/samagotchi/desktop_command.rb +6 -3
  30. data/lib/samagotchi/edit_preview.rb +82 -0
  31. data/lib/samagotchi/engine.rb +201 -104
  32. data/lib/samagotchi/gem_update.rb +89 -0
  33. data/lib/samagotchi/guardrails/approval.rb +26 -4
  34. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  35. data/lib/samagotchi/host_registry.rb +8 -12
  36. data/lib/samagotchi/idle_client.rb +24 -15
  37. data/lib/samagotchi/idle_reminders.rb +2 -2
  38. data/lib/samagotchi/image_store.rb +10 -6
  39. data/lib/samagotchi/kernel_loop.rb +26 -79
  40. data/lib/samagotchi/live_versions.rb +59 -0
  41. data/lib/samagotchi/llm/api_key.rb +41 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  43. data/lib/samagotchi/llm/errors.rb +21 -7
  44. data/lib/samagotchi/llm/http.rb +15 -4
  45. data/lib/samagotchi/llm/openai_chat.rb +5 -26
  46. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  47. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  49. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  50. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  51. data/lib/samagotchi/model_profile.rb +23 -0
  52. data/lib/samagotchi/prompt.rb +4 -2
  53. data/lib/samagotchi/reminder_store.rb +1 -9
  54. data/lib/samagotchi/self_report.rb +17 -3
  55. data/lib/samagotchi/send_command.rb +107 -12
  56. data/lib/samagotchi/session_commands.rb +38 -8
  57. data/lib/samagotchi/session_manager.rb +1 -16
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
  59. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
  60. data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
  61. data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  63. data/lib/samagotchi/terminal_ui.rb +62 -248
  64. data/lib/samagotchi/text_diff.rb +181 -0
  65. data/lib/samagotchi/thinking.rb +115 -0
  66. data/lib/samagotchi/tool_runner.rb +34 -1
  67. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  68. data/lib/samagotchi/tools/edit.rb +23 -9
  69. data/lib/samagotchi/tools/write.rb +4 -0
  70. data/lib/samagotchi/turn_flow.rb +12 -2
  71. data/lib/samagotchi/update_command.rb +308 -0
  72. data/lib/samagotchi/update_hint.rb +59 -0
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +6 -4
  75. data/lib/samagotchi/web/app.rb +3 -3
  76. data/lib/samagotchi/web/message_parts.rb +8 -3
  77. data/lib/samagotchi/web/public/activity.js +3 -0
  78. data/lib/samagotchi/web/public/app.js +36 -24
  79. data/lib/samagotchi/web/public/chat_view.js +3 -0
  80. data/lib/samagotchi/web/public/data.js +2 -0
  81. data/lib/samagotchi/web/public/diff_view.js +58 -0
  82. data/lib/samagotchi/web/public/index.html +22 -1
  83. data/lib/samagotchi/web/public/question_card.js +3 -1
  84. data/lib/samagotchi/web/public/turn_events.js +29 -5
  85. data/lib/samagotchi/web/public/turn_view.js +2 -1
  86. data/lib/samagotchi/worker.rb +5 -4
  87. metadata +12 -1
@@ -4,6 +4,7 @@ require_relative "session"
4
4
  require_relative "session_manager"
5
5
  require_relative "context_quote"
6
6
  require_relative "reply_wait"
7
+ require_relative "image_store"
7
8
 
8
9
  module Samagotchi
9
10
  # `chi send`: put text into sessions as the user's message, the same as
@@ -19,16 +20,23 @@ module Samagotchi
19
20
  # No live worker this long while waiting: it died before it could mark
20
21
  # the session (a worker takes well under a second to start).
21
22
  WORKER_GONE_AFTER = 5
23
+ # The Bridge's cap on one turn's images (Bridge::MAX_TURN_IMAGES).
24
+ MAX_IMAGES = 20
22
25
 
23
26
  USAGE = <<~TEXT
24
- Usage: chi send [-m TEXT] (ID|PREFIX)...
25
- chi send --new [--dir DIR] [--model M] [-m TEXT]
26
- chi send --wait [--timeout S] [-m TEXT] (--new | ID)
27
+ Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
28
+ chi send --new [--dir DIR] [--model M] [-m TEXT] [--image PATH]...
29
+ chi send --wait [--timeout S] [-m TEXT] [--image PATH]... (--new | ID)
27
30
  chi send --wait [--timeout S] ID
28
31
  Sends a message to each session, as if typed in it: a turn starts,
29
32
  or a running one picks it up. A stopped session's worker starts.
30
33
  -m TEXT the message; stdin, when piped too, goes above it as a
31
34
  quote (context); without -m, stdin is the message
35
+ --image PATH
36
+ an image sent with the message (png, jpeg, gif, webp;
37
+ others converted, large ones downscaled); repeat for
38
+ more, up to 20. Needs text too (-m or stdin) and a
39
+ model that sees images
32
40
  --new start a new session with the message instead, as the
33
41
  web does, and print its id
34
42
  --dir DIR (--new) its folder, the project it belongs to; default
@@ -65,6 +73,11 @@ module Samagotchi
65
73
  # With --wait stdout is the answer alone.
66
74
  @info = options[:wait] ? @stderr : @stdout
67
75
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
76
+ # Before the wait-only case, which would drop the images.
77
+ if prompt.nil? && !options[:images].empty?
78
+ usage_error("--image needs a message: pass -m TEXT or pipe it in")
79
+ return 2
80
+ end
68
81
  return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
69
82
  unless prompt
70
83
  usage_error("no message: pass -m TEXT or pipe it in")
@@ -76,6 +89,7 @@ module Samagotchi
76
89
  @stderr.puts("chi send: #{e.message}")
77
90
  return 1
78
91
  end
92
+ return 2 unless ingest_images(options[:images])
79
93
 
80
94
  return run_new(prompt, options) if options[:new]
81
95
  return run_wait(prompt, options) if options[:wait]
@@ -91,12 +105,14 @@ module Samagotchi
91
105
  deliver(id, prompt)
92
106
  end
93
107
  results.all? ? 0 : 1
108
+ ensure
109
+ FileUtils.rm_rf(@image_dir) if @image_dir
94
110
  end
95
111
 
96
112
  private
97
113
 
98
114
  def parse
99
- options = { ids: [] }
115
+ options = { ids: [], images: [] }
100
116
  until @argv.empty?
101
117
  arg = @argv.shift
102
118
  case arg
@@ -110,6 +126,8 @@ module Samagotchi
110
126
  when "--dir", "--model"
111
127
  options[arg.delete_prefix("--").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
112
128
  when /\A--(dir|model)=(.*)\z/m then options[Regexp.last_match(1).to_sym] = Regexp.last_match(2)
129
+ when "--image" then options[:images] << (@argv.shift or return usage_error("#{arg} needs a value"))
130
+ when /\A--image=(.*)\z/m then options[:images] << Regexp.last_match(1)
113
131
  when "--wait" then options[:wait] = true
114
132
  when "--timeout" then options[:timeout] = @argv.shift or return usage_error("#{arg} needs a value")
115
133
  when /\A--timeout=(.*)\z/ then options[:timeout] = Regexp.last_match(1)
@@ -127,6 +145,7 @@ module Samagotchi
127
145
  return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
128
146
  end
129
147
  return usage_error("--wait takes one session") if options[:wait] && options[:ids].uniq.size > 1
148
+ return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
130
149
  return new_options(options) if options[:new]
131
150
  %i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
132
151
  return usage_error("give session ids") if options[:ids].empty?
@@ -180,18 +199,27 @@ module Samagotchi
180
199
 
181
200
  # A worker session like the web start page's: saved as running with the
182
201
  # message before its worker spawns, so lists and the web show it at
183
- # once. The full id, so a script can pass it on. The model name isn't
184
- # checked here (nor in the web): a wrong one fails in the worker.
202
+ # once. The full id, so a script can pass it on. Only the model's host
203
+ # is checked here (in spawn_session, as in the web): a wrong model id
204
+ # fails in the worker.
185
205
  # @return [Integer] the exit status
186
206
  def run_new(prompt, options)
207
+ images = !@images.empty?
187
208
  begin
188
- session = SessionManager.spawn_session(prompt: prompt, working_directory: options[:dir],
189
- model_name: options[:model], state_dir: @state_dir)
209
+ # With images the web's way: idle (the message names it in the
210
+ # lists), the images copied in, then the message as a turn.
211
+ start = images ? { prompt: nil, title: prompt } : { prompt: prompt }
212
+ session = SessionManager.spawn_session(**start, working_directory: options[:dir],
213
+ model_name: options[:model], state_dir: @state_dir)
190
214
  rescue StandardError => e
191
215
  error_line("chi send: could not start a session: #{e.message}")
192
216
  return 1
193
217
  end
194
- @info.puts("#{session.id} started")
218
+ if images
219
+ return 1 unless deliver_new(session, prompt)
220
+ else
221
+ @info.puts("#{session.id} started")
222
+ end
195
223
  return 0 unless options[:wait]
196
224
 
197
225
  wait_for_reply(session.id, cursor: nil, baseline: { messages: session.messages.size, question_id: nil },
@@ -269,21 +297,56 @@ module Samagotchi
269
297
  0
270
298
  end
271
299
 
300
+ # A new idle session's first turn, with the images. Its worker's Bridge
301
+ # first: deliver_turn wakes a worker when none owns the session yet,
302
+ # and the spawned one takes its lock only once it runs.
303
+ # @return [Boolean] whether the message was queued
304
+ def deliver_new(session, prompt)
305
+ dir = Session.session_dir(session.id, state_dir: @state_dir)
306
+ result = nil
307
+ begin
308
+ refs = copy_images(dir)
309
+ if BridgeClient.wait_for(session.id, session_dir: dir, timeout: SessionManager::TURN_BRIDGE_WAIT)
310
+ result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: CLIENT_ID, images: refs,
311
+ state_dir: @state_dir)
312
+ end
313
+ rescue StandardError => e
314
+ @info.puts("#{session.id} failed: #{e.message}")
315
+ return false
316
+ end
317
+ # Kept, not deleted: it holds the images, and the message shows as
318
+ # its preview; the user can attach and send it again.
319
+ unless result
320
+ @info.puts("#{session.id} failed: its worker did not start; the session is kept (chi --attach #{session.id})")
321
+ return false
322
+ end
323
+ unless result[:status] == :accepted
324
+ @info.puts("#{session.id} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
325
+ return false
326
+ end
327
+
328
+ @info.puts("#{session.id} started#{with_images}")
329
+ true
330
+ end
331
+
272
332
  # @return [Boolean] whether the message was queued
273
333
  def deliver(id, prompt)
274
334
  short = id[0, 8]
275
335
  owner = SessionManager.session_owner(id, state_dir: @state_dir)
276
336
  running = owner && Session.load(id, state_dir: @state_dir).status == Session::STATUS_RUNNING
277
- result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, state_dir: @state_dir)
337
+ refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
338
+ result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir)
278
339
  unless result[:status] == :accepted
279
340
  @info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
280
341
  return false
281
342
  end
282
343
 
344
+ # A busy worker runs a message with images as its own next turn
345
+ # rather than merging it into the running one.
283
346
  note = if owner.nil? then " (started its worker)"
284
- elsif running then " (the running turn picks it up)"
347
+ elsif running then refs.empty? ? " (the running turn picks it up)" : " (runs after the current turn)"
285
348
  end
286
- @info.puts("#{short} sent#{note}")
349
+ @info.puts("#{short} sent#{with_images}#{note}")
287
350
  true
288
351
  rescue SessionManager::OwnedByTUI
289
352
  @info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
@@ -293,6 +356,38 @@ module Samagotchi
293
356
  false
294
357
  end
295
358
 
359
+ # Each --image read, converted and downscaled once, into a scratch
360
+ # session dir; each target then gets copies of the stored files. A file
361
+ # that can't be sent stops everything before anything is sent.
362
+ # @return [Boolean] false after the error line
363
+ def ingest_images(paths)
364
+ @images = []
365
+ return true if paths.empty?
366
+
367
+ @image_dir = Dir.mktmpdir("chi-send-images")
368
+ @images = paths.map { |path| ImageStore.ingest(@image_dir, path: path) }
369
+ true
370
+ rescue ImageStore::Error => e
371
+ error_line("chi send: #{e.message}")
372
+ false
373
+ end
374
+
375
+ # The images' files copied into a session dir, as the refs a turn takes.
376
+ def copy_images(session_dir)
377
+ @images.map do |ref|
378
+ ImageStore.copy_file(ref, from: @image_dir, to: session_dir)
379
+ { file: ref[:file], name: ref[:name] }
380
+ end
381
+ end
382
+
383
+ def with_images
384
+ case @images.size
385
+ when 0 then ""
386
+ when 1 then " with 1 image"
387
+ else " with #{@images.size} images"
388
+ end
389
+ end
390
+
296
391
  # The text as UTF-8 whatever the locale says: with no LANG/LC_* (an app
297
392
  # started from Finder, launchd) stdin reads as US-ASCII and ARGV as
298
393
  # binary. Invalid bytes become U+FFFD rather than an error.
@@ -15,8 +15,10 @@ module Samagotchi
15
15
  # the Engine and its TurnFlow; the host prints the result's output and
16
16
  # runs a continue turn when asked to (#run never runs a turn).
17
17
  #
18
- # /stats, /recap and /exit are the UI's own: they are in the registry
19
- # (local: for Tab and help) but #run never runs them.
18
+ # /stats, /recap, /exit, /quit, /archive and /detach are the terminal
19
+ # UIs' own: they are in the registry (local:) for Tab and help, and so
20
+ # both TUIs read the same words (Registry#lookup_local gives the entry,
21
+ # its id says what to do), but #run never runs them.
20
22
  class SessionCommands
21
23
  MODEL_COMMAND = "/model"
22
24
  MODELS_COMMAND = "/models"
@@ -30,6 +32,8 @@ module Samagotchi
30
32
  SHELL_BANG_PREFIX = "!"
31
33
  RESERVED_MODEL_ARGS = %w[clear default none off].freeze
32
34
  ALIAS_USAGE = "usage /model <model> --alias <name> [--default]"
35
+ # /exit --delete: delete the session on the way out.
36
+ EXIT_DELETE_FLAG = "--delete"
33
37
 
34
38
  # @!attribute status [Symbol] :ok, or :error when the line was refused
35
39
  # @!attribute output [String, nil] what to tell the user
@@ -66,13 +70,28 @@ module Samagotchi
66
70
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
67
71
  registry.register("/stats", "show the session's stats", local: true)
68
72
  registry.register("/recap", "show the session's recap", local: true)
69
- registry.register("/exit", "leave (--delete also deletes the session)", local: true)
70
- registry.register("/archive", "leave and archive the session: hidden from the lists, kept for good", local: true)
71
- registry.register("/quit", "leave, like /exit", local: true, uis: [:attached])
72
- registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached])
73
+ # Bare `exit` too; any case; --delete after it.
74
+ registry.register("/exit", "leave (--delete also deletes the session)", local: true,
75
+ match: exit_match("/?exit"))
76
+ registry.register("/quit", "leave, like /exit", id: :exit, local: true, match: exit_match("/quit"))
77
+ registry.register("/archive", "leave and archive the session: hidden from the lists, kept for good", local: true,
78
+ match: ->(text) { text.casecmp?("/archive") })
79
+ # The REPL owns its session: it answers /detach with a note, and doesn't offer it.
80
+ registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
81
+ match: ->(text) { text.casecmp?("/detach") })
73
82
  registry
74
83
  end
75
84
 
85
+ # @param word [String] the regexp source of the exit word
86
+ def self.exit_match(word)
87
+ pattern = /\A#{word}(?:\s+#{EXIT_DELETE_FLAG})?\z/i
88
+ ->(text) { text.match?(pattern) }
89
+ end
90
+ private_class_method :exit_match
91
+
92
+ # @return [Boolean] an exit line (/exit, /quit, exit) that deletes the session too
93
+ def self.delete_on_exit?(line) = line.to_s.split.last.to_s.casecmp?(EXIT_DELETE_FLAG)
94
+
76
95
  # The built-ins alone, for callers without an Engine (an attached TUI
77
96
  # before its snapshot names the session's commands, specs).
78
97
  # @return [Commands::Registry] frozen
@@ -277,15 +296,18 @@ module Samagotchi
277
296
  def model(text)
278
297
  output, switched = model_command(text)
279
298
  reply(output, changed: switched ? [:model] : [])
299
+ rescue ModelProfile::MissingModel => e
300
+ # A model qualified with an unknown host: nothing switched.
301
+ reply(e.message, status: :error)
280
302
  end
281
303
 
282
304
  # @return [Array(String, Boolean)] the message, and whether the model changed
283
305
  def model_command(input)
284
306
  suffix = input.delete_prefix(MODEL_COMMAND).strip
285
307
  if suffix.empty?
286
- return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}", false] if model_name == @default_model
308
+ return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}", false] if model_name == @default_model
287
309
 
288
- return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}", false]
310
+ return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}", false]
289
311
  end
290
312
 
291
313
  # Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
@@ -420,6 +442,14 @@ module Samagotchi
420
442
  ""
421
443
  end
422
444
 
445
+ # "; thinking: off (models: qwen)" when the model has a level set.
446
+ def thinking_note
447
+ summary = @engine.respond_to?(:thinking_summary) ? @engine.thinking_summary : nil
448
+ summary ? "; thinking: #{summary}" : ""
449
+ rescue StandardError
450
+ ""
451
+ end
452
+
423
453
  # " (default: x, profile=qwen36, name)": +extra+ and the prompt profile,
424
454
  # which a chat host's model doesn't have (its loop uses none); "" when
425
455
  # there is neither.
@@ -148,7 +148,7 @@ module Samagotchi
148
148
  sd = state_dir || Session.default_state_dir
149
149
  session = Session.new_session(
150
150
  mode: mode,
151
- model_name: model_name || Samagotchi::ModelProfile.required_model_name,
151
+ model_name: Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name)),
152
152
  working_directory: working_directory || Dir.pwd,
153
153
  preloaded_memory_names: memories,
154
154
  muted_memory_names: muted_memories,
@@ -697,21 +697,6 @@ module Samagotchi
697
697
  true
698
698
  end
699
699
 
700
- # Wait for a session to reach a terminal state (completed, error, stopped).
701
- # Returns true if the session finished, false if the timeout elapsed.
702
- def self.wait_for_session(session_id, timeout: 30, state_dir: nil)
703
- sd = state_dir || Session.default_state_dir
704
- elapsed = 0
705
- while elapsed < timeout
706
- session = Session.load(session_id, state_dir: sd)
707
- return true if %w[completed error stopped].include?(session.status)
708
-
709
- sleep(0.5)
710
- elapsed += 0.5
711
- end
712
- false
713
- end
714
-
715
700
  # Run the session loop inside the forked process.
716
701
  # This is the entry point called by Process.spawn.
717
702
  #
@@ -31,19 +31,9 @@ module Samagotchi
31
31
  include Formatting
32
32
  include InputSupport
33
33
 
34
- STATS_COMMAND = "/stats"
35
- RECAP_COMMAND = "/recap"
36
34
  PROMPT = "> "
37
- # Detach and leave the worker up.
38
- DETACH_COMMANDS = %w[/detach].freeze
39
- # Detach and ask the worker to exit (bare `exit` too, as in the REPL).
40
- EXIT_COMMANDS = %w[/exit /quit].freeze
41
- # After an exit command: delete the session once the worker has gone.
42
- DELETE_FLAG = "--delete"
43
35
  # How long /exit --delete waits for the worker to let go.
44
36
  DELETE_WAIT = 10
45
- # Leave, and archive the session (hidden from the lists, kept for good).
46
- ARCHIVE_COMMAND = "/archive"
47
37
  # A second Ctrl-C at an empty idle prompt within this many seconds detaches.
48
38
  DETACH_WINDOW = 2.0
49
39
  DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
@@ -269,23 +259,26 @@ module Samagotchi
269
259
 
270
260
  text = line.strip
271
261
  return answer_question(text) if @question
272
- # Before the continue offer: neither is an answer to it.
273
- return submit(nil) if DETACH_COMMANDS.include?(text.downcase)
274
- return exit_worker if exit_command?(text)
275
- return exit_and_delete if exit_command?(text.delete_suffix(DELETE_FLAG).rstrip) && text.end_with?(" #{DELETE_FLAG}")
276
- return exit_and_archive if text.casecmp?(ARCHIVE_COMMAND)
277
- command = text.split(/\s+/, 2).first
262
+
263
+ # The terminal's own commands (SessionCommands registers them, the
264
+ # REPL reads the same words). Before the continue offer: the
265
+ # leaving ones are no answer to it.
266
+ local = command_registry.lookup_local(text)&.id
267
+ return submit(nil) if local == :detach
268
+ return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
269
+ return exit_and_archive if local == :archive
270
+
278
271
  if @continue_offer
279
- return answer_continue(text) unless command_registry.command?(text) || [STATS_COMMAND, RECAP_COMMAND].include?(command)
272
+ return answer_continue(text) unless local || command_registry.command?(text)
280
273
 
281
274
  # Not an answer: the read left no echo, so show what ran.
282
275
  echo_answer(text)
283
276
  end
284
277
  return if text.empty?
285
278
 
286
- if command == STATS_COMMAND
279
+ if local == :stats
287
280
  show_stats
288
- elsif command == RECAP_COMMAND
281
+ elsif local == :recap
289
282
  show_recap
290
283
  elsif command_registry.command?(text)
291
284
  # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
@@ -314,8 +307,6 @@ module Samagotchi
314
307
  detach(exit_failed_line(e.message))
315
308
  end
316
309
 
317
- def exit_command?(text) = EXIT_COMMANDS.include?(text.downcase) || text.casecmp?("exit")
318
-
319
310
  # /exit --delete: the worker exits as for /exit, then the session is
320
311
  # deleted. When the worker stays up (another UI, queued input, ...),
321
312
  # nothing is deleted.
@@ -498,12 +489,10 @@ module Samagotchi
498
489
 
499
490
  def send_prompt(text)
500
491
  persist_recent_history(text)
501
- # #memory shorthand becomes words the model reads, as in the REPL.
502
- prompt = normalize_model_input(text)
503
492
  images = attach_images(text)
504
493
  return if images.nil?
505
494
 
506
- options = { prompt: prompt, client_id: @client_id }
495
+ options = { prompt: text, client_id: @client_id }
507
496
  options[:no_interrupt] = true if @no_interrupt
508
497
  options[:images] = images unless images.empty?
509
498
  reply = @client.post_turn(**options)
@@ -595,6 +584,13 @@ module Samagotchi
595
584
  # What was typed at the prompt waits for the question to close.
596
585
  @set_aside = @reader&.typed_text unless @question
597
586
  @question = QuestionPrompt.new(pending)
587
+ # An edit's diff goes above, into the scrollback, once per question
588
+ # (a join gets it from the snapshot and may get the event too).
589
+ unless @previewed_id == @question.id
590
+ @previewed_id = @question.id
591
+ preview = @question.preview_lines(paint: method(:paint))
592
+ @screen.commit(preview.join("\n")) unless preview.empty?
593
+ end
598
594
  # The prompt first, so the choices never show under the typed text.
599
595
  sync_prompt(keep_text: false)
600
596
  @screen.set_slot(:notes, @question.slot(paint: method(:paint)))
@@ -1063,7 +1059,7 @@ module Samagotchi
1063
1059
  params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
1064
1060
  status = part[:status].to_s
1065
1061
  "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
1066
- "#{format_tool_image_suffix(part[:images])}"
1062
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
1067
1063
  end
1068
1064
  end
1069
1065
  end
@@ -55,10 +55,13 @@ module Samagotchi
55
55
  when :tool_call_started
56
56
  @tool_started_at[tool_call_key(event)] = @clock.call
57
57
  @view.tool_call_feedback_started(event)
58
+ when :used_memories_updated
59
+ @view.used_memories_updated(event) if @view.respond_to?(:used_memories_updated)
58
60
  when :tool_call_completed
59
61
  @view.clear_generation_retry
60
62
  @view.tool_call_feedback_completed(event)
61
- render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images])
63
+ render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images],
64
+ diff: event[:diff])
62
65
  when :generation_completed, :generation_cancelled, :tool_dispatch_started
63
66
  @view.generation_feedback_finished
64
67
  when :pending_input_merged
@@ -104,7 +107,8 @@ module Samagotchi
104
107
  # @param event [Hash] :hook_notice event or a snapshot part with
105
108
  # hook:, text:, level:
106
109
  def self.hook_notice_line(event)
107
- label = event[:hook].to_s[/\(bundle (.+)\)\z/, 1] || "hook"
110
+ # chi's own notices name themselves with a bare word (thinking).
111
+ label = event[:hook].to_s[/\(bundle (.+)\)\z/, 1] || event[:hook].to_s[/\A[a-z][a-z0-9-]*\z/] || "hook"
108
112
  text = event[:text].to_s
109
113
  text = "warning: #{text}" if event[:level].to_s == "warn"
110
114
  "#{label}> #{text}"
@@ -162,12 +166,13 @@ module Samagotchi
162
166
 
163
167
  private
164
168
 
165
- def render_streamed_tool_activity(activity, duration_ms:, images: nil)
169
+ def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
166
170
  return if activity.nil?
167
171
 
168
172
  @streamed_tool_activity[tool_activity_key(activity)] += 1
169
173
  line = @view.format_tool_activity_line(activity, duration_ms: duration_ms)
170
174
  line += @view.format_tool_image_suffix(images) if images&.any?
175
+ line += @view.format_tool_diff_suffix(diff) if diff
171
176
  @view.print_line(line)
172
177
  end
173
178
 
@@ -50,6 +50,15 @@ module Samagotchi
50
50
  " #{paint(refs.map { |ref| "→ image #{ref[:width]}×#{ref[:height]}" }.join(", "), 90)}"
51
51
  end
52
52
 
53
+ # An edit/write row's change, after the row: " +3 −1" (green, red).
54
+ # +diff+ is EditPreview's hash, symbol or string keys (SSE, snapshot).
55
+ def format_tool_diff_suffix(diff)
56
+ return "" unless diff.is_a?(Hash)
57
+
58
+ get = ->(key) { diff[key] || diff[key.to_s] }
59
+ " #{paint("+#{get.(:added).to_i}", 32)} #{paint("\u2212#{get.(:removed).to_i}", 31)}"
60
+ end
61
+
53
62
  # A card (Engine#show_card) as a framed block: the title and its
54
63
  # source, the body as wrapped plain text (no terminal markdown), and
55
64
  # one `→ <command>` line per action, the label after it when it says
@@ -11,10 +11,10 @@ require_relative "../session_commands"
11
11
  module Samagotchi
12
12
  class TerminalUI
13
13
  # Input the REPL and the attached TUI share: the persistent prompt
14
- # history, Tab completion (/commands, @path in the cwd, #memory), the
15
- # #memory shorthand rewrite, multiline reads at the main prompt, and a
16
- # one-shot prefill of the next read (the default input, a prompt given
17
- # back after a failed turn).
14
+ # history, Tab completion (/commands, @path in the cwd, #memory, which
15
+ # inserts #name and the model reads it as typed), multiline reads at the
16
+ # main prompt, and a one-shot prefill of the next read (the default
17
+ # input, a prompt given back after a failed turn).
18
18
  #
19
19
  # Included for private use; it keeps state in @next_input_prefill and
20
20
  # reads @no_default_input.
@@ -204,21 +204,6 @@ module Samagotchi
204
204
  }
205
205
  end
206
206
 
207
- def normalize_model_input(input)
208
- input.to_s.gsub(/(^|[^\w\/])#((?:project|system)\/)?([a-zA-Z0-9][a-zA-Z0-9_-]*)/) do
209
- prefix = Regexp.last_match(1)
210
- scoped = Regexp.last_match(2).to_s
211
- name = Regexp.last_match(3)
212
- scope = scoped.delete_suffix("/")
213
- normalized = if scope.empty?
214
- "memory \"#{name}\""
215
- else
216
- "memory \"#{name}\" in #{scope} scope"
217
- end
218
- "#{prefix}#{normalized}"
219
- end
220
- end
221
-
222
207
  def history_file_path
223
208
  explicit = ENV[PROMPT_HISTORY_ENV].to_s.strip
224
209
  return explicit unless explicit.empty?
@@ -14,6 +14,8 @@ module Samagotchi
14
14
  # an optional "; reason"; a substring would pick the wrong one ("y" is
15
15
  # in "Deny").
16
16
  class QuestionPrompt
17
+ DIFF_CODES = { "+" => 32, "-" => 31, "@" => 2, "\\" => 2 }.freeze
18
+
17
19
  # A parsed answer. +error+ set: re-ask. +note+: print, then accept.
18
20
  Answer = Struct.new(:selected, :freeform, :error, :note, keyword_init: true) do
19
21
  def ok? = error.nil?
@@ -24,6 +26,8 @@ module Samagotchi
24
26
  # @param pending [Hash] Engine#pending_question (symbol or string keys)
25
27
  def initialize(pending)
26
28
  field = ->(key) { pending[key] || pending[key.to_s] }
29
+ approval = field.(:approval)
30
+ @preview = approval.is_a?(Hash) ? (approval[:preview] || approval["preview"]) : nil
27
31
  @id = field.(:id).to_s
28
32
  @question = field.(:question).to_s
29
33
  @options = Array(field.(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
@@ -49,6 +53,34 @@ module Samagotchi
49
53
  hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint)
50
54
  end
51
55
 
56
+ # The most diff lines #preview_lines prints; the web shows them all.
57
+ PREVIEW_LINES = 40
58
+
59
+ # An edit/write approval's dry-run diff (approval.preview, symbol or
60
+ # string keys: nested keys are strings after a reload or SSE), as
61
+ # lines to print above the slot: coloured +/-, dim @@, cut at
62
+ # PREVIEW_LINES. [] for none.
63
+ def preview_lines(paint: ->(text, _code) { text })
64
+ preview = @preview
65
+ return [] unless preview.is_a?(Hash)
66
+
67
+ get = ->(key) { preview[key] || preview[key.to_s] }
68
+ return [paint.("this edit would fail: #{get.(:error)}", 33)] if get.(:error)
69
+ return [paint.("diff not shown: #{get.(:skipped)}", 2)] if get.(:skipped)
70
+
71
+ lines = get.(:text).to_s.split("\n")
72
+ return [] if lines.empty?
73
+
74
+ # TextDiff's own cut note ("… N more lines") counts toward the rest.
75
+ cut = lines.last.to_s[/\A… (\d+) more lines\z/, 1]
76
+ lines.pop if cut
77
+ hidden = [lines.size - PREVIEW_LINES, 0].max + cut.to_i
78
+ shown = lines.first(PREVIEW_LINES).map { |line| paint.(line, diff_code(line)) }
79
+ shown.unshift(paint.("new file", 2)) if get.(:new_file)
80
+ shown << paint.("… #{hidden} more lines (full diff on the web)", 2) if hidden.positive?
81
+ shown
82
+ end
83
+
52
84
  # The one line that stays in the scrollback once the question closes:
53
85
  # the question and what became of it.
54
86
  # @param outcome [String] the answer (#answer_text) or what closed it
@@ -148,6 +180,9 @@ module Samagotchi
148
180
  found = options.find { |o| o.downcase == tok.downcase || o.downcase.include?(tok.downcase) }
149
181
  found ? [found, nil] : [nil, "Unknown option '#{tok}'. Use numbers 1-#{options.size} or exact labels."]
150
182
  end
183
+
184
+ # "+" green, "-" red, "@@" and "\ No newline" dim, context plain.
185
+ def diff_code(line) = DIFF_CODES.fetch(line[0], 0)
151
186
  end
152
187
  end
153
188
  end