samagotchi 0.17.0 → 0.18.1

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +25 -2
  5. data/docs/cli.md +41 -9
  6. data/docs/configuration.md +4 -2
  7. data/docs/guardrails.md +59 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/memory.md +5 -3
  10. data/docs/plugins.md +3 -2
  11. data/docs/sessions.md +6 -5
  12. data/docs/sub-agent.md +42 -4
  13. data/lib/samagotchi/answer_command.rb +23 -2
  14. data/lib/samagotchi/answer_tail.rb +52 -0
  15. data/lib/samagotchi/bridge.rb +49 -8
  16. data/lib/samagotchi/bridge_client.rb +18 -3
  17. data/lib/samagotchi/bundle_command.rb +108 -2
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/client.rb +58 -10
  23. data/lib/samagotchi/commands/registry.rb +28 -0
  24. data/lib/samagotchi/config.rb +7 -1
  25. data/lib/samagotchi/continue_offer.rb +177 -0
  26. data/lib/samagotchi/engine.rb +68 -22
  27. data/lib/samagotchi/guardrail_wiring.rb +56 -3
  28. data/lib/samagotchi/guardrails/outside.rb +46 -0
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  30. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  31. data/lib/samagotchi/guardrails/rules.rb +17 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  33. data/lib/samagotchi/guardrails/targets.rb +30 -13
  34. data/lib/samagotchi/guardrails.rb +2 -0
  35. data/lib/samagotchi/iteration_limit.rb +38 -0
  36. data/lib/samagotchi/kernel_loop.rb +2 -1
  37. data/lib/samagotchi/llm/backend.rb +2 -1
  38. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  39. data/lib/samagotchi/llm/http.rb +9 -3
  40. data/lib/samagotchi/llm/native_backend.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  42. data/lib/samagotchi/model_profile.rb +2 -2
  43. data/lib/samagotchi/output_formatter.rb +9 -4
  44. data/lib/samagotchi/parent_report.rb +39 -9
  45. data/lib/samagotchi/question_desk.rb +205 -46
  46. data/lib/samagotchi/reply_wait.rb +10 -4
  47. data/lib/samagotchi/send_command.rb +2 -1
  48. data/lib/samagotchi/served_model.rb +14 -4
  49. data/lib/samagotchi/session.rb +3 -2
  50. data/lib/samagotchi/session_commands.rb +3 -2
  51. data/lib/samagotchi/session_manager.rb +4 -1
  52. data/lib/samagotchi/sessions_command.rb +3 -2
  53. data/lib/samagotchi/system_prompt.rb +1 -1
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
  55. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  56. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  57. data/lib/samagotchi/terminal_ui.rb +75 -17
  58. data/lib/samagotchi/tool_declarations.rb +4 -4
  59. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  60. data/lib/samagotchi/tools/edit.rb +6 -1
  61. data/lib/samagotchi/tools/execute.rb +102 -23
  62. data/lib/samagotchi/tools/read.rb +2 -1
  63. data/lib/samagotchi/turn_flow.rb +8 -7
  64. data/lib/samagotchi/turn_note.rb +7 -0
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +187 -42
  67. data/lib/samagotchi/web/public/app.js +122 -25
  68. data/lib/samagotchi/web/public/data.js +12 -13
  69. data/lib/samagotchi/web/public/index.html +6 -1
  70. data/lib/samagotchi/web/public/notify.js +5 -2
  71. data/lib/samagotchi/web/public/question_card.js +28 -1
  72. data/lib/samagotchi/web/public/question_cards.js +74 -3
  73. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  74. data/lib/samagotchi/web/public/timing.js +41 -0
  75. data/lib/samagotchi/web/public/turn_events.js +61 -0
  76. data/lib/samagotchi/web/public/update_notice.js +15 -0
  77. data/lib/samagotchi/worker.rb +30 -58
  78. data/lib/samagotchi/worker_sidecar.rb +6 -3
  79. metadata +9 -1
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "iteration_limit"
3
4
  require_relative "model_profile"
4
5
  require_relative "tool_call_parser"
5
6
  require_relative "config"
@@ -174,7 +175,7 @@ module Samagotchi
174
175
  # at the conversation tail (prefix KV cache preserved) and a
175
176
  # :pending_input_merged stream event is emitted.
176
177
  # @return [LLM::ModelResult] final visible response with continuation metadata
177
- def run(messages, max_iterations: 100, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil)
178
+ def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil)
178
179
  @turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
179
180
  turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
180
181
  pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../iteration_limit"
3
4
  require_relative "model_result"
4
5
 
5
6
  module Samagotchi
@@ -14,7 +15,7 @@ module Samagotchi
14
15
  #
15
16
  # The raw-prompt KernelLoop is NativeBackend; the chat loop is ChatLoop.
16
17
  class ModelBackend
17
- def complete(messages:, max_iterations: 100, on_stream_event: nil, cancel_controller: nil,
18
+ def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
18
19
  model_name: nil, max_tool_output_chars: nil, pending_input: nil)
19
20
  raise NotImplementedError, "#{self.class}#complete must be implemented"
20
21
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../iteration_limit"
3
4
  require "json"
4
5
  require_relative "backend"
5
6
  require_relative "model_result"
@@ -56,7 +57,7 @@ module Samagotchi
56
57
 
57
58
  def provider = :chat
58
59
 
59
- def complete(messages:, max_iterations: 100, on_stream_event: nil, cancel_controller: nil,
60
+ def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
60
61
  model_name: nil, max_tool_output_chars: nil, pending_input: nil)
61
62
  raise ArgumentError, "ChatLoop has no adapter" unless @adapter
62
63
 
@@ -168,7 +168,7 @@ module Samagotchi
168
168
 
169
169
  # Send +request+ and return the response with its body read.
170
170
  # @param retries [Boolean] false: one attempt, network errors raised as
171
- # is (Net::HTTP's own silent retry of an idempotent GET included)
171
+ # is; either way Net::HTTP's own silent retry is off (see #start)
172
172
  # @param check_status [Boolean] false: return an error response instead
173
173
  # of raising its ProviderError
174
174
  # @raise [RequestCancelled] when +cancel_controller+ cancels (with or
@@ -178,7 +178,7 @@ module Samagotchi
178
178
  identify(request)
179
179
  current = new_attempt_state(uri, request, log_fields, stream: false)
180
180
  attempt = lambda do |state|
181
- start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: retries ? nil : 0) do |http|
181
+ start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: 0) do |http|
182
182
  state[:http] = http
183
183
  http.request(request).tap do |response|
184
184
  state[:status] = response.code.to_i
@@ -210,7 +210,13 @@ module Samagotchi
210
210
 
211
211
  def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
212
212
  options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
213
- options[:max_retries] = max_retries if max_retries
213
+ # Net::HTTP retries an idempotent request once on its own (max_retries
214
+ # defaults to 1) when the connection drops before an answer: chi's
215
+ # RetryPolicy owns retries (and the log line says how many were sent),
216
+ # so every request here gets Net::HTTP's silent one off. Callers may
217
+ # still ask for it explicitly (a fetch whose retries: false passes 0).
218
+ retries = max_retries || 0
219
+ options[:max_retries] = retries
214
220
  options[:use_ssl] = true if uri.scheme == "https"
215
221
  Net::HTTP.start(uri.host, uri.port, **options, &block)
216
222
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../iteration_limit"
3
4
  require_relative "backend"
4
5
  require_relative "model_result"
5
6
 
@@ -15,7 +16,7 @@ module Samagotchi
15
16
 
16
17
  def provider = :native
17
18
 
18
- def complete(messages:, max_iterations: 100, on_stream_event: nil, cancel_controller: nil,
19
+ def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
19
20
  model_name: nil, max_tool_output_chars: nil, pending_input: nil)
20
21
  @kernel.run(
21
22
  messages,
@@ -9,13 +9,52 @@ module Samagotchi
9
9
  # (uninstall, an upgrade's dropped file): moved, never deleted, into
10
10
  # <system memories>/.bundles/.trash/<bundle>-<YYYYmmdd-HHMMSS>/, one dir
11
11
  # per operation (-2, -3… when one already has that name). A dot dir, so
12
- # Provenance.each_installed never reads it as a bundle. Nothing prunes
13
- # it: the user deletes it when sure.
12
+ # Provenance.each_installed never reads it as a bundle.
13
+ #
14
+ # `chi bundle trash` lists and empties the trash.
14
15
  class Trash
16
+ # A single trash entry (one directory in the trash).
17
+ Entry = Data.define(:name, :path, :time, :files, :bytes)
18
+
15
19
  def self.root(bundles_dir: MemoryPaths.bundles_dir)
16
20
  File.join(bundles_dir, ".trash")
17
21
  end
18
22
 
23
+ # Returns an array of {Entry} for every real directory directly inside
24
+ # the trash, oldest first. A missing or empty trash gives [].
25
+ #
26
+ # +now+ is the reference time (default: Time.now); used only when a
27
+ # directory name has no timestamp (mtime fallback).
28
+ def self.entries(bundles_dir: MemoryPaths.bundles_dir, now: Time.now)
29
+ trash_dir = root(bundles_dir: bundles_dir)
30
+ return [] unless Dir.exist?(trash_dir)
31
+
32
+ Dir.children(trash_dir)
33
+ .select { |c| real_dir?(File.join(trash_dir, c)) }
34
+ .map { |name| entry_for(trash_dir, name, now) }
35
+ .sort_by(&:time)
36
+ end
37
+
38
+ # Deletes every entry that matches the filter, returning the entries
39
+ # that were (or would be) deleted.
40
+ #
41
+ # +older_than_days+ nil = all; a positive integer keeps newer dirs.
42
+ # +dry_run+ true = only return what would be deleted.
43
+ # +now+ reference time (default: Time.now).
44
+ def self.empty!(bundles_dir: MemoryPaths.bundles_dir, older_than_days: nil, now: Time.now, dry_run: false)
45
+ entries = self.entries(bundles_dir: bundles_dir, now: now)
46
+ if older_than_days
47
+ cutoff = now - older_than_days.to_i * 86_400
48
+ entries = entries.select { |e| e.time < cutoff }
49
+ end
50
+ return entries if dry_run
51
+
52
+ entries.each do |e|
53
+ FileUtils.rm_rf(e.path)
54
+ end
55
+ entries
56
+ end
57
+
19
58
  # The dir, once a file was moved (nil before).
20
59
  attr_reader :dir
21
60
 
@@ -52,6 +91,46 @@ module Samagotchi
52
91
  end
53
92
  @dir = candidate
54
93
  end
94
+
95
+ # Class methods below.
96
+
97
+ def self.real_dir?(path)
98
+ File.directory?(path) && !File.symlink?(path)
99
+ end
100
+
101
+ def self.entry_for(trash_dir, name, now)
102
+ path = File.join(trash_dir, name)
103
+ time = parse_timestamp(name) || File.mtime(path)
104
+ files, bytes = count_files(path)
105
+ Entry.new(name: name, path: path, time: time, files: files, bytes: bytes)
106
+ end
107
+
108
+ # Extracts a Time from "bundle-YYYYmmdd-HHMMSS" or "bundle-YYYYmmdd-HHMMSS-2".
109
+ # Returns nil if the pattern doesn't match.
110
+ TIMESTAMP_RE = /\A.*?-(\d{8})-(\d{6})(?:-\d+)?\z/
111
+
112
+ def self.parse_timestamp(name)
113
+ m = name.match(TIMESTAMP_RE)
114
+ return nil unless m
115
+
116
+ begin
117
+ Time.strptime("#{m[1]}-#{m[2]}", "%Y%m%d-%H%M%S")
118
+ rescue ArgumentError
119
+ nil
120
+ end
121
+ end
122
+
123
+ # Recursively counts files and sums their sizes.
124
+ def self.count_files(dir)
125
+ files = 0
126
+ bytes = 0
127
+ Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |f|
128
+ next if File.directory?(f)
129
+ files += 1
130
+ bytes += File.size(f) rescue 0
131
+ end
132
+ [files, bytes]
133
+ end
55
134
  end
56
135
  end
57
136
  end
@@ -112,7 +112,7 @@ module Samagotchi
112
112
  end
113
113
 
114
114
  def self.default
115
- gemma4
115
+ named(DEFAULT_NAME)
116
116
  end
117
117
 
118
118
  def self.normalize(value)
@@ -124,7 +124,7 @@ module Samagotchi
124
124
  when "gemma", "gemma4", "gemma4o"
125
125
  gemma4
126
126
  else
127
- gemma4
127
+ named(DEFAULT_NAME)
128
128
  end
129
129
  end
130
130
 
@@ -45,13 +45,17 @@ module Samagotchi
45
45
  )
46
46
  INDIVIDUAL_RE.freeze
47
47
 
48
- # Angle-bracketed literal tokens stripped wholesale (including the thought
49
- # channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
50
- # are removed as a unit.
51
48
  # Angle-bracketed literal tokens stripped wholesale (including the thought
52
49
  # channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
53
50
  # are removed as a unit. Pipes are escaped; angle brackets are literal.
54
- LITERALS_RE = /<\|"\|>|<\|\w*channel>thought|<end_of_turn>/.freeze
51
+ LITERALS_RE = /<\|"\|>|<end_of_turn>/.freeze
52
+
53
+ # Gemma 4's thought channel, as a whole block: `<|channel>thought` to its
54
+ # `<channel|>` close, or to the end of the text when the close is missing
55
+ # (ToolCallParser::Gemma strips the same pair). Removing only the marker
56
+ # would leave the model's private reasoning in the answer; the channel is
57
+ # the profile's thinking lane, not output.
58
+ GEMMA_THOUGHT_RE = /<\|\w*channel>thought.*?(?:<channel\|>|\z)/m.freeze
55
59
 
56
60
  # Qwen prompt-literal placeholders, e.g. </think>.
57
61
  PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/.freeze
@@ -88,6 +92,7 @@ module Samagotchi
88
92
  def remove_tokens(text)
89
93
  text.to_s
90
94
  .gsub(QWEN_THINK_RE, '')
95
+ .gsub(GEMMA_THOUGHT_RE, '')
91
96
  .gsub(QWEN_TOOL_CALL_RE, '')
92
97
  .gsub(LITERALS_RE, '')
93
98
  .gsub(INDIVIDUAL_RE, '')
@@ -31,8 +31,10 @@ module Samagotchi
31
31
  module_function
32
32
 
33
33
  # @param result [ReplyWait::Result]
34
- # @return [String] answered, question, failed, canceled, no_answer,
35
- # error, worker_gone, stopped or running
34
+ # @return [String] answered, question, failed, canceled, limit (the
35
+ # turn ran out of iterations, nobody asked), not_continued (a Stop
36
+ # answered the step-limit question), no_answer, error, worker_gone,
37
+ # stopped or running
36
38
  def status(result)
37
39
  case result.status
38
40
  when :done then "answered"
@@ -41,6 +43,8 @@ module Samagotchi
41
43
  case result.outcome
42
44
  when "failed" then "failed"
43
45
  when "canceled" then "canceled"
46
+ when "exhausted" then "limit"
47
+ when "not_continued" then "not_continued"
44
48
  else "no_answer"
45
49
  end
46
50
  when :error then "error"
@@ -52,11 +56,12 @@ module Samagotchi
52
56
  end
53
57
  end
54
58
 
55
- # @return [Integer] 0 answered, 3 a question waits, 4 still running,
56
- # 1 anything else
59
+ # @return [Integer] 0 answered (or not continued: the Stop asked for
60
+ # was carried out), 3 a question waits, 4 still running, 1 anything
61
+ # else
57
62
  def exit_status(result)
58
63
  case status(result)
59
- when "answered" then CLI::Exit::OK
64
+ when "answered", "not_continued" then CLI::Exit::OK
60
65
  when "question" then CLI::Exit::QUESTION
61
66
  when "running" then CLI::Exit::RUNNING
62
67
  else CLI::Exit::FAILED
@@ -116,7 +121,8 @@ module Samagotchi
116
121
 
117
122
  # A pending question (Session#pending_question) as a parent sees it.
118
123
  # kind is "question" for the model's own (the desk stores none),
119
- # "hook" or "approval".
124
+ # "hook", "approval" or "continue" (the step-limit question, with the
125
+ # turn's limit).
120
126
  # @return [Hash]
121
127
  def question(pending)
122
128
  pending ||= {}
@@ -129,15 +135,18 @@ module Samagotchi
129
135
  options: Array(pending[:options]).map(&:to_s),
130
136
  multi_select: !!pending[:multi_select],
131
137
  allow_freeform: !!pending[:allow_freeform],
138
+ limit: pending[:limit],
132
139
  approval: approval&.slice(*APPROVAL_KEYS)
133
140
  }.compact
134
141
  end
135
142
 
136
143
  # The command that answers +question+; N is the parent's to fill in.
137
144
  # An approval's is a deny: allowing it is the user's
138
- # (Guardrails::ParentApprovals).
145
+ # (Guardrails::ParentApprovals). A step-limit question's continues the
146
+ # turn (its Stop form is in #question_text).
139
147
  def answer_with(session_id, question)
140
148
  return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
149
+ return "chi answer #{session_id} --question #{question[:id]} --option Continue" if continue?(question)
141
150
 
142
151
  "chi answer #{session_id} --question #{question[:id]} --option N"
143
152
  end
@@ -156,6 +165,16 @@ module Samagotchi
156
165
  question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
157
166
  end
158
167
 
168
+ # The step-limit question (ContinueOffer).
169
+ def continue?(question)
170
+ question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "continue"
171
+ end
172
+
173
+ # What a message does to a step-limit question.
174
+ def message_drops(session_id)
175
+ "a message instead (chi send #{session_id} -m …) drops it and starts a new turn"
176
+ end
177
+
159
178
  # What a wait that ended without an answer says, one line.
160
179
  def detail(result, session_id:, timeout: nil)
161
180
  attach = "chi --attach #{session_id}"
@@ -163,10 +182,13 @@ module Samagotchi
163
182
  when :waiting_for_answer
164
183
  if approval?(result.question)
165
184
  "waiting for an approval: #{first_line(result.question[:question])}; #{DENY_AND_TELL}"
185
+ elsif continue?(result.question)
186
+ "waiting at the step limit: #{first_line(result.question[:question])}; " \
187
+ "answer Continue or Stop: chi answer #{session_id} --question #{result.question[:id]} --option Continue"
166
188
  else
167
189
  "waiting for an answer: #{first_line(result.question&.dig(:question))}; open it: #{attach} or the web"
168
190
  end
169
- when :no_reply then "#{no_reply_line(result)}; #{attach} shows it"
191
+ when :no_reply then "#{no_reply_line(result, session_id)}; #{attach} shows it"
170
192
  when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
171
193
  when :worker_gone then "the worker is gone; #{attach} shows what happened"
172
194
  when :stopped then "the session was stopped (chi sessions stop)"
@@ -188,6 +210,10 @@ module Samagotchi
188
210
  lines << " allowing it is up to your user: #{DENY_AND_TELL}"
189
211
  lines << " deny: #{answer_with(session_id, q)}"
190
212
  lines << " #{leave_open(session_id)}"
213
+ elsif continue?(q)
214
+ lines << " continue: #{answer_with(session_id, q)}"
215
+ lines << " stop: chi answer #{session_id} --question #{q[:id]} --option Stop --text WHY (the text is optional; the model reads it)"
216
+ lines << " #{message_drops(session_id)}"
191
217
  else
192
218
  lines << " more than one allowed: repeat --option" if q[:multi_select]
193
219
  lines << " free text allowed: --text" if q[:allow_freeform]
@@ -197,11 +223,15 @@ module Samagotchi
197
223
  lines.map { |line| "#{line}\n" }.join
198
224
  end
199
225
 
200
- def no_reply_line(result)
226
+ def no_reply_line(result, session_id = nil)
201
227
  case result.outcome
202
228
  when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
203
229
  when "canceled" then "the turn was canceled"
204
230
  when "completed" then "the turn ended with no visible answer"
231
+ when "not_continued" then "the turn was not continued (Stop); its work so far stays"
232
+ when "exhausted"
233
+ "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered; " \
234
+ "chi send #{session_id} -m '/continue yes' continues it, a message drops it"
205
235
  else "the turn ended without a reply (canceled, failed or empty)"
206
236
  end
207
237
  end