samagotchi 0.17.0 → 0.18.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/docs/architecture.md +25 -2
  4. data/docs/cli.md +34 -8
  5. data/docs/configuration.md +4 -2
  6. data/docs/guardrails.md +51 -8
  7. data/docs/hooks.md +4 -1
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +3 -2
  10. data/docs/sessions.md +6 -5
  11. data/docs/sub-agent.md +42 -4
  12. data/lib/samagotchi/answer_command.rb +23 -2
  13. data/lib/samagotchi/answer_tail.rb +52 -0
  14. data/lib/samagotchi/bridge.rb +49 -8
  15. data/lib/samagotchi/bridge_client.rb +18 -3
  16. data/lib/samagotchi/bundle_command.rb +108 -2
  17. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  18. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/commands/registry.rb +28 -0
  22. data/lib/samagotchi/config.rb +7 -1
  23. data/lib/samagotchi/continue_offer.rb +177 -0
  24. data/lib/samagotchi/engine.rb +42 -14
  25. data/lib/samagotchi/guardrails/outside.rb +46 -0
  26. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  27. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  28. data/lib/samagotchi/guardrails/rules.rb +17 -5
  29. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  30. data/lib/samagotchi/guardrails/targets.rb +30 -13
  31. data/lib/samagotchi/guardrails.rb +2 -0
  32. data/lib/samagotchi/iteration_limit.rb +38 -0
  33. data/lib/samagotchi/kernel_loop.rb +2 -1
  34. data/lib/samagotchi/llm/backend.rb +2 -1
  35. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  36. data/lib/samagotchi/llm/native_backend.rb +2 -1
  37. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  38. data/lib/samagotchi/parent_report.rb +39 -9
  39. data/lib/samagotchi/question_desk.rb +205 -46
  40. data/lib/samagotchi/reply_wait.rb +10 -4
  41. data/lib/samagotchi/send_command.rb +2 -1
  42. data/lib/samagotchi/session.rb +3 -2
  43. data/lib/samagotchi/session_commands.rb +3 -2
  44. data/lib/samagotchi/session_manager.rb +4 -1
  45. data/lib/samagotchi/sessions_command.rb +3 -2
  46. data/lib/samagotchi/system_prompt.rb +1 -1
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -5
  48. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  49. data/lib/samagotchi/terminal_ui.rb +11 -2
  50. data/lib/samagotchi/tool_declarations.rb +2 -2
  51. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  52. data/lib/samagotchi/tools/edit.rb +6 -1
  53. data/lib/samagotchi/tools/execute.rb +5 -4
  54. data/lib/samagotchi/tools/read.rb +2 -1
  55. data/lib/samagotchi/turn_flow.rb +8 -7
  56. data/lib/samagotchi/turn_note.rb +7 -0
  57. data/lib/samagotchi/version.rb +1 -1
  58. data/lib/samagotchi/web/app.rb +187 -42
  59. data/lib/samagotchi/web/public/app.js +122 -25
  60. data/lib/samagotchi/web/public/data.js +12 -13
  61. data/lib/samagotchi/web/public/index.html +6 -1
  62. data/lib/samagotchi/web/public/notify.js +5 -2
  63. data/lib/samagotchi/web/public/question_card.js +28 -1
  64. data/lib/samagotchi/web/public/question_cards.js +74 -3
  65. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  66. data/lib/samagotchi/web/public/timing.js +41 -0
  67. data/lib/samagotchi/web/public/turn_events.js +61 -0
  68. data/lib/samagotchi/web/public/update_notice.js +15 -0
  69. data/lib/samagotchi/worker.rb +30 -58
  70. data/lib/samagotchi/worker_sidecar.rb +6 -3
  71. metadata +8 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "question_slot"
4
+ require_relative "../turn_flow"
4
5
 
5
6
  module Samagotchi
6
7
  class TerminalUI
@@ -12,7 +13,10 @@ module Samagotchi
12
13
  # Bridge). An approval (kind "approval") takes only a number, an exact
13
14
  # label, y (the first option, Allow once) or n (the last, Deny), plus
14
15
  # an optional "; reason"; a substring would pick the wrong one ("y" is
15
- # in "Deny").
16
+ # in "Deny"). The step-limit question (kind "continue") reads the
17
+ # continue words (TurnFlow.continue_decision): Enter, yes, y or
18
+ # /continue continue; no stops; "no, why" stops with the reason; 1 and
19
+ # 2 pick the options.
16
20
  class QuestionPrompt
17
21
  DIFF_CODES = { "+" => 32, "-" => 31, "@" => 2, "\\" => 2 }.freeze
18
22
 
@@ -51,11 +55,13 @@ module Samagotchi
51
55
  @multi = !!field.(:multi_select)
52
56
  @free = !!field.(:allow_freeform)
53
57
  @approval = field.(:kind).to_s == "approval"
58
+ @continue = field.(:kind).to_s == "continue"
54
59
  end
55
60
 
56
61
  def multi? = @multi
57
62
  def free? = @free
58
63
  def approval? = @approval
64
+ def continue? = @continue
59
65
 
60
66
  # The relay mark (QuestionDesk#annotate's relayed_to: a Hash, or the
61
67
  # short id), or nil when cleared.
@@ -77,6 +83,7 @@ module Samagotchi
77
83
  # What a question closed with no answer here says: a relayed approval
78
84
  # names where it went; else "(question cancelled)".
79
85
  def closed_text(reason)
86
+ return CONTINUE_CLOSED.fetch(reason.to_s, "(question cancelled)") if continue?
80
87
  return "(question cancelled)" unless @delegate
81
88
 
82
89
  case reason.to_s
@@ -98,6 +105,10 @@ module Samagotchi
98
105
  hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint, note: note)
99
106
  end
100
107
 
108
+ # Why a step-limit question closed unanswered here.
109
+ CONTINUE_CLOSED = { "dropped" => "(dropped: a new prompt came)", "answered" => "(answered with /continue)",
110
+ "superseded" => "(set aside for another question)", "replaced" => "(asked again)" }.freeze
111
+
101
112
  # The most diff lines #preview_lines prints; the web shows them all.
102
113
  PREVIEW_LINES = 40
103
114
 
@@ -138,7 +149,7 @@ module Samagotchi
138
149
  # @return [String] "Banana", "Apple, Cherry; ripe ones", "Deny: use a PR"
139
150
  def answer_text(answer)
140
151
  picked = answer.selected.to_a
141
- if approval?
152
+ if approval? || continue?
142
153
  choice = picked.first || options.last
143
154
  return answer.freeform ? "#{choice}: #{answer.freeform}" : choice
144
155
  end
@@ -150,6 +161,7 @@ module Samagotchi
150
161
  # @return [Answer]
151
162
  def parse(raw)
152
163
  return parse_approval(raw) if approval?
164
+ return parse_continue(raw) if continue?
153
165
 
154
166
  raw = raw.to_s.strip
155
167
  # "1,3; my text": the first ';' separates the selection from freeform text.
@@ -178,6 +190,9 @@ module Samagotchi
178
190
  def mark = approval? ? "! " : "? "
179
191
 
180
192
  def slot_hint
193
+ if continue?
194
+ return "Enter or yes = #{options.first}; no = #{options.last}; no, <reason> = #{options.last} and tell the model why"
195
+ end
181
196
  if approval?
182
197
  return "1-#{options.size}, y = #{options.first}, n = #{options.last}; add '; reason' to tell the model why; " \
183
198
  "Enter alone denies"
@@ -189,6 +204,23 @@ module Samagotchi
189
204
  hint.join("; ")
190
205
  end
191
206
 
207
+ # The continue words, or an option's number. Empty is Continue (Enter
208
+ # alone, as at the REPL's continue prompt).
209
+ def parse_continue(raw)
210
+ text = raw.to_s.strip
211
+ if text.match?(/\A\d+\z/)
212
+ idx = text.to_i - 1
213
+ return Answer.new(selected: [options[idx]]) if idx.between?(0, options.size - 1)
214
+ end
215
+ decision, reason = TurnFlow.continue_decision(text)
216
+ case decision
217
+ when :resume then Answer.new(selected: [options.first])
218
+ when :abort then Answer.new(selected: [options.last])
219
+ when :abort_with_reason then Answer.new(selected: [options.last], freeform: reason)
220
+ else Answer.new(error: "Answer yes (Enter alone too), no, or no, <reason>.")
221
+ end
222
+ end
223
+
192
224
  def parse_approval(raw)
193
225
  sel_part, free_part = raw.to_s.strip.split(";", 2).map { |part| part.to_s.strip }
194
226
  free_part = nil if free_part.to_s.empty?
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "io/console"
6
6
  require "reline"
7
7
 
8
+ require_relative "iteration_limit"
8
9
  require_relative "model_profile"
9
10
  require_relative "turn_note"
10
11
  require_relative "config"
@@ -243,7 +244,7 @@ module Samagotchi
243
244
  session,
244
245
  @prompt,
245
246
  on_event: nil,
246
- max_iterations: 1000,
247
+ max_iterations: IterationLimit.for(no_interrupt: true),
247
248
  cancel_controller: nil,
248
249
  images: ImageInput.extract(@prompt)
249
250
  )
@@ -485,7 +486,7 @@ module Samagotchi
485
486
  # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
486
487
  # Engine#run_turn, rendering via @renderer.
487
488
  # @param images [Array<Hash>] the prompt's `@path` images ({path:})
488
- def run_engine_turn(session, prompt, continue: false, max_iterations: 100, images: [])
489
+ def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [])
489
490
  cancellation_controller = CancellationController.new
490
491
  @active_cancel_controller = cancellation_controller
491
492
  @renderer.begin_turn
@@ -655,6 +656,9 @@ module Samagotchi
655
656
  return
656
657
  end
657
658
  return if show_local_command(input)
659
+ # A typo of a command (/modle) is not a prompt: say so, send nothing.
660
+ hint = command_registry.unknown_command_hint(input)
661
+ return @surface.commit(hint) if hint
658
662
 
659
663
  @turn_flow.before_prompt_turn
660
664
  persist_recent_history(input)
@@ -941,6 +945,11 @@ module Samagotchi
941
945
  # A command, never steering text (/archive waits, back in the prompt).
942
946
  return command_during_turn(line) if local || command_registry.command?(line)
943
947
  return true if line.strip.empty?
948
+ # A typo of a command (/modle) is not steering text: say so, take it.
949
+ if (hint = command_registry.unknown_command_hint(line))
950
+ @surface.commit(hint)
951
+ return true
952
+ end
944
953
  # Steering merges text only: a line with images runs as the next turn.
945
954
  return image_line_waits if ImageInput.extract(line).any?
946
955
 
@@ -46,11 +46,11 @@ module Samagotchi
46
46
  },
47
47
  start_line: {
48
48
  type: "integer",
49
- description: "Optional start line (1-based, inclusive). Must be provided with end_line."
49
+ description: "Optional start line (1-based, inclusive). Without end_line, reads to the end of the file."
50
50
  },
51
51
  end_line: {
52
52
  type: "integer",
53
- description: "Optional end line (1-based, inclusive). Must be provided with start_line."
53
+ description: "Optional end line (1-based, inclusive). Without start_line, reads from line 1."
54
54
  }
55
55
  },
56
56
  required: ["path"]
@@ -139,7 +139,7 @@ module Samagotchi
139
139
  when :canceled
140
140
  canceled_result(child_id)
141
141
  when :no_reply
142
- result(child_id, status, "#{no_reply_text(wait)}; its session shows what happened")
142
+ result(child_id, status, "#{no_reply_text(wait, child_id)}; its session shows what happened")
143
143
  when :worker_gone
144
144
  result(child_id, status,
145
145
  "the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
@@ -168,8 +168,8 @@ module Samagotchi
168
168
  end
169
169
 
170
170
  # ParentReport's line for a turn that left no reply, about the child.
171
- def no_reply_text(wait)
172
- ParentReport.no_reply_line(wait).sub(/\Athe turn/, "the child's turn")
171
+ def no_reply_text(wait, child_id)
172
+ ParentReport.no_reply_line(wait, child_id).sub(/\Athe turn/, "the child's turn")
173
173
  end
174
174
 
175
175
  def result(child_id, status, text)
@@ -184,9 +184,18 @@ module Samagotchi
184
184
 
185
185
  # The whole question, as `chi send --wait` prints it (ParentReport):
186
186
  # its text, options and the commands that answer it.
187
+ # A step-limit question (kind continue) is the parent model's to
188
+ # decide (it isn't relayed: Continue grants no permission): continue
189
+ # it with chi answer through execute, send a follow-up instead, or
190
+ # report back.
187
191
  def waiting_text(child_id, pending)
188
- "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}" \
189
- "delegate_result #{child_id} waits again once it is answered."
192
+ text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
193
+ if ParentReport.continue?(pending)
194
+ text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
195
+ "above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
196
+ "session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
197
+ end
198
+ "#{text}delegate_result #{child_id} waits again once it is answered."
190
199
  end
191
200
 
192
201
  def cut(text)
@@ -62,6 +62,9 @@ module Samagotchi
62
62
  "Error: #{e.message}"
63
63
  end
64
64
 
65
+ START_LINE_REQUIRED = "Error: end_line alone isn't enough for a range edit; " \
66
+ "pass start_line too (1-based, the first line to replace)"
67
+
65
68
  def self.apply_range_mode(new_text, path:, start_line:, end_line:, read:)
66
69
  return "Error: file not found: #{path}" unless File.exist?(path)
67
70
  return NEW_TEXT_REQUIRED if new_text.nil?
@@ -70,7 +73,9 @@ module Samagotchi
70
73
 
71
74
  start_num = parse_positive_line_number(start_line, "start_line")
72
75
  return start_num if start_num.is_a?(String)
73
- return "Error: start_line must be provided for range edits" if start_num.nil?
76
+ # Not defaulted to 1 like read: a range edit replaces the span, so a
77
+ # guessed start would silently overwrite the top of the file.
78
+ return START_LINE_REQUIRED if start_num.nil?
74
79
 
75
80
  source = read.(path)
76
81
  lines = source.lines
@@ -15,11 +15,12 @@ module Samagotchi
15
15
  # <tool name="execute">ruby path/to/script.rb</tool>
16
16
  class Execute
17
17
  NAME = "execute"
18
- TIMEOUT_SEC = 30
18
+ TIMEOUT_SEC = 120
19
19
  STOP_GRACE_SEC = 1.0
20
20
  STOP_POLL_INTERVAL_SEC = 0.05
21
21
  WAIT_SLICE_SEC = 0.2
22
22
  NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
23
+ TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
23
24
 
24
25
  def self.name = NAME
25
26
 
@@ -51,9 +52,9 @@ module Samagotchi
51
52
  parts << (silent ? "exit: #{status.exitstatus} (no output)" : "exit: #{status.exitstatus}")
52
53
  parts.join("\n")
53
54
  rescue CommandTimedOut => e
54
- # Keep the "Error:" first line (callers classify on it) and return
55
- # whatever the command printed before it was killed.
56
- stopped_result("Error: command timed out after #{timeout_sec}s", e)
55
+ # Keep the "Error:" first line (callers classify on it), say how to run
56
+ # a long command, and return whatever it printed before it was killed.
57
+ stopped_result("Error: command timed out after #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
57
58
  rescue CommandCancelled => e
58
59
  stopped_result("Error: command stopped by the user after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
59
60
  rescue => e
@@ -124,7 +124,8 @@ module Samagotchi
124
124
 
125
125
  start_num = parse_positive_line_number(start_line, "start_line")
126
126
  return start_num if start_num.is_a?(String)
127
- return "Error: start_line must be provided for range reads" if start_num.nil?
127
+ # end_line alone reads from the top of the file.
128
+ start_num ||= 1
128
129
 
129
130
  end_provided = !blank?(end_line)
130
131
  end_num = end_provided ? parse_positive_line_number(end_line, "end_line") : nil
@@ -108,12 +108,13 @@ module Samagotchi
108
108
  @checkpoint = nil
109
109
  end
110
110
 
111
- # Answer the offer with no: the interrupted turn is discarded, and a
112
- # reason (with a summary of that turn) is left for the model to read.
111
+ # Answer the offer with no (Stop): the interrupted turn stays, as
112
+ # after a Ctrl-C or a new prompt (!rollback still erases it), and the
113
+ # model reads that it wasn't continued: a note, or the user's reason
114
+ # (with a summary of that turn).
113
115
  def abort_continue!(reason: nil)
114
- restore(@checkpoint) if @checkpoint
115
- @checkpoint = nil
116
- @engine.append_messages([{ role: "user", content: reason_message(reason) }]) if reason
116
+ message = reason ? { role: "user", content: reason_message(reason) } : TurnNote.not_continued
117
+ @engine.append_messages([message])
117
118
  @offer = nil
118
119
  end
119
120
 
@@ -212,9 +213,9 @@ module Samagotchi
212
213
 
213
214
  def reason_message(reason)
214
215
  context = @offer && @offer[:context]
215
- lines = ["I chose not to continue the interrupted turn because: #{reason}"]
216
+ lines = ["I chose not to continue the turn that ran out of steps because: #{reason}"]
216
217
  lines << ""
217
- lines << "Interrupted turn summary:"
218
+ lines << "Its work so far stays. Interrupted turn summary:"
218
219
  original_prompt = context && context[:original_prompt]
219
220
  lines << "- original_prompt: #{original_prompt.to_s.empty? ? "(unavailable)" : original_prompt}"
220
221
 
@@ -131,6 +131,13 @@ module Samagotchi
131
131
  (entry[:kind] || entry["kind"]).to_s == KIND
132
132
  end
133
133
 
134
+ # The step-limit offer answered Stop (no reason): the partial turn
135
+ # stays, and the model reads that it wasn't continued.
136
+ def not_continued
137
+ message("the previous turn ran out of steps before it answered, and the user chose not to continue it. " \
138
+ "Its work so far (tool calls, file changes) stays; wait for the user's next message.")
139
+ end
140
+
134
141
  def message(text)
135
142
  { role: "system", content: "#{OPEN}#{text}#{CLOSE}", kind: KIND }
136
143
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.17.0"
4
+ VERSION = "0.18.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end