samagotchi 0.2.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -22,9 +22,25 @@ module Samagotchi
22
22
 
23
23
  def self.call(content, path:, start_line: nil, end_line: nil)
24
24
  path = ToolPath.normalize(path)
25
+ result = apply(content, path: path, start_line: start_line, end_line: end_line)
26
+ return result if result.is_a?(String)
27
+
28
+ updated, message = result
29
+ File.write(path, updated)
30
+ message
31
+ rescue => e
32
+ "Error: #{e.message}"
33
+ end
34
+
35
+ # The edit without the write: [updated, message] or "Error: …", with
36
+ # exactly the strings call returns. +read+ reads the file, so a dry run
37
+ # (EditPreview) can reuse a copy it already has; each mode keeps its own
38
+ # check order (exact: tags first; range: file not found first).
39
+ def self.apply(content, path:, start_line: nil, end_line: nil, read: ->(p) { File.read(p) })
40
+ path = ToolPath.normalize(path)
25
41
 
26
42
  if range_requested?(start_line, end_line)
27
- return call_range_mode(content, path: path, start_line: start_line, end_line: end_line)
43
+ return apply_range_mode(content, path: path, start_line: start_line, end_line: end_line, read: read)
28
44
  end
29
45
 
30
46
  old_text = extract_tag(content, "old")
@@ -35,7 +51,7 @@ module Samagotchi
35
51
  return "Error: <old> block is empty" if old_text.empty?
36
52
  return "Error: file not found: #{path}" unless File.exist?(path)
37
53
 
38
- source = File.read(path)
54
+ source = read.(path)
39
55
  count = count_occurrences(source, old_text)
40
56
 
41
57
  return "Error: old text not found in #{path}" if count == 0
@@ -43,13 +59,12 @@ module Samagotchi
43
59
 
44
60
  idx = source.index(old_text)
45
61
  updated = source[0, idx] + new_text + source[idx + old_text.length..]
46
- File.write(path, updated)
47
- "Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"
62
+ [updated, "Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"]
48
63
  rescue => e
49
64
  "Error: #{e.message}"
50
65
  end
51
66
 
52
- def self.call_range_mode(content, path:, start_line:, end_line:)
67
+ def self.apply_range_mode(content, path:, start_line:, end_line:, read:)
53
68
  return "Error: file not found: #{path}" unless File.exist?(path)
54
69
 
55
70
  new_text = extract_tag(content, "new")
@@ -59,7 +74,7 @@ module Samagotchi
59
74
  return start_num if start_num.is_a?(String)
60
75
  return "Error: start_line must be provided for range edits" if start_num.nil?
61
76
 
62
- source = File.read(path)
77
+ source = read.(path)
63
78
  lines = source.lines
64
79
  total_lines = lines.length
65
80
 
@@ -102,15 +117,14 @@ module Samagotchi
102
117
  # first suffix line isn't concatenated onto the last replacement line.
103
118
  normalized = (!new_text.empty? && !suffix.empty? && !new_text.end_with?("\n")) ? new_text + "\n" : new_text
104
119
  updated = prefix + normalized + suffix
105
- File.write(path, updated)
106
120
 
107
121
  replaced_lines = (end_value - start_num) + 1
108
122
  new_line_count = new_text.lines.length
109
- "Edited #{path}: replaced lines #{start_num}-#{end_value} (#{replaced_lines} lines) with #{new_line_count} lines#{clamp_note}"
123
+ [updated, "Edited #{path}: replaced lines #{start_num}-#{end_value} (#{replaced_lines} lines) with #{new_line_count} lines#{clamp_note}"]
110
124
  rescue => e
111
125
  "Error: #{e.message}"
112
126
  end
113
- private_class_method :call_range_mode
127
+ private_class_method :apply_range_mode
114
128
 
115
129
  # Count non-overlapping literal occurrences of +needle+ in +haystack+.
116
130
  def self.count_occurrences(haystack, needle)
@@ -18,16 +18,22 @@ module Samagotchi
18
18
  TIMEOUT_SEC = 30
19
19
  STOP_GRACE_SEC = 1.0
20
20
  STOP_POLL_INTERVAL_SEC = 0.05
21
+ WAIT_SLICE_SEC = 0.2
22
+ NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
21
23
 
22
24
  def self.name = NAME
23
25
 
24
- def self.call(command, cwd: nil)
26
+ # @param cancelled [#call] true once the user stopped the turn: the
27
+ # command is killed (or not started) and its output so far returned
28
+ def self.call(command, cwd: nil, cancelled: -> { false })
29
+ return NOT_RUN_ON_STOP if cancelled.call
30
+
25
31
  command = command.strip
26
32
  resolved_cwd = resolve_cwd(cwd)
27
33
  return "Error: cwd not found: #{resolved_cwd}" unless resolved_cwd
28
34
 
29
35
  timeout_sec = timeout_seconds
30
- stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd)
36
+ stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled)
31
37
 
32
38
  stdout_block = output_block("stdout", stdout)
33
39
  stderr_block = output_block("stderr", stderr)
@@ -45,19 +51,29 @@ module Samagotchi
45
51
  rescue CommandTimedOut => e
46
52
  # Keep the "Error:" first line (callers classify on it) and return
47
53
  # whatever the command printed before it was killed.
48
- parts = ["Error: command timed out after #{timeout_sec}s"]
49
- parts << output_block("stdout", e.stdout)
50
- parts << output_block("stderr", e.stderr)
51
- parts.compact.join("\n")
54
+ stopped_result("Error: command timed out after #{timeout_sec}s", e)
55
+ rescue CommandCancelled => e
56
+ stopped_result("Error: command stopped by the user after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
52
57
  rescue => e
53
58
  "Error: #{e.message}"
54
59
  end
55
60
 
56
- def self.run_command(command, timeout_sec:, cwd:)
61
+ def self.stopped_result(first_line, error)
62
+ parts = [first_line]
63
+ parts << output_block("stdout", error.stdout)
64
+ parts << output_block("stderr", error.stderr)
65
+ parts.compact.join("\n")
66
+ end
67
+ private_class_method :stopped_result
68
+
69
+ def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false })
57
70
  stdout_text = ""
58
71
  stderr_text = ""
59
72
  status = nil
60
73
  timed_out = false
74
+ was_cancelled = false
75
+ started = monotonic_time
76
+ deadline = started + timeout_sec
61
77
 
62
78
  Open3.popen3(command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
63
79
  stdin.close
@@ -65,14 +81,24 @@ module Samagotchi
65
81
  stderr_reader = reader_thread_for(stderr)
66
82
 
67
83
  begin
68
- if wait_thr.join(timeout_sec)
69
- status = wait_thr.value
70
- else
71
- timed_out = true
72
- terminate_process_tree(wait_thr.pid)
73
- wait_thr.join
74
- status = wait_thr.value
84
+ until wait_thr.join(WAIT_SLICE_SEC)
85
+ if cancelled.call
86
+ was_cancelled = true
87
+ break
88
+ elsif monotonic_time > deadline
89
+ timed_out = true
90
+ break
91
+ end
75
92
  end
93
+ terminate_process_tree(wait_thr.pid) if timed_out || was_cancelled
94
+ wait_thr.join
95
+ status = wait_thr.value
96
+ rescue Exception # rubocop:disable Lint/RescueException -- kill the child, then re-raise
97
+ # An Interrupt (or anything else) mid-wait: the child is in its own
98
+ # process group, so it never saw the SIGINT. Kill it, or the reads
99
+ # below block until it closes stdout.
100
+ terminate_process_tree(wait_thr.pid)
101
+ raise
76
102
  ensure
77
103
  # Read the buffered output before closing the pipes. For a process
78
104
  # that exits almost instantly (e.g. `echo hello`), the background
@@ -86,6 +112,7 @@ module Samagotchi
86
112
  end
87
113
 
88
114
  raise CommandTimedOut.new(stdout_text, stderr_text) if timed_out
115
+ raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started) if was_cancelled
89
116
 
90
117
  [stdout_text, stderr_text, status]
91
118
  end
@@ -209,6 +236,17 @@ module Samagotchi
209
236
  super("command timed out")
210
237
  end
211
238
  end
239
+
240
+ class CommandCancelled < StandardError
241
+ attr_reader :stdout, :stderr, :elapsed
242
+
243
+ def initialize(stdout, stderr, elapsed)
244
+ @stdout = stdout
245
+ @stderr = stderr
246
+ @elapsed = elapsed
247
+ super("command stopped by the user")
248
+ end
249
+ end
212
250
  end
213
251
  end
214
252
  end
@@ -94,6 +94,25 @@ module Samagotchi
94
94
  end.compact.sort_by { |record| record["created_at"].to_s }.reverse
95
95
  end
96
96
 
97
+ # The tasks this conversation's task_create calls started that are
98
+ # still running. Only task_create results count: records are shared by
99
+ # every session in the worker's cwd, and task_list shows them all.
100
+ # A native tool_response joins its calls' results with "---".
101
+ # @return [Array<Hash>] {id:, command:}, oldest first
102
+ def running_created_in(messages)
103
+ ids = Array(messages).flat_map do |message|
104
+ next [] unless (message[:role] || message["role"]).to_s == "tool_response"
105
+
106
+ (message[:content] || message["content"]).to_s.split("\n\n---\n\n").filter_map do |block|
107
+ block[/\A\[task_create\]\ntask_id: (\S+)/, 1]
108
+ end
109
+ end
110
+ ids.uniq.filter_map do |id|
111
+ record, _error = get_record(id)
112
+ { id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
113
+ end
114
+ end
115
+
97
116
  def get_record(task_id)
98
117
  record = load_record(task_id)
99
118
  return [nil, "Error: task not found: #{task_id}"] unless record
@@ -14,7 +14,10 @@ module Samagotchi
14
14
 
15
15
  def self.name = NAME
16
16
 
17
- def self.call(task_id, timeout: TIMEOUT_DEFAULT, tail_lines: TAIL_LINES_DEFAULT, done_pattern: nil)
17
+ # @param cancelled [#call] true once the user stopped the turn: the
18
+ # wait returns then, and the task keeps running
19
+ def self.call(task_id, timeout: TIMEOUT_DEFAULT, tail_lines: TAIL_LINES_DEFAULT, done_pattern: nil,
20
+ cancelled: -> { false })
18
21
  timeout = TIMEOUT_DEFAULT if blank?(timeout)
19
22
  tail_lines = TAIL_LINES_DEFAULT if blank?(tail_lines)
20
23
  timeout = timeout.to_i
@@ -27,7 +30,8 @@ module Samagotchi
27
30
  return completion_pattern if completion_pattern.is_a?(String)
28
31
 
29
32
  task_id = task_id.to_s.strip
30
- deadline = monotonic_time + timeout
33
+ started = monotonic_time
34
+ deadline = started + timeout
31
35
 
32
36
  loop do
33
37
  record, error = TaskRuntime.get_record(task_id)
@@ -41,6 +45,8 @@ module Samagotchi
41
45
  return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
42
46
  end
43
47
 
48
+ return cancelled_response(task_id, tail_lines, started) if cancelled.call
49
+
44
50
  break if monotonic_time > deadline
45
51
  sleep(POLL_INTERVAL)
46
52
  end
@@ -55,7 +61,24 @@ module Samagotchi
55
61
  "Error: #{e.message}"
56
62
  end
57
63
 
58
- def self.format_response(record, wait_result: nil, output_tail: nil)
64
+ # The task may have finished between the poll and the Stop: then the
65
+ # normal finished result. Only a task still running says so.
66
+ def self.cancelled_response(task_id, tail_lines, started)
67
+ record, error = TaskRuntime.get_record(task_id)
68
+ return "Error: #{error}" if error
69
+ return format_response(record) unless record.fetch("status") == "running"
70
+
71
+ output_tail = TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
72
+ note = [
73
+ "note: the user stopped the turn while waiting; the task is STILL RUNNING.",
74
+ " To keep waiting: task_wait #{task_id}. If it is stuck or no longer needed: task_stop #{task_id}.",
75
+ "waited: #{(monotonic_time - started).round}s"
76
+ ]
77
+ format_response(record, wait_result: "canceled", output_tail: output_tail, extra: note)
78
+ end
79
+ private_class_method :cancelled_response
80
+
81
+ def self.format_response(record, wait_result: nil, output_tail: nil, extra: [])
59
82
  lines = [
60
83
  "task_id: #{record.fetch("id")}",
61
84
  "status: #{record.fetch("status")}",
@@ -66,6 +89,7 @@ module Samagotchi
66
89
  return lines.join("\n") unless wait_result
67
90
 
68
91
  lines << "wait_result: #{wait_result}"
92
+ lines.concat(extra)
69
93
  lines << "output_tail:"
70
94
  lines << output_tail.rstrip unless output_tail.empty?
71
95
  lines.join("\n")
@@ -14,6 +14,10 @@ module Samagotchi
14
14
  def self.name = NAME
15
15
 
16
16
  def self.call(content, path:)
17
+ # A native call without content/text arrives as nil; check before
18
+ # touching the file, or File.write(path, nil) would empty it.
19
+ return "Error: missing content" unless content.is_a?(String)
20
+
17
21
  path = ToolPath.normalize(path)
18
22
  FileUtils.mkdir_p(File.dirname(path))
19
23
  File.write(path, content)
@@ -143,9 +143,19 @@ module Samagotchi
143
143
  @checkpoint = nil
144
144
  end
145
145
 
146
- # A reminder turn ran. The checkpoint stays only for a pending offer.
146
+ # A reminder turn is about to run: a pending continue offer goes, as for
147
+ # a new prompt (the partial turn stays), so a later "no" can't roll the
148
+ # reminder's exchange back with it.
149
+ # @return [Boolean] whether an offer was pending
150
+ def before_reminder_turn
151
+ offered = awaiting_continue?
152
+ @offer = nil
153
+ offered
154
+ end
155
+
156
+ # A reminder turn ran: the rollback window closes.
147
157
  def after_reminder_turn
148
- @checkpoint = nil unless awaiting_continue?
158
+ @checkpoint = nil
149
159
  end
150
160
 
151
161
  private
@@ -13,6 +13,10 @@ module Samagotchi
13
13
  KIND = "turn_note"
14
14
  OPEN = "[SYSTEM: "
15
15
  CLOSE = "]"
16
+ FAILED = "the previous turn failed before any answer: "
17
+ RESTORED = "The message went back to the user, who may send it again."
18
+ TASKS_LISTED = 5
19
+ TASK_COMMAND_CHARS = 60
16
20
 
17
21
  module_function
18
22
 
@@ -21,28 +25,48 @@ module Samagotchi
21
25
  # failed user message is not in the conversation any more)
22
26
  # @param continued [Boolean] it was a continue turn (no user message)
23
27
  def failed(summary, restored: false, continued: false)
24
- tail = if restored then "The message went back to the user, who may send it again."
28
+ tail = if restored then RESTORED
25
29
  elsif continued then "The continued turn stopped there."
26
30
  else "The user's last message was not answered."
27
31
  end
28
- message("the previous turn failed before any answer: #{one_line(summary)}. #{tail}")
32
+ message("#{FAILED}#{one_line(summary)}. #{tail}")
29
33
  end
30
34
 
31
35
  # @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
32
36
  # @param seconds [Numeric, nil] how long the turn had run
33
37
  # @param shown [Boolean] visible text had streamed (an `[interrupted]`
34
38
  # model message precedes this note)
35
- def cancelled(reason, seconds: nil, shown: false)
39
+ # @param running_tasks [Array<Hash>] {id:, command:} of this session's
40
+ # background tasks still running: a Stop doesn't end them
41
+ def cancelled(reason, seconds: nil, shown: false, running_tasks: [])
36
42
  why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")})"
37
43
  after = seconds ? " after #{seconds.round}s" : ""
38
44
  what = shown ? "the answer above ends where it was cut off." : "no answer had been shown."
39
- message("the previous turn was cancelled#{why}#{after}; #{what}")
45
+ message("the previous turn was cancelled#{why}#{after}; #{what}#{still_running(running_tasks)}")
46
+ end
47
+
48
+ def still_running(tasks)
49
+ return "" if tasks.empty?
50
+
51
+ listed = tasks.first(TASKS_LISTED).map do |task|
52
+ command = one_line(task[:command])
53
+ command = "#{command[0, TASK_COMMAND_CHARS - 1]}…" if command.length > TASK_COMMAND_CHARS
54
+ "task #{task[:id]} (#{command})"
55
+ end
56
+ listed << "#{tasks.size - TASKS_LISTED} more (task_list)" if tasks.size > TASKS_LISTED
57
+ " Still running: #{listed.join(", ")}. Continue with task_wait <id> or stop with task_stop <id>."
40
58
  end
41
59
 
42
60
  def empty
43
61
  message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
44
62
  end
45
63
 
64
+ # The hidden nudge before a retry of an empty answer (EmptyAnswerRetry):
65
+ # the model never sees its empty generation, so it is told what happened.
66
+ def empty_retry
67
+ message("your last reply had no visible answer. Answer the user's last message now, briefly.")
68
+ end
69
+
46
70
  def note?(entry)
47
71
  return false unless entry.respond_to?(:[])
48
72
 
@@ -56,11 +80,41 @@ module Samagotchi
56
80
  # +messages+ plus +note+, in place of a note already at the tail (behind
57
81
  # context notes at most): failed retries leave one note, not a pile.
58
82
  def replace_trailing(messages, note)
83
+ without_trailing(messages) << note
84
+ end
85
+
86
+ # A copy of +messages+ without the note at its tail (behind context
87
+ # notes at most).
88
+ def without_trailing(messages)
59
89
  list = Array(messages).dup
90
+ index = trailing_index(list)
91
+ list.delete_at(index) if index
92
+ list
93
+ end
94
+
95
+ # The index of the note at the tail of +list+ (behind context notes at
96
+ # most), or nil.
97
+ def trailing_index(list)
60
98
  index = list.length - 1
61
99
  index -= 1 while index >= 0 && (list[index][:role] || list[index]["role"]).to_s == "system" && !note?(list[index])
62
- list.delete_at(index) if index >= 0 && note?(list[index])
63
- list << note
100
+ index >= 0 && note?(list[index]) ? index : nil
101
+ end
102
+
103
+ # The failure summary of the note at the tail of +messages+ (behind
104
+ # context notes at most) when it says a failed turn's prompt went back
105
+ # to the user, else nil: the prompt is not in the conversation any more
106
+ # (the session's last_prompt holds it), so a UI shows it from here.
107
+ def restored_failure(messages)
108
+ list = Array(messages)
109
+ index = trailing_index(list)
110
+ return nil unless index
111
+
112
+ text = (list[index][:content] || list[index]["content"]).to_s
113
+ prefix = "#{OPEN}#{FAILED}"
114
+ suffix = ". #{RESTORED}#{CLOSE}"
115
+ return nil unless text.start_with?(prefix) && text.end_with?(suffix)
116
+
117
+ text[prefix.length...-suffix.length]
64
118
  end
65
119
 
66
120
  # The last message is a reply cut short (`[interrupted]`).