samagotchi 0.16.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +38 -2
  5. data/docs/cli.md +37 -9
  6. data/docs/configuration.md +8 -6
  7. data/docs/guardrails.md +51 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/internals/background-tasks.md +1 -0
  10. data/docs/memory.md +41 -0
  11. data/docs/plugins.md +5 -2
  12. data/docs/sessions.md +8 -6
  13. data/docs/sub-agent.md +43 -4
  14. data/docs/testing.md +47 -0
  15. data/lib/samagotchi/answer_command.rb +23 -2
  16. data/lib/samagotchi/answer_tail.rb +52 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  18. data/lib/samagotchi/bridge.rb +86 -14
  19. data/lib/samagotchi/bridge_client.rb +18 -3
  20. data/lib/samagotchi/bundle_command.rb +121 -4
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  26. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  27. data/lib/samagotchi/commands/registry.rb +28 -0
  28. data/lib/samagotchi/config.rb +7 -1
  29. data/lib/samagotchi/continue_offer.rb +177 -0
  30. data/lib/samagotchi/engine.rb +73 -19
  31. data/lib/samagotchi/guardrails/outside.rb +46 -0
  32. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  33. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  34. data/lib/samagotchi/guardrails/rules.rb +17 -5
  35. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  36. data/lib/samagotchi/guardrails/targets.rb +30 -13
  37. data/lib/samagotchi/guardrails.rb +2 -0
  38. data/lib/samagotchi/iteration_limit.rb +38 -0
  39. data/lib/samagotchi/kernel_loop.rb +2 -1
  40. data/lib/samagotchi/llm/backend.rb +2 -1
  41. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  42. data/lib/samagotchi/llm/native_backend.rb +2 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  44. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  45. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  46. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  47. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  49. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  50. data/lib/samagotchi/memory_bundle/trash.rb +136 -0
  51. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  52. data/lib/samagotchi/parent_report.rb +39 -9
  53. data/lib/samagotchi/plugin/context.rb +14 -2
  54. data/lib/samagotchi/question_desk.rb +205 -46
  55. data/lib/samagotchi/reply_wait.rb +10 -4
  56. data/lib/samagotchi/self_report.rb +49 -3
  57. data/lib/samagotchi/send_command.rb +46 -6
  58. data/lib/samagotchi/session.rb +3 -2
  59. data/lib/samagotchi/session_commands.rb +3 -2
  60. data/lib/samagotchi/session_manager.rb +4 -1
  61. data/lib/samagotchi/session_metrics.rb +137 -48
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/system_prompt.rb +46 -5
  64. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  65. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  66. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  67. data/lib/samagotchi/terminal_ui.rb +35 -2
  68. data/lib/samagotchi/tool_declarations.rb +2 -2
  69. data/lib/samagotchi/tools/builtins.rb +11 -3
  70. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  71. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  72. data/lib/samagotchi/tools/edit.rb +6 -1
  73. data/lib/samagotchi/tools/execute.rb +5 -4
  74. data/lib/samagotchi/tools/peers.rb +3 -2
  75. data/lib/samagotchi/tools/read.rb +2 -1
  76. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  77. data/lib/samagotchi/turn_flow.rb +8 -7
  78. data/lib/samagotchi/turn_note.rb +7 -0
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/app.rb +189 -43
  81. data/lib/samagotchi/web/public/app.js +157 -51
  82. data/lib/samagotchi/web/public/data.js +12 -13
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/notify.js +12 -5
  85. data/lib/samagotchi/web/public/question_card.js +28 -1
  86. data/lib/samagotchi/web/public/question_cards.js +74 -3
  87. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  88. data/lib/samagotchi/web/public/stage_view.js +13 -1
  89. data/lib/samagotchi/web/public/strip.js +11 -0
  90. data/lib/samagotchi/web/public/timing.js +67 -12
  91. data/lib/samagotchi/web/public/turn_events.js +74 -3
  92. data/lib/samagotchi/web/public/update_notice.js +15 -0
  93. data/lib/samagotchi/worker.rb +58 -60
  94. data/lib/samagotchi/worker_sidecar.rb +7 -1
  95. metadata +10 -1
@@ -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"
@@ -232,6 +233,10 @@ module Samagotchi
232
233
  @engine.session = session
233
234
  messages = messages_for(session)
234
235
 
236
+ # As typed at the prompt: a session command (/models, !ls) runs as
237
+ # the command, not as a turn.
238
+ return run_one_shot_command(session) if @prompt && @non_interactive && command_registry.command?(@prompt)
239
+
235
240
  if @prompt && @non_interactive
236
241
  # Headless / CI mode: run directly without TTY rendering.
237
242
  ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
@@ -239,7 +244,7 @@ module Samagotchi
239
244
  session,
240
245
  @prompt,
241
246
  on_event: nil,
242
- max_iterations: 1000,
247
+ max_iterations: IterationLimit.for(no_interrupt: true),
243
248
  cancel_controller: nil,
244
249
  images: ImageInput.extract(@prompt)
245
250
  )
@@ -281,6 +286,22 @@ module Samagotchi
281
286
 
282
287
  EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
283
288
 
289
+ # `chi -p "/models" --non-interactive`: the command's output; a refused
290
+ # one fails the run (bin/chi exits 1). The session is saved only when
291
+ # the command changed it.
292
+ # @return [Symbol, nil] :turn_failed when the command was refused
293
+ def run_one_shot_command(session)
294
+ line = @prompt.strip
295
+ result = begin
296
+ @commands.run(line)
297
+ rescue StandardError => e
298
+ SessionCommands::Result.new(status: :error, output: "#{line.split.first}: #{e.message}", changed: [])
299
+ end
300
+ @surface.commit(result.output) unless result.output.to_s.empty?
301
+ session.save unless Array(result.changed).empty?
302
+ :turn_failed if result.status == :error
303
+ end
304
+
284
305
  # Delete the scratch session, the last thing the REPL does. Quiet after
285
306
  # a one-shot or on the way out of an error or a signal.
286
307
  def delete_scratch(session, quiet:)
@@ -465,7 +486,7 @@ module Samagotchi
465
486
  # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
466
487
  # Engine#run_turn, rendering via @renderer.
467
488
  # @param images [Array<Hash>] the prompt's `@path` images ({path:})
468
- 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: [])
469
490
  cancellation_controller = CancellationController.new
470
491
  @active_cancel_controller = cancellation_controller
471
492
  @renderer.begin_turn
@@ -635,6 +656,9 @@ module Samagotchi
635
656
  return
636
657
  end
637
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
638
662
 
639
663
  @turn_flow.before_prompt_turn
640
664
  persist_recent_history(input)
@@ -921,6 +945,11 @@ module Samagotchi
921
945
  # A command, never steering text (/archive waits, back in the prompt).
922
946
  return command_during_turn(line) if local || command_registry.command?(line)
923
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
924
953
  # Steering merges text only: a line with images runs as the next turn.
925
954
  return image_line_waits if ImageInput.extract(line).any?
926
955
 
@@ -1184,6 +1213,10 @@ module Samagotchi
1184
1213
 
1185
1214
  # A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
1186
1215
  def read_choice_line(question_prompt)
1216
+ # Off a terminal the ? prompt can't be taken back: at the input's end
1217
+ # it would stay bare above the summary. eof? waits as gets would.
1218
+ return nil if !$stdin.tty? && $stdin.eof?
1219
+
1187
1220
  @surface.set_slot(:editor, [question_prompt])
1188
1221
  line = $stdin.gets
1189
1222
  # A pipe doesn't echo: the line completes the ? prompt here, once,
@@ -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"]
@@ -105,12 +105,20 @@ module Samagotchi
105
105
  # What execute and task_create export into the commands they run: a
106
106
  # chi started there answers as a parent agent
107
107
  # (Guardrails::ParentApprovals.parent_process?), even under a PTY
108
- # wrapper. The session's id, "chi" without one.
108
+ # wrapper. The session's id, "chi" without one. And the model the
109
+ # session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
110
+ # /model), which chi self reports; unset when unknown, so a value
111
+ # inherited from an outer session never passes for this one.
109
112
  def parent_env(kctx)
110
- id = kctx.peers.respond_to?(:session_id) ? kctx.peers.session_id.to_s : ""
111
- { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id }
113
+ peers = kctx.peers
114
+ id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
115
+ model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
116
+ { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
117
+ SESSION_MODEL_ENV => model.empty? ? nil : model }
112
118
  end
113
119
 
120
+ SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
121
+
114
122
  # Stop flips the turn's controller, seen through the Engine's PeerView
115
123
  # (as DelegateWait does); a bare kernel has no peers: never cancelled.
116
124
  def cancelled_proc(kctx)
@@ -55,6 +55,10 @@ module Samagotchi
55
55
  fields = Relay.card(child, question, relay_id: relay_id, more: more)
56
56
  answer = relay.open_question(fields, watch: watch(child_id, qid, state_dir))
57
57
  settle(answer, fields, client: client, desk: desk, relay_id: relay_id, qid: qid)
58
+ ensure
59
+ # A relay that failed before it settled closes, so the desk (which
60
+ # prunes only settled ones) keeps none open; a settled one stays.
61
+ desk&.close(relay_id) if relay_id
58
62
  end
59
63
 
60
64
  # Closes the card when the child's question is no longer pending (it
@@ -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
@@ -7,10 +7,11 @@ module Samagotchi
7
7
  # folder, where sessions live, and whether the running turn was
8
8
  # canceled (a waiting tool returns on it), and the question flow a
9
9
  # delegate's approval is relayed through (relay: nil when this session
10
- # can't host one). Engine hands KernelLoop one that follows its current
10
+ # can't host one), and the model the session runs on (model_ref: the
11
+ # resolved ref, live after /model). Engine hands KernelLoop one that follows its current
11
12
  # session (Engine::PeerView); this plain one is for specs and a kernel
12
13
  # without an Engine.
13
- Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, keyword_init: true) do
14
+ Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, keyword_init: true) do
14
15
  # @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
15
16
  def cancelled?
16
17
  cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
@@ -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
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  OUTPUT_TAIL_READ_BYTES = 16 * 1024
21
21
  SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
22
22
  # Set by chi (Builtins.parent_env); the model's env can't change it.
23
- MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION].freeze
23
+ MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
24
24
 
25
25
  module_function
26
26
 
@@ -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.16.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