samagotchi 0.2.0 → 0.3.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +40 -4
  4. data/bin/chi +86 -19
  5. data/docs/cli.md +108 -5
  6. data/docs/configuration.md +229 -45
  7. data/docs/desktop.md +6 -0
  8. data/docs/hooks.md +126 -5
  9. data/docs/plugins.md +68 -2
  10. data/docs/releasing.md +18 -8
  11. data/docs/sessions.md +30 -4
  12. data/lib/samagotchi/answer_display.rb +95 -0
  13. data/lib/samagotchi/archive_store.rb +90 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  16. data/lib/samagotchi/bootstrap_command.rb +347 -0
  17. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
  19. data/lib/samagotchi/bridge.rb +9 -0
  20. data/lib/samagotchi/bridge_client.rb +6 -2
  21. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  22. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  25. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  29. data/lib/samagotchi/client.rb +72 -13
  30. data/lib/samagotchi/config.rb +196 -36
  31. data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
  32. data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
  33. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  34. data/lib/samagotchi/engine.rb +233 -36
  35. data/lib/samagotchi/guardrails/approval.rb +9 -0
  36. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  37. data/lib/samagotchi/guardrails.rb +1 -0
  38. data/lib/samagotchi/hooks/registry.rb +24 -5
  39. data/lib/samagotchi/host_registry.rb +4 -3
  40. data/lib/samagotchi/idle_recap.rb +5 -1
  41. data/lib/samagotchi/kernel_loop.rb +47 -15
  42. data/lib/samagotchi/llm/chat_loop.rb +59 -20
  43. data/lib/samagotchi/llm/errors.rb +21 -3
  44. data/lib/samagotchi/llm/http.rb +42 -13
  45. data/lib/samagotchi/llm/openai_chat.rb +12 -4
  46. data/lib/samagotchi/log_subscriber.rb +18 -3
  47. data/lib/samagotchi/model_profile.rb +1 -1
  48. data/lib/samagotchi/plugin/context.rb +22 -1
  49. data/lib/samagotchi/plugin/sessions.rb +3 -1
  50. data/lib/samagotchi/reply_wait.rb +126 -0
  51. data/lib/samagotchi/sampling_settings.rb +58 -0
  52. data/lib/samagotchi/self_report.rb +1 -0
  53. data/lib/samagotchi/send_command.rb +153 -7
  54. data/lib/samagotchi/session.rb +52 -11
  55. data/lib/samagotchi/session_archive_command.rb +107 -0
  56. data/lib/samagotchi/session_commands.rb +11 -2
  57. data/lib/samagotchi/session_manager.rb +114 -9
  58. data/lib/samagotchi/session_metrics.rb +222 -106
  59. data/lib/samagotchi/steer.rb +72 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
  62. data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
  63. data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
  64. data/lib/samagotchi/terminal_ui.rb +77 -4
  65. data/lib/samagotchi/tool_activity.rb +3 -1
  66. data/lib/samagotchi/tools/builtins.rb +15 -4
  67. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  68. data/lib/samagotchi/tools/execute.rb +52 -14
  69. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  70. data/lib/samagotchi/tools/task_wait.rb +27 -3
  71. data/lib/samagotchi/turn_note.rb +60 -6
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +2 -6
  74. data/lib/samagotchi/web/app.rb +88 -4
  75. data/lib/samagotchi/web/public/activity.js +10 -1
  76. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  77. data/lib/samagotchi/web/public/annotations.js +13 -0
  78. data/lib/samagotchi/web/public/app.js +437 -88
  79. data/lib/samagotchi/web/public/card.js +5 -3
  80. data/lib/samagotchi/web/public/chat_view.js +10 -1
  81. data/lib/samagotchi/web/public/copy.js +20 -4
  82. data/lib/samagotchi/web/public/ctx.js +15 -0
  83. data/lib/samagotchi/web/public/data.js +21 -6
  84. data/lib/samagotchi/web/public/format.js +9 -0
  85. data/lib/samagotchi/web/public/index.html +38 -2
  86. data/lib/samagotchi/web/public/notify.js +175 -0
  87. data/lib/samagotchi/web/public/question_card.js +2 -1
  88. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  89. data/lib/samagotchi/web/public/timing.js +39 -14
  90. data/lib/samagotchi/web/public/turn_events.js +46 -0
  91. data/lib/samagotchi/web/public/turn_view.js +47 -7
  92. data/lib/samagotchi/web/server.rb +8 -4
  93. data/lib/samagotchi/web/session_hub.rb +2 -1
  94. data/lib/samagotchi/web/session_summary.rb +24 -1
  95. data/lib/samagotchi/worker.rb +11 -0
  96. metadata +20 -1
@@ -12,6 +12,7 @@ require_relative "reline_seam"
12
12
  require_relative "../bridge_client"
13
13
  require_relative "../log"
14
14
  require_relative "../context_note"
15
+ require_relative "../steer"
15
16
  require_relative "../model_profile"
16
17
  require_relative "../output_formatter"
17
18
  require_relative "../session_commands"
@@ -41,6 +42,8 @@ module Samagotchi
41
42
  DELETE_FLAG = "--delete"
42
43
  # How long /exit --delete waits for the worker to let go.
43
44
  DELETE_WAIT = 10
45
+ # Leave, and archive the session (hidden from the lists, kept for good).
46
+ ARCHIVE_COMMAND = "/archive"
44
47
  # A second Ctrl-C at an empty idle prompt within this many seconds detaches.
45
48
  DETACH_WINDOW = 2.0
46
49
  DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
@@ -82,11 +85,15 @@ module Samagotchi
82
85
  # the first read (a new session with no -p, as the REPL)
83
86
  # @param clock [#call] monotonic seconds (the Ctrl-C detach window)
84
87
  # @param delete_session [#call] session id -> deletes it (/exit --delete)
88
+ # @param archive_session [#call] session id -> archives it (/archive);
89
+ # SessionManager.archive_session's result
85
90
  def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
86
91
  default_input: false, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
87
- delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) })
92
+ delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
93
+ archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
88
94
  @client = client
89
95
  @delete_session = delete_session
96
+ @archive_session = archive_session
90
97
  @screen = screen
91
98
  @client_id = client_id
92
99
  @view = AttachedView.new(screen)
@@ -198,7 +205,7 @@ module Samagotchi
198
205
  # Another client's anytime command: its line now, before the cards
199
206
  # it shows (its command_ran comes when it's done).
200
207
  when :command_queued
201
- @screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id])
208
+ @screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id]) && !event[:card]
202
209
  when :command_ran
203
210
  command_ran(event)
204
211
  return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
@@ -266,6 +273,7 @@ module Samagotchi
266
273
  return submit(nil) if DETACH_COMMANDS.include?(text.downcase)
267
274
  return exit_worker if exit_command?(text)
268
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)
269
277
  command = text.split(/\s+/, 2).first
270
278
  if @continue_offer
271
279
  return answer_continue(text) unless command_registry.command?(text) || [STATS_COMMAND, RECAP_COMMAND].include?(command)
@@ -329,6 +337,27 @@ module Samagotchi
329
337
  detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): chi sessions delete #{id}")
330
338
  end
331
339
 
340
+ # /archive: the worker is asked to exit as for /exit, then the session
341
+ # is archived. A worker staying up for another UI (or reminders, a
342
+ # queued prompt) is stopped by the archive, as the web's archive
343
+ # stops it; a turn running refuses it. An empty session: the worker
344
+ # deletes it as it leaves.
345
+ def exit_and_archive
346
+ reply = @client.request_exit(client_id: @client_id)
347
+ id = @client.session_id
348
+ return detach("Detached; the session was empty, so it is discarded.") if reply.status == 200 && discards?(reply)
349
+ return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
350
+
351
+ result = @archive_session.call(id)
352
+ return detach("Detached; the session was empty, so it is discarded.") if result[:discarded].include?(id)
353
+
354
+ detach("Detached; archived session #{id}. chi sessions list --archived finds it.")
355
+ rescue SystemCallError, IOError => e
356
+ detach("#{exit_failed_line(e.message)} Not archived.")
357
+ rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError => e
358
+ detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: chi --attach #{id}")
359
+ end
360
+
332
361
  def exit_line(reply)
333
362
  id = @client.session_id
334
363
  error = reply.json&.fetch("error", nil)
@@ -401,7 +430,7 @@ module Samagotchi
401
430
  end
402
431
 
403
432
  def command_ran(event)
404
- @screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime]
433
+ @screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
405
434
  output = event[:output].to_s
406
435
  if output.empty?
407
436
  nil
@@ -816,12 +845,15 @@ module Samagotchi
816
845
  # The id stays findable: the detach line and chi sessions list show it.
817
846
  Log.session_id = @client.session_id
818
847
  Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size)
819
- last_user = exchange.rindex { |m| m[:role].to_s == "user" }
848
+ last_user = exchange.rindex { |m| Steer.prompt?(m) }
820
849
  render_join_notes(messages)
821
850
  return unless last_user
822
851
 
823
852
  @screen.commit(prompt_line(nil, exchange[last_user][:content]))
824
853
  Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
854
+ exchange[(last_user + 1)..].select { |m| Steer.steer?(m) }.each do |m|
855
+ @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
856
+ end
825
857
  # The saved answer is raw: the latest keeps its thinking, and one may
826
858
  # be only a tool call.
827
859
  answer = exchange[(last_user + 1)..].reverse_each
@@ -834,7 +866,7 @@ module Samagotchi
834
866
  # The context notes that came since the last prompt (all of them in a
835
867
  # session with none), after the exchange the header shows.
836
868
  def render_join_notes(messages, after_exchange: false)
837
- last_user = messages.rindex { |m| m[:role].to_s == "user" }
869
+ last_user = messages.rindex { |m| Steer.prompt?(m) }
838
870
  return if after_exchange != !last_user.nil?
839
871
 
840
872
  messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
@@ -894,6 +926,7 @@ module Samagotchi
894
926
  @screen.commit(snapshot_tool_line(part))
895
927
  end
896
928
  when "input" then @screen.commit("input> #{part[:text]}")
929
+ when "steer" then @screen.commit(format_steer_line(source: part[:source], text: part[:text]))
897
930
  when "reminder" then @screen.commit(reminder_line(part[:reminders]))
898
931
  when "hook_notice" then @screen.commit(EventRenderer.hook_notice_line(part))
899
932
  end
@@ -1029,7 +1062,7 @@ module Samagotchi
1029
1062
  params = part[:params].to_s.strip
1030
1063
  params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
1031
1064
  status = part[:status].to_s
1032
- "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status == "ok" ? 32 : 31)}" \
1065
+ "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
1033
1066
  "#{format_tool_image_suffix(part[:images])}"
1034
1067
  end
1035
1068
  end
@@ -63,6 +63,9 @@ module Samagotchi
63
63
  @view.generation_feedback_finished
64
64
  when :pending_input_merged
65
65
  render_merge(event)
66
+ when :empty_answer_retry
67
+ @view.finish_thinking_spinner
68
+ @view.print_line(@view.format_empty_retry_line(event))
66
69
  when :turn_completed
67
70
  render_turn_summary(event[:turn_summary]) if event[:turn_summary]
68
71
  when :guardrail_warning
@@ -146,14 +149,16 @@ module Samagotchi
146
149
  @view.print_line("iteration limit reached") if summary[:resumable]
147
150
  end
148
151
 
149
- # Steering merged into the running turn: the answer it follows first
150
- # (the turn summary shows only the last one), then the note.
151
- def render_merge(event)
152
- @view.finish_thinking_spinner
153
- @view.print_line(event[:answer]) unless event[:answer].to_s.strip.empty?
154
- count = event[:count].to_i
155
- @view.print_line("(#{count} message#{"s" unless count == 1} merged into the running turn)")
156
- end
152
+ # Steering merged into the running turn: the answer it follows first
153
+ # (the turn summary shows only the last one), then the note, then a
154
+ # line per plugin steer (none for a steer-only merge).
155
+ def render_merge(event)
156
+ @view.finish_thinking_spinner
157
+ @view.print_line(event[:answer]) unless event[:answer].to_s.strip.empty?
158
+ count = event[:count].to_i
159
+ @view.print_line("(#{count} message#{"s" unless count == 1} merged into the running turn)") if count.positive?
160
+ Array(event[:steers]).each { |steer| @view.print_line(@view.format_steer_line(source: steer[:source], text: steer[:text])) }
161
+ end
157
162
 
158
163
  private
159
164
 
@@ -13,9 +13,13 @@ module Samagotchi
13
13
  params = activity[:params].to_s.strip
14
14
  params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
15
15
  status = activity[:status].to_s
16
- status_color = status == "ok" ? 32 : 31
17
16
  elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
18
- "#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color)}#{elapsed_suffix}"
17
+ "#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color(status))}#{elapsed_suffix}"
18
+ end
19
+
20
+ # Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
21
+ def status_color(status)
22
+ { "ok" => 32, "stopped" => 33 }.fetch(status, 31)
19
23
  end
20
24
 
21
25
  # "[image shot.png 1280×800 · ~1.3k tokens]", dim: a turn's image.
@@ -23,6 +27,21 @@ module Samagotchi
23
27
  paint("[image #{ImageRef.label(ImageStore.symbolize(ref))}]", 90)
24
28
  end
25
29
 
30
+ STEER_PREVIEW = 80
31
+
32
+ # "check-in> nudged: <text>", dim, one line cut: a plugin's steer
33
+ # (Steer) in the running turn or the join's last exchange.
34
+ def format_steer_line(source:, text:)
35
+ first = text.to_s.strip.split("\n").first.to_s
36
+ first = "#{first[0, STEER_PREVIEW - 1]}…" if first.length > STEER_PREVIEW || text.to_s.strip.include?("\n")
37
+ paint("#{source.to_s.empty? ? "plugin" : source}> nudged: #{first}", 90)
38
+ end
39
+
40
+ # "↻ empty answer, asking again (1/1)": the loop retries an empty answer.
41
+ def format_empty_retry_line(event)
42
+ paint("↻ empty answer, asking again (#{event[:attempt]}/#{event[:of]})", 90)
43
+ end
44
+
26
45
  # " → image 1280×800" after a tool line whose tool read an image.
27
46
  def format_tool_image_suffix(images)
28
47
  refs = Array(images).map { |ref| ImageStore.symbolize(ref) }
@@ -229,11 +248,11 @@ module Samagotchi
229
248
  def format_session_metrics(snapshot)
230
249
  return "(no metrics yet)" unless snapshot.is_a?(Hash)
231
250
 
232
- # A symbol locally; a string when the snapshot came over the Bridge.
233
- token_src = snapshot[:token_source]&.to_s
234
- token_src_label = case token_src
251
+ tokens = snapshot[:tokens] || {}
252
+ token_src_label = case tokens[:source]
235
253
  when "server" then "server-reported"
236
254
  when "estimate" then "estimated (chars/4)"
255
+ when "mixed" then "server-reported, some estimated"
237
256
  else "n/a"
238
257
  end
239
258
 
@@ -245,13 +264,17 @@ module Samagotchi
245
264
  lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
246
265
  end
247
266
  lines << "iterations: #{snapshot[:iterations_total]}"
248
- lines << "tokens in/out: #{snapshot[:tokens_in]}/#{snapshot[:tokens_out]} (total #{snapshot[:tokens_total]}, #{token_src_label})"
267
+ # Summed over every request: each prompt is sent in full again.
268
+ lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})"
249
269
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
250
270
  lines << "cancellations: #{snapshot[:cancellations]}"
251
271
  lines << "retries: #{snapshot[:retries]}"
252
- if snapshot[:context_window_tokens]
253
- lines << "context window: #{snapshot[:context_window_tokens]} tokens (#{snapshot[:context_window_source]})"
272
+ context = snapshot[:context] || {}
273
+ if context[:used_tokens]
274
+ pct = context[:window_tokens].to_i.positive? ? format(" (%.1f%%)", context[:used_tokens] * 100.0 / context[:window_tokens]) : ""
275
+ lines << "context used: #{context[:used_tokens]} tokens#{pct}"
254
276
  end
277
+ lines << "context window: #{context[:window_tokens]} tokens (#{context[:window_source]})" if context[:window_tokens]
255
278
  lines << "prompt profile: #{snapshot[:profile]} (#{snapshot[:profile_source]})" if snapshot[:profile]
256
279
  if snapshot[:served_model]
257
280
  asked = snapshot[:served_model_for]
@@ -240,7 +240,10 @@ module Samagotchi
240
240
  nil
241
241
  end
242
242
 
243
+ # A scratch session keeps nothing, its typed lines neither.
243
244
  def persist_recent_history(input)
245
+ return if @scratch
246
+
244
247
  entries = normalize_history_entries(load_history_entries_from_disk)
245
248
  entries << input
246
249
  trimmed_entries = entries.last(PROMPT_HISTORY_LIMIT)
@@ -52,6 +52,9 @@ module Samagotchi
52
52
  STATS_COMMAND = "/stats"
53
53
  # /exit --delete: delete the session on the way out.
54
54
  EXIT_DELETE_FLAG = "--delete"
55
+ # Leave, then archive the session (hidden from the lists, kept for good).
56
+ ARCHIVE_COMMAND = "/archive"
57
+ SCRATCH_ARCHIVE_REFUSED = "a scratch session is deleted when you leave; nothing to archive"
55
58
  RECAP_COMMAND = "/recap"
56
59
  # The prompt while a question waits for its answer (its choices are in
57
60
  # the notes slot).
@@ -103,9 +106,12 @@ module Samagotchi
103
106
  Engine.system_prompt_for(profile)
104
107
  end
105
108
 
109
+ # @param scratch [Boolean] `chi scratch`: a new session that is deleted
110
+ # however the REPL ends, saves no memories and writes no recap
106
111
  def initialize(mode: :assist, prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
107
- spinner_tick_interval: THINKING_TICK_INTERVAL)
112
+ spinner_tick_interval: THINKING_TICK_INTERVAL, scratch: false)
108
113
  @mode = mode.to_sym
114
+ @scratch = scratch
109
115
  # nil: no ticker thread (specs that compare exact frames).
110
116
  @spinner_tick_interval = spinner_tick_interval
111
117
  @spinner_lock = Monitor.new
@@ -176,7 +182,8 @@ module Samagotchi
176
182
  memories: @requested_memories,
177
183
  muted_memories: @muted_memory_names,
178
184
  kernel: @kernel,
179
- recap: recap_config,
185
+ recap: scratch ? nil : recap_config,
186
+ scratch: scratch,
180
187
  reminders: {
181
188
  callback: lambda { |due_names|
182
189
  # When a reminder is due, queue a synthetic turn that
@@ -242,15 +249,20 @@ module Samagotchi
242
249
  model_name: @effective_model_name,
243
250
  working_directory: Dir.pwd,
244
251
  preloaded_memory_names: @requested_memories,
245
- muted_memory_names: @muted_memory_names
252
+ muted_memory_names: @muted_memory_names,
253
+ scratch: @scratch
246
254
  )
247
255
  claim_session!(session.id) unless @owner_lock
256
+ # Saved at once, so a process killed before its first turn leaves a
257
+ # file the sweep knows to delete.
258
+ session.save if @scratch
248
259
  # Attach before building the prompt so it can name the session id.
249
260
  @engine.session = session
250
261
  messages = messages_for(session)
251
262
 
252
263
  if @prompt && @non_interactive
253
264
  # Headless / CI mode: run directly without TTY rendering.
265
+ ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
254
266
  result = @engine.run_turn(
255
267
  session,
256
268
  @prompt,
@@ -259,6 +271,13 @@ module Samagotchi
259
271
  cancel_controller: nil,
260
272
  images: ImageInput.extract(@prompt)
261
273
  )
274
+ if empty_answer?(result)
275
+ # Nothing to print: say so on stderr and fail, so a script that
276
+ # pipes the answer doesn't take the silence for one.
277
+ session.save
278
+ warn EMPTY_ANSWER_ERROR
279
+ exit 1
280
+ end
262
281
  @surface.commit(result.output)
263
282
  session.save
264
283
  return
@@ -266,10 +285,14 @@ module Samagotchi
266
285
 
267
286
  assist_loop(session: session, messages: messages)
268
287
  # After the idle layer has stopped: nothing writes the session now.
269
- if @delete_on_exit
288
+ if @scratch
289
+ nil # deleted below, however the REPL ended
290
+ elsif @delete_on_exit
270
291
  delete_after_exit(session)
271
292
  elsif @discard_on_exit
272
293
  discard_after_exit(session)
294
+ elsif @archive_on_exit
295
+ archive_after_exit(session)
273
296
  else
274
297
  keep_after_exit(session)
275
298
  end
@@ -278,6 +301,32 @@ module Samagotchi
278
301
  # #initialize): the anytime commands finish, the plugins' services
279
302
  # stop (a server process).
280
303
  @engine&.shutdown
304
+ # A scratch session goes on every way out: /exit, Ctrl-D, an error,
305
+ # SIGTERM or SIGHUP (Ruby raises those here). Only kill -9 leaves it
306
+ # to the sweep.
307
+ delete_scratch(session, quiet: @non_interactive || !$!.nil?) if @scratch && session
308
+ end
309
+
310
+ EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
311
+
312
+ # A -p --non-interactive turn that ended with no answer (the empty-answer
313
+ # retries used up): no text, or the chat loop's placeholder.
314
+ def empty_answer?(result)
315
+ return false if result.respond_to?(:canceled?) && result.canceled?
316
+ return false if result.respond_to?(:resumable?) && result.resumable?
317
+
318
+ result.output.to_s.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?)
319
+ end
320
+
321
+ # Delete the scratch session, the last thing the REPL does. Quiet after
322
+ # a one-shot or on the way out of an error or a signal.
323
+ def delete_scratch(session, quiet:)
324
+ @owner_lock&.release
325
+ @owner_lock = nil
326
+ SessionManager.delete_session(session.id)
327
+ @surface.commit("Scratch session deleted.") unless quiet
328
+ rescue StandardError => e
329
+ warn "Scratch session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}"
281
330
  end
282
331
 
283
332
  # The session stays: the recap first, then the resume line, last.
@@ -330,6 +379,17 @@ module Samagotchi
330
379
  @surface.commit("Session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}")
331
380
  end
332
381
 
382
+ # /archive: give up the session, then archive it (hidden from the lists,
383
+ # kept for good). An empty one is discarded instead (#discard_on_exit?).
384
+ def archive_after_exit(session)
385
+ @owner_lock&.release
386
+ @owner_lock = nil
387
+ SessionManager.archive_session(session.id)
388
+ @surface.commit("Archived session #{session.id}. chi sessions list --archived finds it.")
389
+ rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
390
+ @surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
391
+ end
392
+
333
393
  # Take the session's OwnerLock for this process's lifetime: the TUI runs
334
394
  # its own Engine, so no worker may run the session meanwhile.
335
395
  # @raise [SessionBusy] when a worker or another TUI owns it
@@ -356,6 +416,9 @@ module Samagotchi
356
416
  @surface.commit("Resumed session: #{session.id}")
357
417
  saved = @engine.saved_recap
358
418
  @surface.commit(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
419
+ elsif @scratch
420
+ messages = [system_message]
421
+ @surface.commit("Scratch session: nothing is kept, it is deleted when you leave.")
359
422
  else
360
423
  messages = [system_message]
361
424
  @surface.commit("Session: #{session.id}")
@@ -500,6 +563,12 @@ module Samagotchi
500
563
  @delete_on_exit ||= delete_on_exit?(input)
501
564
  break
502
565
  end
566
+ if archive_command?(input)
567
+ next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
568
+
569
+ @archive_on_exit = true
570
+ break
571
+ end
503
572
  # Not an answer to a continue offer either.
504
573
  next detach_note if detach_command?(input)
505
574
 
@@ -1001,6 +1070,8 @@ module Samagotchi
1001
1070
 
1002
1071
  def detach_command?(input) = input.to_s.strip.casecmp?("/detach")
1003
1072
 
1073
+ def archive_command?(input) = input.to_s.strip.casecmp?(ARCHIVE_COMMAND)
1074
+
1004
1075
  def clone_messages(messages)
1005
1076
  Array(messages).map(&:dup)
1006
1077
  end
@@ -1024,6 +1095,8 @@ module Samagotchi
1024
1095
  # Build the (memoized) prompt now so --memory activations show in this
1025
1096
  # turn's status lines, including after /model rebuilt it.
1026
1097
  seed_system_prompt
1098
+ # A prompt the user typed brings an archived session back to the lists.
1099
+ ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
1027
1100
  result = with_steering do
1028
1101
  @engine.run_turn(
1029
1102
  session,
@@ -65,11 +65,13 @@ module Samagotchi
65
65
 
66
66
  # "error" for an "Error:" result, and for an execute whose command
67
67
  # exited non-zero (its last "exit: N" line; no number: killed by a
68
- # signal), so the turn tally's "(N failed)" counts it.
68
+ # signal), so the turn tally's "(N failed)" counts it. "stopped" for a
69
+ # task_wait the user's Stop ended: the task itself runs on.
69
70
  def tool_activity_status(result, tool_name = nil)
70
71
  text = result.to_s
71
72
  return "error" if text.start_with?("Error:")
72
73
  return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
74
+ return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^wait_result: canceled$/)
73
75
 
74
76
  "ok"
75
77
  end
@@ -68,12 +68,16 @@ module Samagotchi
68
68
  Edit::NAME => lambda do |call, _kctx|
69
69
  Edit.call(call[:content], path: call[:path], start_line: call[:start_line], end_line: call[:end_line])
70
70
  end,
71
- TaskCreate::NAME => ->(call, _kctx) { TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env]) },
72
- TaskWait::NAME => lambda do |call, _kctx|
71
+ TaskCreate::NAME => lambda do |call, kctx|
72
+ next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
73
+
74
+ TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env])
75
+ end,
76
+ TaskWait::NAME => lambda do |call, kctx|
73
77
  TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
74
- done_pattern: call[:done_pattern])
78
+ done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx))
75
79
  end,
76
- Execute::NAME => ->(call, _kctx) { Execute.call(call[:content], cwd: call[:cwd]) },
80
+ Execute::NAME => ->(call, kctx) { Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx)) },
77
81
  RegisterReminder::NAME => lambda do |call, kctx|
78
82
  RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
79
83
  interval_minutes: call[:interval_minutes])
@@ -94,6 +98,13 @@ module Samagotchi
94
98
 
95
99
  module_function
96
100
 
101
+ # Stop flips the turn's controller, seen through the Engine's PeerView
102
+ # (as DelegateWait does); a bare kernel has no peers: never cancelled.
103
+ def cancelled_proc(kctx)
104
+ peers = kctx.peers
105
+ -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
106
+ end
107
+
97
108
  # @return [Registry] a new registry with the built-ins, in
98
109
  # TOOL_SCHEMAS order (an Engine's own, which bundles add to)
99
110
  def registry
@@ -1,22 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../session"
4
+ require_relative "../reply_wait"
4
5
  require_relative "output_guardrails"
5
6
  require_relative "peers"
6
7
 
7
8
  module Samagotchi
8
- # Loaded on first use: session_manager requires terminal_ui, which
9
- # requires KernelLoop and so these tools (a require cycle otherwise).
10
- autoload :SessionManager, File.expand_path("../session_manager", __dir__)
11
-
12
9
  module Tools
13
10
  # Waiting for a delegated session's next reply, shared by delegate and
14
- # delegate_result. A reply is the child's output/<timestamp>.txt file
15
- # (the worker writes one per turn that ended with visible text, just
16
- # before its idle save), so "new" means a filename past the cursor: the
17
- # newest one this parent was already given. The cursors live in this
18
- # process (a worker runs one session), keyed by parent and child; a
19
- # worker respawn loses them, which only repeats the newest reply once.
11
+ # delegate_result: ReplyWait in the tools' words, with a cursor per
12
+ # child (the newest reply this parent was already given). The cursors
13
+ # live in this process (a worker runs one session), keyed by parent and
14
+ # child; a worker respawn loses them, which only repeats the newest reply
15
+ # once.
20
16
  module DelegateWait
21
17
  TIMEOUT_DEFAULT = 600
22
18
  POLL_INTERVAL = 0.5
@@ -40,70 +36,34 @@ module Samagotchi
40
36
  def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
41
37
  sd = peers.state_dir || Session.default_state_dir
42
38
  key = [peers.session_id, child_id]
43
- cursor = seen[key]
44
- deadline = monotonic + timeout.to_i
45
- seen_running = false
46
-
47
- loop do
48
- session = Session.load(child_id, state_dir: sd)
49
-
50
- if (file = newest_reply(child_id, state_dir: sd)) && newer?(file, cursor)
51
- seen[key] = file
52
- return reply_result(child_id, File.read(File.join(reply_dir(child_id, state_dir: sd), file)))
53
- end
54
-
55
- case session.status
56
- when Session::STATUS_ERROR
57
- return result(child_id, "error", "the child's worker failed: #{session.last_prompt.to_s.strip}; its session shows what happened")
58
- when Session::STATUS_STOPPED
59
- return result(child_id, "stopped", "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
60
- end
61
-
62
- if (pending = session.pending_question)
63
- return result(child_id, "waiting_for_answer", waiting_text(child_id, pending))
64
- end
65
-
66
- if peers.respond_to?(:cancelled?) && peers.cancelled?
67
- return result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
68
- end
69
-
70
- if session.status == Session::STATUS_RUNNING
71
- seen_running = true
72
- elsif seen_running
73
- # It ran and is idle again with no new reply: canceled, failed or
74
- # empty. Before it was ever seen running, idle means a
75
- # file-delivered follow-up its worker has not picked up yet.
76
- return result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
77
- end
78
-
79
- return timeout_result(child_id, timeout) if monotonic > deadline
80
-
81
- sleep(poll_interval)
39
+ cancelled = -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
40
+ wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
41
+ poll_interval: poll_interval, cancelled: cancelled)
42
+ case wait.status
43
+ when :done
44
+ seen[key] = wait.file
45
+ reply_result(child_id, wait.text)
46
+ when :error
47
+ result(child_id, "error", "the child's worker failed: #{wait.text}; its session shows what happened")
48
+ when :stopped
49
+ result(child_id, "stopped", "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
50
+ when :waiting_for_answer
51
+ result(child_id, "waiting_for_answer", waiting_text(child_id, wait.question))
52
+ when :canceled
53
+ result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
54
+ when :no_reply
55
+ result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
56
+ else
57
+ timeout_result(child_id, timeout)
82
58
  end
83
59
  rescue ArgumentError => e
84
60
  "Error: #{e.message}"
85
61
  end
86
62
 
87
- # @return [String, nil] the newest reply filename (sortable timestamps)
88
- def newest_reply(child_id, state_dir:)
89
- dir = reply_dir(child_id, state_dir: state_dir)
90
- return nil unless Dir.exist?(dir)
91
-
92
- Dir.children(dir).select { |f| f.end_with?(".txt") }.max
93
- end
94
-
95
63
  # Point the cursor at the newest reply now, so only a later one counts
96
64
  # (a follow-up sent to a child that already answered).
97
65
  def mark_seen(parent_id, child_id, state_dir:)
98
- seen[[parent_id, child_id]] = newest_reply(child_id, state_dir: state_dir)
99
- end
100
-
101
- def reply_dir(child_id, state_dir:)
102
- File.join(Session.session_dir(child_id, state_dir: state_dir), SessionManager::OUTPUT_DIR)
103
- end
104
-
105
- def newer?(file, cursor)
106
- cursor.nil? || file > cursor
66
+ seen[[parent_id, child_id]] = ReplyWait.newest_reply(child_id, state_dir: state_dir)
107
67
  end
108
68
 
109
69
  def reply_result(child_id, text)
@@ -145,9 +105,6 @@ module Samagotchi
145
105
  ].join("\n")
146
106
  end
147
107
 
148
- def monotonic
149
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
150
- end
151
108
  end
152
109
  end
153
110
  end